mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 12:54:23 +01:00 
			
		
		
		
	feat: add loading state to FilledButton (#7650)
This commit is contained in:
		@@ -10,6 +10,7 @@ import {
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { isPromiseLike } from "../utils";
 | 
			
		||||
 | 
			
		||||
const trackAction = (
 | 
			
		||||
  action: Action,
 | 
			
		||||
@@ -55,7 +56,7 @@ export class ActionManager {
 | 
			
		||||
    app: AppClassProperties,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.updater = (actionResult) => {
 | 
			
		||||
      if (actionResult && "then" in actionResult) {
 | 
			
		||||
      if (isPromiseLike(actionResult)) {
 | 
			
		||||
        actionResult.then((actionResult) => {
 | 
			
		||||
          return updater(actionResult);
 | 
			
		||||
        });
 | 
			
		||||
 
 | 
			
		||||
@@ -10,11 +10,39 @@
 | 
			
		||||
    background-color: var(--back-color);
 | 
			
		||||
    border-color: var(--border-color);
 | 
			
		||||
 | 
			
		||||
    .Spinner {
 | 
			
		||||
      --spinner-color: var(--color-surface-lowest);
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      visibility: visible;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &[disabled] {
 | 
			
		||||
      pointer-events: none;
 | 
			
		||||
 | 
			
		||||
      .ExcButton__contents {
 | 
			
		||||
        visibility: hidden;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__contents {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      flex-shrink: 0;
 | 
			
		||||
      flex-wrap: nowrap;
 | 
			
		||||
      // needed because of .Spinner
 | 
			
		||||
      position: relative;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--color-primary {
 | 
			
		||||
      &.ExcButton--variant-filled {
 | 
			
		||||
        --text-color: var(--color-surface-lowest);
 | 
			
		||||
        --back-color: var(--color-primary);
 | 
			
		||||
 | 
			
		||||
        .Spinner {
 | 
			
		||||
          --spinner-color: var(--text-color);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          --back-color: var(--color-brand-hover);
 | 
			
		||||
        }
 | 
			
		||||
@@ -27,9 +55,13 @@
 | 
			
		||||
      &.ExcButton--variant-outlined,
 | 
			
		||||
      &.ExcButton--variant-icon {
 | 
			
		||||
        --text-color: var(--color-primary);
 | 
			
		||||
        --border-color: var(--color-border-outline);
 | 
			
		||||
        --border-color: var(--color-primary);
 | 
			
		||||
        --back-color: transparent;
 | 
			
		||||
 | 
			
		||||
        .Spinner {
 | 
			
		||||
          --spinner-color: var(--text-color);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          --text-color: var(--color-brand-hover);
 | 
			
		||||
          --border-color: var(--color-brand-hover);
 | 
			
		||||
@@ -47,6 +79,10 @@
 | 
			
		||||
        --text-color: var(--color-danger-text);
 | 
			
		||||
        --back-color: var(--color-danger-dark);
 | 
			
		||||
 | 
			
		||||
        .Spinner {
 | 
			
		||||
          --spinner-color: var(--text-color);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          --back-color: var(--color-danger-darker);
 | 
			
		||||
        }
 | 
			
		||||
@@ -62,6 +98,10 @@
 | 
			
		||||
        --border-color: var(--color-danger);
 | 
			
		||||
        --back-color: transparent;
 | 
			
		||||
 | 
			
		||||
        .Spinner {
 | 
			
		||||
          --spinner-color: var(--text-color);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          --text-color: var(--color-danger-darkest);
 | 
			
		||||
          --border-color: var(--color-danger-darkest);
 | 
			
		||||
@@ -79,6 +119,10 @@
 | 
			
		||||
        --text-color: var(--island-bg-color);
 | 
			
		||||
        --back-color: var(--color-gray-50);
 | 
			
		||||
 | 
			
		||||
        .Spinner {
 | 
			
		||||
          --spinner-color: var(--text-color);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          --back-color: var(--color-gray-60);
 | 
			
		||||
        }
 | 
			
		||||
@@ -94,6 +138,10 @@
 | 
			
		||||
        --border-color: var(--color-muted);
 | 
			
		||||
        --back-color: var(--island-bg-color);
 | 
			
		||||
 | 
			
		||||
        .Spinner {
 | 
			
		||||
          --spinner-color: var(--text-color);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          --text-color: var(--color-muted-background-darker);
 | 
			
		||||
          --border-color: var(--color-muted-darker);
 | 
			
		||||
@@ -111,6 +159,10 @@
 | 
			
		||||
        --text-color: black;
 | 
			
		||||
        --back-color: var(--color-warning-dark);
 | 
			
		||||
 | 
			
		||||
        .Spinner {
 | 
			
		||||
          --spinner-color: var(--text-color);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          --back-color: var(--color-warning-darker);
 | 
			
		||||
        }
 | 
			
		||||
@@ -126,6 +178,10 @@
 | 
			
		||||
        --border-color: var(--color-warning-dark);
 | 
			
		||||
        --back-color: var(--input-bg-color);
 | 
			
		||||
 | 
			
		||||
        .Spinner {
 | 
			
		||||
          --spinner-color: var(--text-color);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          --text-color: var(--color-warning-darker);
 | 
			
		||||
          --border-color: var(--color-warning-darker);
 | 
			
		||||
@@ -138,17 +194,11 @@
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    flex-shrink: 0;
 | 
			
		||||
    flex-wrap: nowrap;
 | 
			
		||||
 | 
			
		||||
    border-radius: 0.5rem;
 | 
			
		||||
    border-width: 1px;
 | 
			
		||||
    border-style: solid;
 | 
			
		||||
 | 
			
		||||
    font-family: "Assistant";
 | 
			
		||||
    font-family: var(--font-family);
 | 
			
		||||
 | 
			
		||||
    user-select: none;
 | 
			
		||||
 | 
			
		||||
@@ -159,9 +209,12 @@
 | 
			
		||||
      font-size: 0.875rem;
 | 
			
		||||
      min-height: 3rem;
 | 
			
		||||
      padding: 0.5rem 1.5rem;
 | 
			
		||||
      gap: 0.75rem;
 | 
			
		||||
 | 
			
		||||
      letter-spacing: 0.4px;
 | 
			
		||||
 | 
			
		||||
      .ExcButton__contents {
 | 
			
		||||
        gap: 0.75rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--size-medium {
 | 
			
		||||
@@ -169,9 +222,12 @@
 | 
			
		||||
      font-size: 0.75rem;
 | 
			
		||||
      min-height: 2.5rem;
 | 
			
		||||
      padding: 0.5rem 1rem;
 | 
			
		||||
      gap: 0.5rem;
 | 
			
		||||
 | 
			
		||||
      letter-spacing: normal;
 | 
			
		||||
 | 
			
		||||
      .ExcButton__contents {
 | 
			
		||||
        gap: 0.5rem;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--variant-icon {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,10 @@
 | 
			
		||||
import React, { forwardRef } from "react";
 | 
			
		||||
import React, { forwardRef, useState } from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
 | 
			
		||||
import "./FilledButton.scss";
 | 
			
		||||
import { AbortError } from "../errors";
 | 
			
		||||
import Spinner from "./Spinner";
 | 
			
		||||
import { isPromiseLike } from "../utils";
 | 
			
		||||
 | 
			
		||||
export type ButtonVariant = "filled" | "outlined" | "icon";
 | 
			
		||||
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
 | 
			
		||||
@@ -11,7 +14,7 @@ export type FilledButtonProps = {
 | 
			
		||||
  label: string;
 | 
			
		||||
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
  onClick?: (event: React.MouseEvent) => void;
 | 
			
		||||
 | 
			
		||||
  variant?: ButtonVariant;
 | 
			
		||||
  color?: ButtonColor;
 | 
			
		||||
@@ -19,14 +22,14 @@ export type FilledButtonProps = {
 | 
			
		||||
  className?: string;
 | 
			
		||||
  fullWidth?: boolean;
 | 
			
		||||
 | 
			
		||||
  startIcon?: React.ReactNode;
 | 
			
		||||
  icon?: React.ReactNode;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
 | 
			
		||||
  (
 | 
			
		||||
    {
 | 
			
		||||
      children,
 | 
			
		||||
      startIcon,
 | 
			
		||||
      icon,
 | 
			
		||||
      onClick,
 | 
			
		||||
      label,
 | 
			
		||||
      variant = "filled",
 | 
			
		||||
@@ -37,6 +40,27 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
 | 
			
		||||
    },
 | 
			
		||||
    ref,
 | 
			
		||||
  ) => {
 | 
			
		||||
    const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
 | 
			
		||||
    const _onClick = async (event: React.MouseEvent) => {
 | 
			
		||||
      const ret = onClick?.(event);
 | 
			
		||||
 | 
			
		||||
      if (isPromiseLike(ret)) {
 | 
			
		||||
        try {
 | 
			
		||||
          setIsLoading(true);
 | 
			
		||||
          await ret;
 | 
			
		||||
        } catch (error: any) {
 | 
			
		||||
          if (!(error instanceof AbortError)) {
 | 
			
		||||
            throw error;
 | 
			
		||||
          } else {
 | 
			
		||||
            console.warn(error);
 | 
			
		||||
          }
 | 
			
		||||
        } finally {
 | 
			
		||||
          setIsLoading(false);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <button
 | 
			
		||||
        className={clsx(
 | 
			
		||||
@@ -47,17 +71,21 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
 | 
			
		||||
          { "ExcButton--fullWidth": fullWidth },
 | 
			
		||||
          className,
 | 
			
		||||
        )}
 | 
			
		||||
        onClick={onClick}
 | 
			
		||||
        onClick={_onClick}
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-label={label}
 | 
			
		||||
        ref={ref}
 | 
			
		||||
        disabled={isLoading}
 | 
			
		||||
      >
 | 
			
		||||
        {startIcon && (
 | 
			
		||||
          <div className="ExcButton__icon" aria-hidden>
 | 
			
		||||
            {startIcon}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        {variant !== "icon" && (children ?? label)}
 | 
			
		||||
        <div className="ExcButton__contents">
 | 
			
		||||
          {isLoading && <Spinner />}
 | 
			
		||||
          {icon && (
 | 
			
		||||
            <div className="ExcButton__icon" aria-hidden>
 | 
			
		||||
              {icon}
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
          {variant !== "icon" && (children ?? label)}
 | 
			
		||||
        </div>
 | 
			
		||||
      </button>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,8 @@
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    justify-content: space-between;
 | 
			
		||||
 | 
			
		||||
    user-select: none;
 | 
			
		||||
 | 
			
		||||
    & h3 {
 | 
			
		||||
      font-family: "Assistant";
 | 
			
		||||
      font-style: normal;
 | 
			
		||||
 
 | 
			
		||||
@@ -271,7 +271,7 @@ const ImageExportModal = ({
 | 
			
		||||
                exportingFrame,
 | 
			
		||||
              })
 | 
			
		||||
            }
 | 
			
		||||
            startIcon={downloadIcon}
 | 
			
		||||
            icon={downloadIcon}
 | 
			
		||||
          >
 | 
			
		||||
            {t("imageExportDialog.button.exportToPng")}
 | 
			
		||||
          </FilledButton>
 | 
			
		||||
@@ -283,7 +283,7 @@ const ImageExportModal = ({
 | 
			
		||||
                exportingFrame,
 | 
			
		||||
              })
 | 
			
		||||
            }
 | 
			
		||||
            startIcon={downloadIcon}
 | 
			
		||||
            icon={downloadIcon}
 | 
			
		||||
          >
 | 
			
		||||
            {t("imageExportDialog.button.exportToSvg")}
 | 
			
		||||
          </FilledButton>
 | 
			
		||||
@@ -296,7 +296,7 @@ const ImageExportModal = ({
 | 
			
		||||
                  exportingFrame,
 | 
			
		||||
                })
 | 
			
		||||
              }
 | 
			
		||||
              startIcon={copyIcon}
 | 
			
		||||
              icon={copyIcon}
 | 
			
		||||
            >
 | 
			
		||||
              {t("imageExportDialog.button.copyPngToClipboard")}
 | 
			
		||||
            </FilledButton>
 | 
			
		||||
 
 | 
			
		||||
@@ -66,7 +66,7 @@ export const ShareableLinkDialog = ({
 | 
			
		||||
              <FilledButton
 | 
			
		||||
                size="large"
 | 
			
		||||
                label="Copy link"
 | 
			
		||||
                startIcon={copyIcon}
 | 
			
		||||
                icon={copyIcon}
 | 
			
		||||
                onClick={copyRoomLink}
 | 
			
		||||
              />
 | 
			
		||||
            </Popover.Trigger>
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import { useExcalidrawContainer } from "./App";
 | 
			
		||||
import { AbortError } from "../errors";
 | 
			
		||||
import Spinner from "./Spinner";
 | 
			
		||||
import { PointerType } from "../element/types";
 | 
			
		||||
import { isPromiseLike } from "../utils";
 | 
			
		||||
 | 
			
		||||
export type ToolButtonSize = "small" | "medium";
 | 
			
		||||
 | 
			
		||||
@@ -65,7 +66,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 | 
			
		||||
  const onClick = async (event: React.MouseEvent) => {
 | 
			
		||||
    const ret = "onClick" in props && props.onClick?.(event);
 | 
			
		||||
 | 
			
		||||
    if (ret && "then" in ret) {
 | 
			
		||||
    if (isPromiseLike(ret)) {
 | 
			
		||||
      try {
 | 
			
		||||
        setIsLoading(true);
 | 
			
		||||
        await ret;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user