Skip to content

Commit d1dd31a

Browse files
committed
refactor: clean up and update API for useClipboard
1 parent 245e280 commit d1dd31a

File tree

4 files changed

+113
-40
lines changed

4 files changed

+113
-40
lines changed

site/src/components/CopyButton/CopyButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(
3232
buttonStyles,
3333
tooltipTitle = Language.tooltipTitle,
3434
} = props;
35-
const { isCopied, copyToClipboard } = useClipboard(text);
35+
const { showCopiedSuccess, copyToClipboard } = useClipboard(text);
3636

3737
return (
3838
<Tooltip title={tooltipTitle} placement="top">
@@ -45,7 +45,7 @@ export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(
4545
variant="text"
4646
onClick={copyToClipboard}
4747
>
48-
{isCopied ? (
48+
{showCopiedSuccess ? (
4949
<Check css={styles.copyIcon} />
5050
) : (
5151
<FileCopyIcon css={styles.copyIcon} />

site/src/components/CopyableValue/CopyableValue.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ export const CopyableValue: FC<CopyableValueProps> = ({
1616
children,
1717
...attrs
1818
}) => {
19-
const { isCopied, copyToClipboard } = useClipboard(value);
19+
const { showCopiedSuccess, copyToClipboard } = useClipboard(value);
2020
const clickableProps = useClickable<HTMLSpanElement>(copyToClipboard);
2121

2222
return (
2323
<Tooltip
24-
title={isCopied ? "Copied!" : "Click to copy"}
24+
title={showCopiedSuccess ? "Copied!" : "Click to copy"}
2525
placement={placement}
2626
PopperProps={PopperProps}
2727
>

site/src/hooks/useClipboard.ts

Lines changed: 103 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,120 @@
11
import { useEffect, useRef, useState } from "react";
22

3-
type UseClipboardResult = Readonly<{
4-
isCopied: boolean;
3+
const CLIPBOARD_TIMEOUT_MS = 1_000;
4+
const COPY_FAILED_MESSAGE = "copyToClipboard: failed to copy text to clipboard";
5+
6+
export type UseClipboardResult = Readonly<{
57
copyToClipboard: () => Promise<void>;
8+
9+
/**
10+
* Indicates whether the UI should show a successfully-copied status to the
11+
* user. This is _not_ the same as an `isCopied` property, because the hook
12+
* does not ever look at the clipboard, and only makes a guess at whether the
13+
* text has been copied.
14+
*
15+
* It is possible for this value to be true, yet for the clipboard not to
16+
* have the user's text in it. Trying to make this property accurate enough
17+
* that it could safely be called `isCopied` led to browser compatibility
18+
* issues in Safari
19+
*
20+
* @see {@link https://github.com/coder/coder/pull/11863}
21+
*/
22+
showCopiedSuccess: boolean;
623
}>;
724

825
export const useClipboard = (textToCopy: string): UseClipboardResult => {
9-
const [isCopied, setIsCopied] = useState(false);
26+
const [showCopiedSuccess, setShowCopiedSuccess] = useState(false);
1027
const timeoutIdRef = useRef<number | undefined>();
1128

1229
useEffect(() => {
1330
const clearIdsOnUnmount = () => window.clearTimeout(timeoutIdRef.current);
1431
return clearIdsOnUnmount;
1532
}, []);
1633

17-
const copyToClipboard = async () => {
18-
try {
19-
await window.navigator.clipboard.writeText(textToCopy);
20-
setIsCopied(true);
21-
timeoutIdRef.current = window.setTimeout(() => {
22-
setIsCopied(false);
23-
}, 1000);
24-
} catch (err) {
25-
const input = document.createElement("input");
26-
input.value = textToCopy;
27-
document.body.appendChild(input);
28-
input.focus();
29-
input.select();
30-
const isCopied = document.execCommand("copy");
31-
document.body.removeChild(input);
32-
33-
if (isCopied) {
34-
setIsCopied(true);
35-
timeoutIdRef.current = window.setTimeout(() => {
36-
setIsCopied(false);
37-
}, 1000);
38-
} else {
39-
const wrappedErr = new Error(
40-
"copyToClipboard: failed to copy text to clipboard",
41-
);
42-
if (err instanceof Error) {
43-
wrappedErr.stack = err.stack;
34+
const handleSuccessfulCopy = () => {
35+
setShowCopiedSuccess(true);
36+
timeoutIdRef.current = window.setTimeout(() => {
37+
setShowCopiedSuccess(false);
38+
}, CLIPBOARD_TIMEOUT_MS);
39+
};
40+
41+
return {
42+
showCopiedSuccess,
43+
copyToClipboard: async () => {
44+
try {
45+
await window.navigator.clipboard.writeText(textToCopy);
46+
handleSuccessfulCopy();
47+
} catch (err) {
48+
const copySuccessful = simulateClipboardWrite(textToCopy);
49+
if (copySuccessful) {
50+
handleSuccessfulCopy();
51+
} else {
52+
const wrappedErr = new Error(COPY_FAILED_MESSAGE);
53+
if (err instanceof Error) {
54+
wrappedErr.stack = err.stack;
55+
}
56+
57+
console.error(wrappedErr);
4458
}
45-
console.error(wrappedErr);
4659
}
47-
}
60+
},
4861
};
49-
50-
return { isCopied, copyToClipboard };
5162
};
63+
64+
/**
65+
* Provides a fallback clipboard method for when browsers do not have access
66+
* to the clipboard API (the browser is older, or the deployment is only running
67+
* on HTTP, when the clipboard API is only available in secure contexts).
68+
*
69+
* It feels silly that you have to make a whole dummy input just to simulate a
70+
* clipboard, but that's really the recommended approach for older browsers.
71+
*
72+
* @see {@link https://web.dev/patterns/clipboard/copy-text?hl=en}
73+
*/
74+
function simulateClipboardWrite(textToCopy: string): boolean {
75+
const previousFocusTarget = document.activeElement;
76+
const dummyInput = document.createElement("input");
77+
78+
// Using visually-hidden styling to ensure that inserting the element doesn't
79+
// cause any content reflows on the page (removes any risk of UI flickers).
80+
// Can't use visibility:hidden or display:none, because then the elements
81+
// can't receive focus, which is needed for the execCommand method to work
82+
const style = dummyInput.style;
83+
style.display = "inline-block";
84+
style.position = "absolute";
85+
style.overflow = "hidden";
86+
style.clip = "rect(0 0 0 0)";
87+
style.clipPath = "rect(0 0 0 0)";
88+
style.height = "1px";
89+
style.width = "1px";
90+
style.margin = "-1px";
91+
style.padding = "0";
92+
style.border = "0";
93+
94+
document.body.appendChild(dummyInput);
95+
dummyInput.value = textToCopy;
96+
dummyInput.focus();
97+
dummyInput.select();
98+
99+
/**
100+
* The document.execCommand method is officially deprecated. Browsers are free
101+
* to remove the method entirely or choose to turn it into a no-op function
102+
* that always returns false. You cannot make any assumptions about how its
103+
* core functionality will be removed.
104+
*
105+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Clipboard}
106+
*/
107+
let isCopied: boolean;
108+
try {
109+
isCopied = document?.execCommand("copy") ?? false;
110+
} catch {
111+
isCopied = false;
112+
}
113+
114+
dummyInput.remove();
115+
if (previousFocusTarget instanceof HTMLElement) {
116+
previousFocusTarget.focus();
117+
}
118+
119+
return isCopied;
120+
}

site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,15 @@ export const TemplateEmbedPageView: FC<TemplateEmbedPageViewProps> = ({
173173
<Button
174174
css={{ borderRadius: 999 }}
175175
startIcon={
176-
clipboard.isCopied ? <CheckOutlined /> : <FileCopyOutlined />
176+
clipboard.showCopiedSuccess ? (
177+
<CheckOutlined />
178+
) : (
179+
<FileCopyOutlined />
180+
)
177181
}
178182
variant="contained"
179183
onClick={clipboard.copyToClipboard}
180-
disabled={clipboard.isCopied}
184+
disabled={clipboard.showCopiedSuccess}
181185
>
182186
Copy button code
183187
</Button>

0 commit comments

Comments
 (0)