|
1 | 1 | import { useEffect, useRef, useState } from "react";
|
2 | 2 |
|
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<{ |
5 | 7 | 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; |
6 | 23 | }>;
|
7 | 24 |
|
8 | 25 | export const useClipboard = (textToCopy: string): UseClipboardResult => {
|
9 |
| - const [isCopied, setIsCopied] = useState(false); |
| 26 | + const [showCopiedSuccess, setShowCopiedSuccess] = useState(false); |
10 | 27 | const timeoutIdRef = useRef<number | undefined>();
|
11 | 28 |
|
12 | 29 | useEffect(() => {
|
13 | 30 | const clearIdsOnUnmount = () => window.clearTimeout(timeoutIdRef.current);
|
14 | 31 | return clearIdsOnUnmount;
|
15 | 32 | }, []);
|
16 | 33 |
|
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); |
44 | 58 | }
|
45 |
| - console.error(wrappedErr); |
46 | 59 | }
|
47 |
| - } |
| 60 | + }, |
48 | 61 | };
|
49 |
| - |
50 |
| - return { isCopied, copyToClipboard }; |
51 | 62 | };
|
| 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 | +} |
0 commit comments