Skip to content
Source
Sign inSign up

useCopyToClipboard.ts

245 lines6.7 KB

1import * as React from "react";
2import * as Schema from "effect/Schema";
3import {
4 COMPOSER_CONTEXT_CLIPBOARD_MIME,
5 encodeComposerContextClipboardHtml,
6} from "@t3tools/shared/composerContextClipboard";
7import { useStableCallback } from "./useStableCallback";
8​
9export class ClipboardApiUnavailableError extends Schema.TaggedError<ClipboardApiUnavailableError>()(
10 "ClipboardApiUnavailableError",
11 {
12 target: Schema.String,
13 },
14) {
15 override get message(): string {
16 return `Clipboard API is unavailable while copying ${this.target}.`;
17 }
19​
20export class ClipboardWriteError extends Schema.TaggedError<ClipboardWriteError>()(
21 "ClipboardWriteError",
22 {
23 target: Schema.String,
24 cause: Schema.Defect(),
25 },
26) {
27 override get message(): string {
28 return `Failed to copy ${this.target} to the clipboard.`;
29 }
31​
32export class ClipboardReadUnavailableError extends Schema.TaggedError<ClipboardReadUnavailableError>()(
33 "ClipboardReadUnavailableError",
34 {
35 target: Schema.String,
36 },
37) {
38 override get message(): string {
39 return `Clipboard API is unavailable while reading ${this.target}.`;
40 }
42​
43export class ClipboardReadError extends Schema.TaggedError<ClipboardReadError>()(
44 "ClipboardReadError",
45 {
46 target: Schema.String,
47 cause: Schema.Defect(),
48 },
49) {
50 override get message(): string {
51 return `Failed to read ${this.target} from the clipboard.`;
52 }
54​
55function writeTextWithExecCommand(
56 value: string,
57 extraFlavors?: Readonly<Record<string, string>>,
58): boolean {
59 if (typeof document === "undefined" || typeof document.execCommand !== "function") return false;
60​
61 const textarea = document.createElement("textarea");
62 textarea.value = value;
63 textarea.setAttribute("readonly", "");
64 textarea.setAttribute("aria-hidden", "true");
65 textarea.style.position = "fixed";
66 textarea.style.top = "0";
67 textarea.style.left = "0";
68 textarea.style.opacity = "0";
69 textarea.style.fontSize = "16px";
70​
71 const previouslyFocused = document.activeElement;
72 const copy = (event: ClipboardEvent) => {
73 if (!extraFlavors || !event.clipboardData) return;
74 event.clipboardData.setData("text/plain", value);
75 for (const [type, data] of Object.entries(extraFlavors))
76 event.clipboardData.setData(type, data);
77 event.preventDefault();
78 };
79 document.body.appendChild(textarea);
80 textarea.addEventListener("copy", copy);
81 try {
82 textarea.focus({ preventScroll: true });
83 textarea.select();
84 textarea.setSelectionRange(0, value.length);
85 return document.execCommand("copy");
86 } catch {
87 return false;
88 } finally {
89 textarea.removeEventListener("copy", copy);
90 textarea.remove();
91 const restoreFocus = (previouslyFocused as { focus?: unknown } | null)?.focus;
92 if (typeof restoreFocus === "function") {
93 restoreFocus.call(previouslyFocused);
94 }
95 }
97​
98export async function writeTextToClipboard(
99 value: string,
100 target = "text",
101 extraFlavors?: Readonly<Record<string, string>>,
102) {
103 if (typeof window === "undefined") {
104 throw new ClipboardApiUnavailableError({
105 target,
106 });
107 }
108​
109 if (!value) return false;
110 if (extraFlavors) {
111 extraFlavors = Object.fromEntries(
112 Object.entries(extraFlavors).filter(([type]) => type !== "text/plain"),
113 );
114 }
115 const contextFragment = extraFlavors?.[COMPOSER_CONTEXT_CLIPBOARD_MIME];
116 if (contextFragment)
117 extraFlavors = {
118 ...extraFlavors,
119 "text/html": encodeComposerContextClipboardHtml(
120 value,
121 contextFragment,
122 extraFlavors?.["text/html"],
123 ),
124 };
125​
126 if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
127 if (writeTextWithExecCommand(value, extraFlavors)) return true;
128 throw new ClipboardApiUnavailableError({
129 target,
130 });
131 }
132​
133 try {
134 if (extraFlavors && typeof ClipboardItem !== "undefined" && navigator.clipboard.write) {
135 try {
136 await navigator.clipboard.write([
137 new ClipboardItem({
138 "text/plain": new Blob([value], { type: "text/plain" }),
139 ...Object.fromEntries(
140 Object.entries(extraFlavors).map(([type, data]) => [
141 type,
142 new Blob([data], { type }),
143 ]),
144 ),
145 }),
146 ]);
147 return true;
148 } catch {
149 // Safari/native bridges may accept HTML but reject Chromium's custom web flavor.
150 const html = extraFlavors["text/html"];
151 if (html) {
152 try {
153 await navigator.clipboard.write([
154 new ClipboardItem({
155 "text/plain": new Blob([value], { type: "text/plain" }),
156 "text/html": new Blob([html], { type: "text/html" }),
157 }),
158 ]);
159 return true;
160 } catch {}
161 }
162 }
163 }
164 await navigator.clipboard.writeText(value);
165 return true;
166 } catch (cause) {
167 throw new ClipboardWriteError({
168 target,
169 cause,
170 });
171 }
173​
174export async function readTextFromClipboard(target = "text"): Promise<string> {
175 if (
176 typeof window === "undefined" ||
177 typeof navigator === "undefined" ||
178 !navigator.clipboard?.readText
179 ) {
180 throw new ClipboardReadUnavailableError({
181 target,
182 });
183 }
184​
185 try {
186 return await navigator.clipboard.readText();
187 } catch (cause) {
188 throw new ClipboardReadError({
189 target,
190 cause,
191 });
192 }
194​
195export function useCopyToClipboard<TContext = void>({
196 timeout = 2000,
197 target = "text",
198 onCopy,
199 onError,
200 extraFlavors,
201}: {
202 timeout?: number;
203 target?: string;
204 onCopy?: (ctx: TContext) => void;
205 onError?: (error: Error, ctx: TContext) => void;
206 extraFlavors?: Readonly<Record<string, string>>;
207} = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } {
208 const [isCopied, setIsCopied] = React.useState(false);
209 const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null);
210 const copyToClipboard = useStableCallback((value: string, ctx: TContext): void => {
211 void writeTextToClipboard(value, target, extraFlavors).then(
212 (didCopy) => {
213 if (!didCopy) return;
214 if (timeoutIdRef.current) {
215 clearTimeout(timeoutIdRef.current);
216 }
217 setIsCopied(true);
218​
219 onCopy?.(ctx);
220​
221 if (timeout !== 0) {
222 timeoutIdRef.current = setTimeout(() => {
223 setIsCopied(false);
224 timeoutIdRef.current = null;
225 }, timeout);
226 }
227 },
228 (error) => {
229 console.error(error);
230 onError?.(error, ctx);
231 },
232 );
233 });
234​
235 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the copied-state reset timer on unmount
236 React.useEffect(() => {
237 return (): void => {
238 if (timeoutIdRef.current) {
239 clearTimeout(timeoutIdRef.current);
240 }
241 };
242 }, []);
243​
244 return { copyToClipboard, isCopied };