mirror of
https://github.com/excalidraw/excalidraw.git
synced 2024-11-02 03:25:53 +01:00
fix: fixed copy to clipboard button (#8426)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
afb68a6467
commit
26d2296578
@ -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;
|
@ -58,8 +58,8 @@
|
||||
font-size: 0.75rem;
|
||||
line-height: 110%;
|
||||
|
||||
background: var(--color-success-lighter);
|
||||
color: var(--color-success);
|
||||
background: var(--color-success);
|
||||
color: var(--color-success-text);
|
||||
|
||||
& > svg {
|
||||
width: 0.875rem;
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useEffect, 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";
|
||||
@ -14,7 +13,6 @@ import {
|
||||
share,
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
tablerCheckIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
@ -24,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "jotai";
|
||||
|
||||
import "./ShareDialog.scss";
|
||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
||||
|
||||
type OnExportToBackend = () => void;
|
||||
type ShareDialogType = "share" | "collaborationOnly";
|
||||
@ -63,10 +62,11 @@ const ActiveRoomDialog = ({
|
||||
handleClose: () => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const [, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isShareSupported = "share" in navigator;
|
||||
const { onCopy, copyStatus } = useCopyStatus();
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
@ -130,26 +130,16 @@ const ActiveRoomDialog = ({
|
||||
onClick={shareRoomLink}
|
||||
/>
|
||||
)}
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
label={t("buttons.copyLink")}
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
status={copyStatus}
|
||||
onClick={() => {
|
||||
copyRoomLink();
|
||||
onCopy();
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
<div className="ShareDialog__active__description">
|
||||
<p>
|
||||
|
@ -16,11 +16,19 @@
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--color-surface-lowest);
|
||||
position: absolute;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
.ExcButton__statusIcon {
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&.ExcButton--status-loading,
|
||||
&.ExcButton--status-success {
|
||||
pointer-events: none;
|
||||
|
||||
.ExcButton__contents {
|
||||
@ -28,6 +36,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&,
|
||||
&__contents {
|
||||
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 {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--island-bg-color);
|
||||
|
@ -5,9 +5,15 @@ import "./FilledButton.scss";
|
||||
import { AbortError } from "../errors";
|
||||
import Spinner from "./Spinner";
|
||||
import { isPromiseLike } from "../utils";
|
||||
import { tablerCheckIcon } from "./icons";
|
||||
|
||||
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 FilledButtonProps = {
|
||||
@ -15,6 +21,7 @@ export type FilledButtonProps = {
|
||||
|
||||
children?: React.ReactNode;
|
||||
onClick?: (event: React.MouseEvent) => void;
|
||||
status?: null | "loading" | "success";
|
||||
|
||||
variant?: ButtonVariant;
|
||||
color?: ButtonColor;
|
||||
@ -37,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
size = "medium",
|
||||
fullWidth,
|
||||
className,
|
||||
status,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@ -46,8 +54,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
const ret = onClick?.(event);
|
||||
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
// delay loading state to prevent flicker in case of quick response
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsLoading(true);
|
||||
}, 50);
|
||||
try {
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
@ -56,11 +67,15 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const _status = isLoading ? "loading" : status;
|
||||
color = _status === "success" ? "success" : color;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
@ -68,6 +83,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
`ExcButton--color-${color}`,
|
||||
`ExcButton--variant-${variant}`,
|
||||
`ExcButton--size-${size}`,
|
||||
`ExcButton--status-${_status}`,
|
||||
{ "ExcButton--fullWidth": fullWidth },
|
||||
className,
|
||||
)}
|
||||
@ -75,10 +91,16 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
type="button"
|
||||
aria-label={label}
|
||||
ref={ref}
|
||||
disabled={isLoading}
|
||||
disabled={_status === "loading" || _status === "success"}
|
||||
>
|
||||
<div className="ExcButton__contents">
|
||||
{isLoading && <Spinner />}
|
||||
{_status === "loading" ? (
|
||||
<Spinner className="ExcButton__statusIcon" />
|
||||
) : (
|
||||
_status === "success" && (
|
||||
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
|
||||
)
|
||||
)}
|
||||
{icon && (
|
||||
<div className="ExcButton__icon" aria-hidden>
|
||||
{icon}
|
||||
|
@ -35,6 +35,7 @@ import "./ImageExportDialog.scss";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { cloneJSON } from "../utils";
|
||||
import { prepareElementsForExport } from "../data";
|
||||
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
@ -89,6 +90,8 @@ const ImageExportModal = ({
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const { onCopy, copyStatus } = useCopyStatus();
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elementsSnapshot,
|
||||
appStateSnapshot,
|
||||
@ -294,11 +297,17 @@ const ImageExportModal = ({
|
||||
<FilledButton
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.copyPngToClipboard")}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
|
||||
status={copyStatus}
|
||||
onClick={async () => {
|
||||
await onExportImage(
|
||||
EXPORT_IMAGE_TYPES.clipboard,
|
||||
exportedElements,
|
||||
{
|
||||
exportingFrame,
|
||||
})
|
||||
}
|
||||
},
|
||||
);
|
||||
onCopy();
|
||||
}}
|
||||
icon={copyIcon}
|
||||
>
|
||||
{t("imageExportDialog.button.copyPngToClipboard")}
|
||||
|
@ -52,8 +52,8 @@
|
||||
font-size: 0.75rem;
|
||||
line-height: 110%;
|
||||
|
||||
background: var(--color-success-lighter);
|
||||
color: var(--color-success);
|
||||
background: var(--color-success);
|
||||
color: var(--color-success-text);
|
||||
|
||||
& > svg {
|
||||
width: 0.875rem;
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { useI18n } from "../i18n";
|
||||
@ -7,7 +6,8 @@ import { useI18n } from "../i18n";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { TextField } from "./TextField";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { copyIcon, tablerCheckIcon } from "./icons";
|
||||
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
||||
import { copyIcon } from "./icons";
|
||||
|
||||
import "./ShareableLinkDialog.scss";
|
||||
|
||||
@ -24,7 +24,7 @@ export const ShareableLinkDialog = ({
|
||||
setErrorMessage,
|
||||
}: ShareableLinkDialogProps) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const [, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
@ -46,7 +46,7 @@ export const ShareableLinkDialog = ({
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
|
||||
const { onCopy, copyStatus } = useCopyStatus();
|
||||
return (
|
||||
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
|
||||
<div className="ShareableLinkDialog">
|
||||
@ -60,26 +60,16 @@ export const ShareableLinkDialog = ({
|
||||
value={link}
|
||||
selectOnRender
|
||||
/>
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
label={t("buttons.copyLink")}
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
status={copyStatus}
|
||||
onClick={() => {
|
||||
onCopy();
|
||||
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>
|
||||
</div>
|
||||
<div className="ShareableLinkDialog__description">
|
||||
🔒 {t("alerts.uploadedSecurly")}
|
||||
|
@ -6,16 +6,18 @@ const Spinner = ({
|
||||
size = "1em",
|
||||
circleWidth = 8,
|
||||
synchronized = false,
|
||||
className = "",
|
||||
}: {
|
||||
size?: string | number;
|
||||
circleWidth?: number;
|
||||
synchronized?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const mountTime = React.useRef(Date.now());
|
||||
const mountDelay = -(mountTime.current % 1600);
|
||||
|
||||
return (
|
||||
<div className="Spinner">
|
||||
<div className={`Spinner ${className}`}>
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
style={{
|
||||
|
@ -129,8 +129,14 @@
|
||||
--color-muted-background-darker: var(--color-gray-100);
|
||||
|
||||
--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-text: #190064;
|
||||
|
22
packages/excalidraw/hooks/useCopiedIndicator.ts
Normal file
22
packages/excalidraw/hooks/useCopiedIndicator.ts
Normal file
@ -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,
|
||||
};
|
||||
};
|
@ -168,6 +168,7 @@
|
||||
"exportImage": "Export image...",
|
||||
"export": "Save to...",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"save": "Save to current file",
|
||||
"saveAs": "Save as",
|
||||
"load": "Open",
|
||||
|
Loading…
Reference in New Issue
Block a user