1import * as React from "react";
2import * as Schema from "effect/Schema";
3import {
4 COMPOSER_CONTEXT_CLIPBOARD_MIME,
5 encodeComposerContextClipboardHtml,
6} from "@t3tools/shared/composerContextClipboard";
7
8export class ClipboardApiUnavailableError extends Schema.TaggedError<ClipboardApiUnavailableError>()(
9 "ClipboardApiUnavailableError",
10 {
11 target: Schema.String,
12 },
13) {
14 override get message(): string {
15 return `Clipboard API is unavailable while copying ${this.target}.`;
16 }
17}
18
19export class ClipboardWriteError extends Schema.TaggedError<ClipboardWriteError>()(
20 "ClipboardWriteError",
21 {
22 target: Schema.String,
23 cause: Schema.Defect(),
24 },
25) {
26 override get message(): string {
27 return `Failed to copy ${this.target} to the clipboard.`;
28 }
29}
30
31export class ClipboardReadUnavailableError extends Schema.TaggedError<ClipboardReadUnavailableError>()(
32 "ClipboardReadUnavailableError",
33 {
34 target: Schema.String,
35 },
36) {
37 override get message(): string {
38 return `Clipboard API is unavailable while reading ${this.target}.`;
39 }
40}
41
42export class ClipboardReadError extends Schema.TaggedError<ClipboardReadError>()(
43 "ClipboardReadError",
44 {
45 target: Schema.String,
46 cause: Schema.Defect(),
47 },
48) {
49 override get message(): string {
50 return `Failed to read ${this.target} from the clipboard.`;
51 }
52}
53
54/** Copy fallback for remote web pages served over plain HTTP. */
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 }
96}
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 // A caller that already built rich HTML keeps it; the escaped `<pre>` is only a fallback.
120 "text/html": encodeComposerContextClipboardHtml(
121 value,
122 contextFragment,
123 extraFlavors?.["text/html"],
124 ),
125 };
126
127 if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
128 if (writeTextWithExecCommand(value, extraFlavors)) return true;
129 throw new ClipboardApiUnavailableError({
130 target,
131 });
132 }
133
134 try {
135 // Custom flavors need ClipboardItem; when it is missing or refuses the type, plain text
136 // still lands so the copy never silently fails.
137 if (extraFlavors && typeof ClipboardItem !== "undefined" && navigator.clipboard.write) {
138 try {
139 await navigator.clipboard.write([
140 new ClipboardItem({
141 "text/plain": new Blob([value], { type: "text/plain" }),
142 ...Object.fromEntries(
143 Object.entries(extraFlavors).map(([type, data]) => [
144 type,
145 new Blob([data], { type }),
146 ]),
147 ),
148 }),
149 ]);
150 return true;
151 } catch {
152 // Safari/native bridges may accept HTML but reject Chromium's custom web flavor.
153 const html = extraFlavors["text/html"];
154 if (html) {
155 try {
156 await navigator.clipboard.write([
157 new ClipboardItem({
158 "text/plain": new Blob([value], { type: "text/plain" }),
159 "text/html": new Blob([html], { type: "text/html" }),
160 }),
161 ]);
162 return true;
163 } catch {
164 // Plain text still makes unavailable references visible to the receiver.
165 }
166 }
167 }
168 }
169 await navigator.clipboard.writeText(value);
170 return true;
171 } catch (cause) {
172 throw new ClipboardWriteError({
173 target,
174 cause,
175 });
176 }
177}
178
179export async function readTextFromClipboard(target = "text"): Promise<string> {
180 if (
181 typeof window === "undefined" ||
182 typeof navigator === "undefined" ||
183 !navigator.clipboard?.readText
184 ) {
185 throw new ClipboardReadUnavailableError({
186 target,
187 });
188 }
189
190 try {
191 return await navigator.clipboard.readText();
192 } catch (cause) {
193 throw new ClipboardReadError({
194 target,
195 cause,
196 });
197 }
198}
199
200export function useCopyToClipboard<TContext = void>({
201 timeout = 2000,
202 target = "text",
203 onCopy,
204 onError,
205 extraFlavors,
206}: {
207 timeout?: number;
208 target?: string;
209 onCopy?: (ctx: TContext) => void;
210 onError?: (error: Error, ctx: TContext) => void;
211 extraFlavors?: Readonly<Record<string, string>>;
212} = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } {
213 const [isCopied, setIsCopied] = React.useState(false);
214 const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null);
215 const onCopyRef = React.useRef(onCopy);
216 const onErrorRef = React.useRef(onError);
217 const targetRef = React.useRef(target);
218 const timeoutRef = React.useRef(timeout);
219
220 onCopyRef.current = onCopy;
221 onErrorRef.current = onError;
222 const extraFlavorsRef = React.useRef(extraFlavors);
223 targetRef.current = target;
224 timeoutRef.current = timeout;
225 extraFlavorsRef.current = extraFlavors;
226
227 const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => {
228 void writeTextToClipboard(value, targetRef.current, extraFlavorsRef.current).then(
229 (didCopy) => {
230 if (!didCopy) return;
231 if (timeoutIdRef.current) {
232 clearTimeout(timeoutIdRef.current);
233 }
234 setIsCopied(true);
235
236 onCopyRef.current?.(ctx);
237
238 if (timeoutRef.current !== 0) {
239 timeoutIdRef.current = setTimeout(() => {
240 setIsCopied(false);
241 timeoutIdRef.current = null;
242 }, timeoutRef.current);
243 }
244 },
245 (error) => {
246 console.error(error);
247 onErrorRef.current?.(error, ctx);
248 },
249 );
250 }, []);
251
252 // Cleanup timeout on unmount
253 React.useEffect(() => {
254 return (): void => {
255 if (timeoutIdRef.current) {
256 clearTimeout(timeoutIdRef.current);
257 }
258 };
259 }, []);
260
261 return { copyToClipboard, isCopied };
262}