fix: fixed copy to clipboard button (#8426)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
spc-28 2024-08-27 03:57:44 +05:30 committed by GitHub
parent afb68a6467
commit 26d2296578
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 160 additions and 284 deletions

View File

@ -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;

View File

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

View File

@ -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}> <FilledButton
<Popover.Trigger asChild> size="large"
<FilledButton label={t("buttons.copyLink")}
size="large" icon={copyIcon}
label="Copy link" status={copyStatus}
icon={copyIcon} onClick={() => {
onClick={copyRoomLink} 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>
<div className="ShareDialog__active__description"> <div className="ShareDialog__active__description">
<p> <p>

View File

@ -16,11 +16,19 @@
.Spinner { .Spinner {
--spinner-color: var(--color-surface-lowest); --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; 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);

View File

@ -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}

View File

@ -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={() => status={copyStatus}
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, { onClick={async () => {
exportingFrame, await onExportImage(
}) EXPORT_IMAGE_TYPES.clipboard,
} exportedElements,
{
exportingFrame,
},
);
onCopy();
}}
icon={copyIcon} icon={copyIcon}
> >
{t("imageExportDialog.button.copyPngToClipboard")} {t("imageExportDialog.button.copyPngToClipboard")}

View File

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

View File

@ -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}> <FilledButton
<Popover.Trigger asChild> size="large"
<FilledButton label={t("buttons.copyLink")}
size="large" icon={copyIcon}
label="Copy link" status={copyStatus}
icon={copyIcon} onClick={() => {
onClick={copyRoomLink} 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>
<div className="ShareableLinkDialog__description"> <div className="ShareableLinkDialog__description">
🔒 {t("alerts.uploadedSecurly")} 🔒 {t("alerts.uploadedSecurly")}

View File

@ -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={{

View File

@ -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;

View 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,
};
};

View File

@ -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",