Bladeren bron

fix: fixed copy to clipboard button (#8426)

Co-authored-by: dwelle <[email protected]>
spc-28 11 maanden geleden
bovenliggende
commit
26d2296578

+ 0 - 218
excalidraw-app/collab/RoomDialog.tsx

@@ -1,218 +0,0 @@
-import { useRef, useState } from "react";
-import * as Popover from "@radix-ui/react-popover";
-
-import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
-import { trackEvent } from "../../packages/excalidraw/analytics";
-import { getFrame } from "../../packages/excalidraw/utils";
-import { useI18n } from "../../packages/excalidraw/i18n";
-import { KEYS } from "../../packages/excalidraw/keys";
-
-import { Dialog } from "../../packages/excalidraw/components/Dialog";
-import {
-  copyIcon,
-  playerPlayIcon,
-  playerStopFilledIcon,
-  share,
-  shareIOS,
-  shareWindows,
-  tablerCheckIcon,
-} from "../../packages/excalidraw/components/icons";
-import { TextField } from "../../packages/excalidraw/components/TextField";
-import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
-
-import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
-import "./RoomDialog.scss";
-
-const getShareIcon = () => {
-  const navigator = window.navigator as any;
-  const isAppleBrowser = /Apple/.test(navigator.vendor);
-  const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
-
-  if (isAppleBrowser) {
-    return shareIOS;
-  } else if (isWindowsBrowser) {
-    return shareWindows;
-  }
-
-  return share;
-};
-
-export type RoomModalProps = {
-  handleClose: () => void;
-  activeRoomLink: string;
-  username: string;
-  onUsernameChange: (username: string) => void;
-  onRoomCreate: () => void;
-  onRoomDestroy: () => void;
-  setErrorMessage: (message: string) => void;
-};
-
-export const RoomModal = ({
-  activeRoomLink,
-  onRoomCreate,
-  onRoomDestroy,
-  setErrorMessage,
-  username,
-  onUsernameChange,
-  handleClose,
-}: RoomModalProps) => {
-  const { t } = useI18n();
-  const [justCopied, setJustCopied] = useState(false);
-  const timerRef = useRef<number>(0);
-  const ref = useRef<HTMLInputElement>(null);
-  const isShareSupported = "share" in navigator;
-
-  const copyRoomLink = async () => {
-    try {
-      await copyTextToSystemClipboard(activeRoomLink);
-    } catch (e) {
-      setErrorMessage(t("errors.copyToSystemClipboardFailed"));
-    }
-    setJustCopied(true);
-
-    if (timerRef.current) {
-      window.clearTimeout(timerRef.current);
-    }
-
-    timerRef.current = window.setTimeout(() => {
-      setJustCopied(false);
-    }, 3000);
-
-    ref.current?.select();
-  };
-
-  const shareRoomLink = async () => {
-    try {
-      await navigator.share({
-        title: t("roomDialog.shareTitle"),
-        text: t("roomDialog.shareTitle"),
-        url: activeRoomLink,
-      });
-    } catch (error: any) {
-      // Just ignore.
-    }
-  };
-
-  if (activeRoomLink) {
-    return (
-      <>
-        <h3 className="RoomDialog__active__header">
-          {t("labels.liveCollaboration")}
-        </h3>
-        <TextField
-          value={username}
-          placeholder="Your name"
-          label="Your name"
-          onChange={onUsernameChange}
-          onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
-        />
-        <div className="RoomDialog__active__linkRow">
-          <TextField
-            ref={ref}
-            label="Link"
-            readonly
-            fullWidth
-            value={activeRoomLink}
-          />
-          {isShareSupported && (
-            <FilledButton
-              size="large"
-              variant="icon"
-              label="Share"
-              icon={getShareIcon()}
-              className="RoomDialog__active__share"
-              onClick={shareRoomLink}
-            />
-          )}
-          <Popover.Root open={justCopied}>
-            <Popover.Trigger asChild>
-              <FilledButton
-                size="large"
-                label="Copy link"
-                icon={copyIcon}
-                onClick={copyRoomLink}
-              />
-            </Popover.Trigger>
-            <Popover.Content
-              onOpenAutoFocus={(event) => event.preventDefault()}
-              onCloseAutoFocus={(event) => event.preventDefault()}
-              className="RoomDialog__popover"
-              side="top"
-              align="end"
-              sideOffset={5.5}
-            >
-              {tablerCheckIcon} copied
-            </Popover.Content>
-          </Popover.Root>
-        </div>
-        <div className="RoomDialog__active__description">
-          <p>
-            <span
-              role="img"
-              aria-hidden="true"
-              className="RoomDialog__active__description__emoji"
-            >
-              🔒{" "}
-            </span>
-            {t("roomDialog.desc_privacy")}
-          </p>
-          <p>{t("roomDialog.desc_exitSession")}</p>
-        </div>
-
-        <div className="RoomDialog__active__actions">
-          <FilledButton
-            size="large"
-            variant="outlined"
-            color="danger"
-            label={t("roomDialog.button_stopSession")}
-            icon={playerStopFilledIcon}
-            onClick={() => {
-              trackEvent("share", "room closed");
-              onRoomDestroy();
-            }}
-          />
-        </div>
-      </>
-    );
-  }
-
-  return (
-    <>
-      <div className="RoomDialog__inactive__illustration">
-        <CollabImage />
-      </div>
-      <div className="RoomDialog__inactive__header">
-        {t("labels.liveCollaboration")}
-      </div>
-
-      <div className="RoomDialog__inactive__description">
-        <strong>{t("roomDialog.desc_intro")}</strong>
-        {t("roomDialog.desc_privacy")}
-      </div>
-
-      <div className="RoomDialog__inactive__start_session">
-        <FilledButton
-          size="large"
-          label={t("roomDialog.button_startSession")}
-          icon={playerPlayIcon}
-          onClick={() => {
-            trackEvent("share", "room creation", `ui (${getFrame()})`);
-            onRoomCreate();
-          }}
-        />
-      </div>
-    </>
-  );
-};
-
-const RoomDialog = (props: RoomModalProps) => {
-  return (
-    <Dialog size="small" onCloseRequest={props.handleClose} title={false}>
-      <div className="RoomDialog">
-        <RoomModal {...props} />
-      </div>
-    </Dialog>
-  );
-};
-
-export default RoomDialog;

+ 2 - 2
excalidraw-app/share/ShareDialog.scss

@@ -58,8 +58,8 @@
       font-size: 0.75rem;
       font-size: 0.75rem;
       line-height: 110%;
       line-height: 110%;
 
 
-      background: var(--color-success-lighter);
-      color: var(--color-success);
+      background: var(--color-success);
+      color: var(--color-success-text);
 
 
       & > svg {
       & > svg {
         width: 0.875rem;
         width: 0.875rem;

+ 13 - 23
excalidraw-app/share/ShareDialog.tsx

@@ -1,5 +1,4 @@
 import { useEffect, useRef, useState } from "react";
 import { useEffect, useRef, useState } from "react";
-import * as Popover from "@radix-ui/react-popover";
 import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
 import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
 import { trackEvent } from "../../packages/excalidraw/analytics";
 import { trackEvent } from "../../packages/excalidraw/analytics";
 import { getFrame } from "../../packages/excalidraw/utils";
 import { getFrame } from "../../packages/excalidraw/utils";
@@ -14,7 +13,6 @@ import {
   share,
   share,
   shareIOS,
   shareIOS,
   shareWindows,
   shareWindows,
-  tablerCheckIcon,
 } from "../../packages/excalidraw/components/icons";
 } from "../../packages/excalidraw/components/icons";
 import { TextField } from "../../packages/excalidraw/components/TextField";
 import { TextField } from "../../packages/excalidraw/components/TextField";
 import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
 import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
@@ -24,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "jotai";
 
 
 import "./ShareDialog.scss";
 import "./ShareDialog.scss";
 import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
 import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
+import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
 
 
 type OnExportToBackend = () => void;
 type OnExportToBackend = () => void;
 type ShareDialogType = "share" | "collaborationOnly";
 type ShareDialogType = "share" | "collaborationOnly";
@@ -63,10 +62,11 @@ const ActiveRoomDialog = ({
   handleClose: () => void;
   handleClose: () => void;
 }) => {
 }) => {
   const { t } = useI18n();
   const { t } = useI18n();
-  const [justCopied, setJustCopied] = useState(false);
+  const [, setJustCopied] = useState(false);
   const timerRef = useRef<number>(0);
   const timerRef = useRef<number>(0);
   const ref = useRef<HTMLInputElement>(null);
   const ref = useRef<HTMLInputElement>(null);
   const isShareSupported = "share" in navigator;
   const isShareSupported = "share" in navigator;
+  const { onCopy, copyStatus } = useCopyStatus();
 
 
   const copyRoomLink = async () => {
   const copyRoomLink = async () => {
     try {
     try {
@@ -130,26 +130,16 @@ const ActiveRoomDialog = ({
             onClick={shareRoomLink}
             onClick={shareRoomLink}
           />
           />
         )}
         )}
-        <Popover.Root open={justCopied}>
-          <Popover.Trigger asChild>
-            <FilledButton
-              size="large"
-              label="Copy link"
-              icon={copyIcon}
-              onClick={copyRoomLink}
-            />
-          </Popover.Trigger>
-          <Popover.Content
-            onOpenAutoFocus={(event) => event.preventDefault()}
-            onCloseAutoFocus={(event) => event.preventDefault()}
-            className="ShareDialog__popover"
-            side="top"
-            align="end"
-            sideOffset={5.5}
-          >
-            {tablerCheckIcon} copied
-          </Popover.Content>
-        </Popover.Root>
+        <FilledButton
+          size="large"
+          label={t("buttons.copyLink")}
+          icon={copyIcon}
+          status={copyStatus}
+          onClick={() => {
+            copyRoomLink();
+            onCopy();
+          }}
+        />
       </div>
       </div>
       <div className="ShareDialog__active__description">
       <div className="ShareDialog__active__description">
         <p>
         <p>

+ 54 - 2
packages/excalidraw/components/FilledButton.scss

@@ -16,11 +16,19 @@
 
 
     .Spinner {
     .Spinner {
       --spinner-color: var(--color-surface-lowest);
       --spinner-color: var(--color-surface-lowest);
-      position: absolute;
+    }
+
+    .ExcButton__statusIcon {
       visibility: visible;
       visibility: visible;
+      position: absolute;
+
+      width: 1rem;
+      height: 1rem;
+      font-size: 1rem;
     }
     }
 
 
-    &[disabled] {
+    &.ExcButton--status-loading,
+    &.ExcButton--status-success {
       pointer-events: none;
       pointer-events: none;
 
 
       .ExcButton__contents {
       .ExcButton__contents {
@@ -28,6 +36,10 @@
       }
       }
     }
     }
 
 
+    &[disabled] {
+      pointer-events: none;
+    }
+
     &,
     &,
     &__contents {
     &__contents {
       display: flex;
       display: flex;
@@ -119,6 +131,46 @@
       }
       }
     }
     }
 
 
+    &--color-success {
+      &.ExcButton--variant-filled {
+        --text-color: var(--color-success-text);
+        --back-color: var(--color-success);
+
+        .Spinner {
+          --spinner-color: var(--color-success);
+        }
+
+        &:hover {
+          --back-color: var(--color-success-darker);
+        }
+
+        &:active {
+          --back-color: var(--color-success-darkest);
+        }
+      }
+
+      &.ExcButton--variant-outlined,
+      &.ExcButton--variant-icon {
+        --text-color: var(--color-success-contrast);
+        --border-color: var(--color-success-contrast);
+        --back-color: transparent;
+
+        .Spinner {
+          --spinner-color: var(--color-success-contrast);
+        }
+
+        &:hover {
+          --text-color: var(--color-success-contrast-hover);
+          --border-color: var(--color-success-contrast-hover);
+        }
+
+        &:active {
+          --text-color: var(--color-success-contrast-active);
+          --border-color: var(--color-success-contrast-active);
+        }
+      }
+    }
+
     &--color-muted {
     &--color-muted {
       &.ExcButton--variant-filled {
       &.ExcButton--variant-filled {
         --text-color: var(--island-bg-color);
         --text-color: var(--island-bg-color);

+ 26 - 4
packages/excalidraw/components/FilledButton.tsx

@@ -5,9 +5,15 @@ import "./FilledButton.scss";
 import { AbortError } from "../errors";
 import { AbortError } from "../errors";
 import Spinner from "./Spinner";
 import Spinner from "./Spinner";
 import { isPromiseLike } from "../utils";
 import { isPromiseLike } from "../utils";
+import { tablerCheckIcon } from "./icons";
 
 
 export type ButtonVariant = "filled" | "outlined" | "icon";
 export type ButtonVariant = "filled" | "outlined" | "icon";
-export type ButtonColor = "primary" | "danger" | "warning" | "muted";
+export type ButtonColor =
+  | "primary"
+  | "danger"
+  | "warning"
+  | "muted"
+  | "success";
 export type ButtonSize = "medium" | "large";
 export type ButtonSize = "medium" | "large";
 
 
 export type FilledButtonProps = {
 export type FilledButtonProps = {
@@ -15,6 +21,7 @@ export type FilledButtonProps = {
 
 
   children?: React.ReactNode;
   children?: React.ReactNode;
   onClick?: (event: React.MouseEvent) => void;
   onClick?: (event: React.MouseEvent) => void;
+  status?: null | "loading" | "success";
 
 
   variant?: ButtonVariant;
   variant?: ButtonVariant;
   color?: ButtonColor;
   color?: ButtonColor;
@@ -37,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
       size = "medium",
       size = "medium",
       fullWidth,
       fullWidth,
       className,
       className,
+      status,
     },
     },
     ref,
     ref,
   ) => {
   ) => {
@@ -46,8 +54,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
       const ret = onClick?.(event);
       const ret = onClick?.(event);
 
 
       if (isPromiseLike(ret)) {
       if (isPromiseLike(ret)) {
-        try {
+        // delay loading state to prevent flicker in case of quick response
+        const timer = window.setTimeout(() => {
           setIsLoading(true);
           setIsLoading(true);
+        }, 50);
+        try {
           await ret;
           await ret;
         } catch (error: any) {
         } catch (error: any) {
           if (!(error instanceof AbortError)) {
           if (!(error instanceof AbortError)) {
@@ -56,11 +67,15 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
             console.warn(error);
             console.warn(error);
           }
           }
         } finally {
         } finally {
+          clearTimeout(timer);
           setIsLoading(false);
           setIsLoading(false);
         }
         }
       }
       }
     };
     };
 
 
+    const _status = isLoading ? "loading" : status;
+    color = _status === "success" ? "success" : color;
+
     return (
     return (
       <button
       <button
         className={clsx(
         className={clsx(
@@ -68,6 +83,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
           `ExcButton--color-${color}`,
           `ExcButton--color-${color}`,
           `ExcButton--variant-${variant}`,
           `ExcButton--variant-${variant}`,
           `ExcButton--size-${size}`,
           `ExcButton--size-${size}`,
+          `ExcButton--status-${_status}`,
           { "ExcButton--fullWidth": fullWidth },
           { "ExcButton--fullWidth": fullWidth },
           className,
           className,
         )}
         )}
@@ -75,10 +91,16 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
         type="button"
         type="button"
         aria-label={label}
         aria-label={label}
         ref={ref}
         ref={ref}
-        disabled={isLoading}
+        disabled={_status === "loading" || _status === "success"}
       >
       >
         <div className="ExcButton__contents">
         <div className="ExcButton__contents">
-          {isLoading && <Spinner />}
+          {_status === "loading" ? (
+            <Spinner className="ExcButton__statusIcon" />
+          ) : (
+            _status === "success" && (
+              <div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
+            )
+          )}
           {icon && (
           {icon && (
             <div className="ExcButton__icon" aria-hidden>
             <div className="ExcButton__icon" aria-hidden>
               {icon}
               {icon}

+ 14 - 5
packages/excalidraw/components/ImageExportDialog.tsx

@@ -35,6 +35,7 @@ import "./ImageExportDialog.scss";
 import { FilledButton } from "./FilledButton";
 import { FilledButton } from "./FilledButton";
 import { cloneJSON } from "../utils";
 import { cloneJSON } from "../utils";
 import { prepareElementsForExport } from "../data";
 import { prepareElementsForExport } from "../data";
+import { useCopyStatus } from "../hooks/useCopiedIndicator";
 
 
 const supportsContextFilters =
 const supportsContextFilters =
   "filter" in document.createElement("canvas").getContext("2d")!;
   "filter" in document.createElement("canvas").getContext("2d")!;
@@ -89,6 +90,8 @@ const ImageExportModal = ({
   const previewRef = useRef<HTMLDivElement>(null);
   const previewRef = useRef<HTMLDivElement>(null);
   const [renderError, setRenderError] = useState<Error | null>(null);
   const [renderError, setRenderError] = useState<Error | null>(null);
 
 
+  const { onCopy, copyStatus } = useCopyStatus();
+
   const { exportedElements, exportingFrame } = prepareElementsForExport(
   const { exportedElements, exportingFrame } = prepareElementsForExport(
     elementsSnapshot,
     elementsSnapshot,
     appStateSnapshot,
     appStateSnapshot,
@@ -294,11 +297,17 @@ const ImageExportModal = ({
             <FilledButton
             <FilledButton
               className="ImageExportModal__settings__buttons__button"
               className="ImageExportModal__settings__buttons__button"
               label={t("imageExportDialog.title.copyPngToClipboard")}
               label={t("imageExportDialog.title.copyPngToClipboard")}
-              onClick={() =>
-                onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
-                  exportingFrame,
-                })
-              }
+              status={copyStatus}
+              onClick={async () => {
+                await onExportImage(
+                  EXPORT_IMAGE_TYPES.clipboard,
+                  exportedElements,
+                  {
+                    exportingFrame,
+                  },
+                );
+                onCopy();
+              }}
               icon={copyIcon}
               icon={copyIcon}
             >
             >
               {t("imageExportDialog.button.copyPngToClipboard")}
               {t("imageExportDialog.button.copyPngToClipboard")}

+ 2 - 2
packages/excalidraw/components/ShareableLinkDialog.scss

@@ -52,8 +52,8 @@
       font-size: 0.75rem;
       font-size: 0.75rem;
       line-height: 110%;
       line-height: 110%;
 
 
-      background: var(--color-success-lighter);
-      color: var(--color-success);
+      background: var(--color-success);
+      color: var(--color-success-text);
 
 
       & > svg {
       & > svg {
         width: 0.875rem;
         width: 0.875rem;

+ 14 - 24
packages/excalidraw/components/ShareableLinkDialog.tsx

@@ -1,5 +1,4 @@
 import { useRef, useState } from "react";
 import { useRef, useState } from "react";
-import * as Popover from "@radix-ui/react-popover";
 
 
 import { copyTextToSystemClipboard } from "../clipboard";
 import { copyTextToSystemClipboard } from "../clipboard";
 import { useI18n } from "../i18n";
 import { useI18n } from "../i18n";
@@ -7,7 +6,8 @@ import { useI18n } from "../i18n";
 import { Dialog } from "./Dialog";
 import { Dialog } from "./Dialog";
 import { TextField } from "./TextField";
 import { TextField } from "./TextField";
 import { FilledButton } from "./FilledButton";
 import { FilledButton } from "./FilledButton";
-import { copyIcon, tablerCheckIcon } from "./icons";
+import { useCopyStatus } from "../hooks/useCopiedIndicator";
+import { copyIcon } from "./icons";
 
 
 import "./ShareableLinkDialog.scss";
 import "./ShareableLinkDialog.scss";
 
 
@@ -24,7 +24,7 @@ export const ShareableLinkDialog = ({
   setErrorMessage,
   setErrorMessage,
 }: ShareableLinkDialogProps) => {
 }: ShareableLinkDialogProps) => {
   const { t } = useI18n();
   const { t } = useI18n();
-  const [justCopied, setJustCopied] = useState(false);
+  const [, setJustCopied] = useState(false);
   const timerRef = useRef<number>(0);
   const timerRef = useRef<number>(0);
   const ref = useRef<HTMLInputElement>(null);
   const ref = useRef<HTMLInputElement>(null);
 
 
@@ -46,7 +46,7 @@ export const ShareableLinkDialog = ({
 
 
     ref.current?.select();
     ref.current?.select();
   };
   };
-
+  const { onCopy, copyStatus } = useCopyStatus();
   return (
   return (
     <Dialog onCloseRequest={onCloseRequest} title={false} size="small">
     <Dialog onCloseRequest={onCloseRequest} title={false} size="small">
       <div className="ShareableLinkDialog">
       <div className="ShareableLinkDialog">
@@ -60,26 +60,16 @@ export const ShareableLinkDialog = ({
             value={link}
             value={link}
             selectOnRender
             selectOnRender
           />
           />
-          <Popover.Root open={justCopied}>
-            <Popover.Trigger asChild>
-              <FilledButton
-                size="large"
-                label="Copy link"
-                icon={copyIcon}
-                onClick={copyRoomLink}
-              />
-            </Popover.Trigger>
-            <Popover.Content
-              onOpenAutoFocus={(event) => event.preventDefault()}
-              onCloseAutoFocus={(event) => event.preventDefault()}
-              className="ShareableLinkDialog__popover"
-              side="top"
-              align="end"
-              sideOffset={5.5}
-            >
-              {tablerCheckIcon} copied
-            </Popover.Content>
-          </Popover.Root>
+          <FilledButton
+            size="large"
+            label={t("buttons.copyLink")}
+            icon={copyIcon}
+            status={copyStatus}
+            onClick={() => {
+              onCopy();
+              copyRoomLink();
+            }}
+          />
         </div>
         </div>
         <div className="ShareableLinkDialog__description">
         <div className="ShareableLinkDialog__description">
           🔒 {t("alerts.uploadedSecurly")}
           🔒 {t("alerts.uploadedSecurly")}

+ 3 - 1
packages/excalidraw/components/Spinner.tsx

@@ -6,16 +6,18 @@ const Spinner = ({
   size = "1em",
   size = "1em",
   circleWidth = 8,
   circleWidth = 8,
   synchronized = false,
   synchronized = false,
+  className = "",
 }: {
 }: {
   size?: string | number;
   size?: string | number;
   circleWidth?: number;
   circleWidth?: number;
   synchronized?: boolean;
   synchronized?: boolean;
+  className?: string;
 }) => {
 }) => {
   const mountTime = React.useRef(Date.now());
   const mountTime = React.useRef(Date.now());
   const mountDelay = -(mountTime.current % 1600);
   const mountDelay = -(mountTime.current % 1600);
 
 
   return (
   return (
-    <div className="Spinner">
+    <div className={`Spinner ${className}`}>
       <svg
       <svg
         viewBox="0 0 100 100"
         viewBox="0 0 100 100"
         style={{
         style={{

+ 8 - 2
packages/excalidraw/css/theme.scss

@@ -129,8 +129,14 @@
   --color-muted-background-darker: var(--color-gray-100);
   --color-muted-background-darker: var(--color-gray-100);
 
 
   --color-promo: var(--color-primary);
   --color-promo: var(--color-primary);
-  --color-success: #268029;
-  --color-success-lighter: #cafccc;
+
+  --color-success: #cafccc;
+  --color-success-darker: #bafabc;
+  --color-success-darkest: #a5eba8;
+  --color-success-text: #268029;
+  --color-success-contrast: #65bb6a;
+  --color-success-contrast-hover: #6bcf70;
+  --color-success-contrast-active: #6edf74;
 
 
   --color-logo-icon: var(--color-primary);
   --color-logo-icon: var(--color-primary);
   --color-logo-text: #190064;
   --color-logo-text: #190064;

+ 22 - 0
packages/excalidraw/hooks/useCopiedIndicator.ts

@@ -0,0 +1,22 @@
+import { useRef, useState } from "react";
+
+const TIMEOUT = 2000;
+
+export const useCopyStatus = () => {
+  const [copyStatus, setCopyStatus] = useState<"success" | null>(null);
+  const timeoutRef = useRef<number>(0);
+
+  const onCopy = () => {
+    clearTimeout(timeoutRef.current);
+    setCopyStatus("success");
+
+    timeoutRef.current = window.setTimeout(() => {
+      setCopyStatus(null);
+    }, TIMEOUT);
+  };
+
+  return {
+    copyStatus,
+    onCopy,
+  };
+};

+ 1 - 0
packages/excalidraw/locales/en.json

@@ -168,6 +168,7 @@
     "exportImage": "Export image...",
     "exportImage": "Export image...",
     "export": "Save to...",
     "export": "Save to...",
     "copyToClipboard": "Copy to clipboard",
     "copyToClipboard": "Copy to clipboard",
+    "copyLink": "Copy link",
     "save": "Save to current file",
     "save": "Save to current file",
     "saveAs": "Save as",
     "saveAs": "Save as",
     "load": "Open",
     "load": "Open",