import * as React from "react"; import * as Schema from "effect/Schema"; export class ClipboardApiUnavailableError extends Schema.TaggedError()( "ClipboardApiUnavailableError", { target: Schema.String, }, ) { override get message(): string { return `Clipboard API is unavailable while copying ${this.target}.`; } } export class ClipboardWriteError extends Schema.TaggedError()( "ClipboardWriteError", { target: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to copy ${this.target} to the clipboard.`; } } export class ClipboardReadUnavailableError extends Schema.TaggedError()( "ClipboardReadUnavailableError", { target: Schema.String, }, ) { override get message(): string { return `Clipboard API is unavailable while reading ${this.target}.`; } } export class ClipboardReadError extends Schema.TaggedError()( "ClipboardReadError", { target: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to read ${this.target} from the clipboard.`; } } /** Copy fallback for remote web pages served over plain HTTP. */ function writeTextWithExecCommand(value: string): boolean { if (typeof document === "undefined" || typeof document.execCommand !== "function") return false; const textarea = document.createElement("textarea"); textarea.value = value; textarea.setAttribute("readonly", ""); textarea.setAttribute("aria-hidden", "true"); textarea.style.position = "fixed"; textarea.style.top = "0"; textarea.style.left = "0"; textarea.style.opacity = "0"; textarea.style.fontSize = "16px"; const previouslyFocused = document.activeElement; document.body.appendChild(textarea); try { textarea.focus({ preventScroll: true }); textarea.select(); textarea.setSelectionRange(0, value.length); return document.execCommand("copy"); } catch { return false; } finally { textarea.remove(); const restoreFocus = (previouslyFocused as { focus?: unknown } | null)?.focus; if (typeof restoreFocus === "function") { restoreFocus.call(previouslyFocused); } } } export async function writeTextToClipboard(value: string, target = "text") { if (typeof window === "undefined") { throw new ClipboardApiUnavailableError({ target, }); } if (!value) return false; if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { if (writeTextWithExecCommand(value)) return true; throw new ClipboardApiUnavailableError({ target, }); } try { await navigator.clipboard.writeText(value); return true; } catch (cause) { throw new ClipboardWriteError({ target, cause, }); } } export async function readTextFromClipboard(target = "text"): Promise { if ( typeof window === "undefined" || typeof navigator === "undefined" || !navigator.clipboard?.readText ) { throw new ClipboardReadUnavailableError({ target, }); } try { return await navigator.clipboard.readText(); } catch (cause) { throw new ClipboardReadError({ target, cause, }); } } export function useCopyToClipboard({ timeout = 2000, target = "text", onCopy, onError, }: { timeout?: number; target?: string; onCopy?: (ctx: TContext) => void; onError?: (error: Error, ctx: TContext) => void; } = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } { const [isCopied, setIsCopied] = React.useState(false); const timeoutIdRef = React.useRef(null); const onCopyRef = React.useRef(onCopy); const onErrorRef = React.useRef(onError); const targetRef = React.useRef(target); const timeoutRef = React.useRef(timeout); onCopyRef.current = onCopy; onErrorRef.current = onError; targetRef.current = target; timeoutRef.current = timeout; const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => { void writeTextToClipboard(value, targetRef.current).then( (didCopy) => { if (!didCopy) return; if (timeoutIdRef.current) { clearTimeout(timeoutIdRef.current); } setIsCopied(true); onCopyRef.current?.(ctx); if (timeoutRef.current !== 0) { timeoutIdRef.current = setTimeout(() => { setIsCopied(false); timeoutIdRef.current = null; }, timeoutRef.current); } }, (error) => { console.error(error); onErrorRef.current?.(error, ctx); }, ); }, []); // Cleanup timeout on unmount React.useEffect(() => { return (): void => { if (timeoutIdRef.current) { clearTimeout(timeoutIdRef.current); } }; }, []); return { copyToClipboard, isCopied }; }