From 711f6f47dc0283604ea23996ecdd4c3a0e371b33 Mon Sep 17 00:00:00 2001 From: Ahmed Efe Petek Date: Mon, 10 Aug 2026 20:13:15 +0300 Subject: [PATCH] fix: fall back when Clipboard API write fails Clipboard copy swallowed writeText errors and still showed the success checkmark. Add an execCommand fallback and only mark copied on success. --- .../src/components/page-toolbar-css/index.tsx | 20 ++--- package/src/utils/clipboard.test.ts | 80 +++++++++++++++++++ package/src/utils/clipboard.ts | 56 +++++++++++++ package/src/utils/index.ts | 1 + 4 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 package/src/utils/clipboard.test.ts create mode 100644 package/src/utils/clipboard.ts diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx index 42df435a..855c31d6 100644 --- a/package/src/components/page-toolbar-css/index.tsx +++ b/package/src/components/page-toolbar-css/index.tsx @@ -86,6 +86,7 @@ import { import type { Annotation } from "../../types"; import styles from "./styles.module.scss"; import { generateOutput } from "../../utils/generate-output"; +import { copyTextToClipboard } from "../../utils/clipboard"; import { AnnotationMarker, ExitingMarker, PendingMarker } from "./annotation-marker"; import { SettingsPanel } from "./settings-panel"; @@ -3106,22 +3107,23 @@ const [settings, setSettings] = useState(() => { } } + let copiedOk = !copyToClipboard; if (copyToClipboard) { - try { - await navigator.clipboard.writeText(output); - } catch { - // Clipboard may fail (permissions, not HTTPS, etc.) - continue anyway - } + copiedOk = await copyTextToClipboard(output); } // Fire callback with markdown output (always, regardless of clipboard success) onCopy?.(output); - setCopied(true); - originalSetTimeout(() => setCopied(false), 2000); + // Only show the success checkmark when the clipboard write actually worked + // (or when the consumer opted out of clipboard and handles copy via onCopy). + if (copiedOk) { + setCopied(true); + originalSetTimeout(() => setCopied(false), 2000); - if (settings.autoClearAfterCopy) { - originalSetTimeout(() => clearAll(), 500); + if (settings.autoClearAfterCopy) { + originalSetTimeout(() => clearAll(), 500); + } } }, [ annotations, diff --git a/package/src/utils/clipboard.test.ts b/package/src/utils/clipboard.test.ts new file mode 100644 index 00000000..ad11d159 --- /dev/null +++ b/package/src/utils/clipboard.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { copyTextToClipboard } from "./clipboard"; + +function stubExecCommand(impl: Document["execCommand"]) { + Object.defineProperty(document, "execCommand", { + configurable: true, + writable: true, + value: impl, + }); + return vi.spyOn(document, "execCommand"); +} + +describe("copyTextToClipboard", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("returns true when Clipboard API write succeeds", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { + clipboard: { writeText }, + }); + + await expect(copyTextToClipboard("hello")).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith("hello"); + }); + + it("falls back to execCommand when Clipboard API throws", async () => { + const writeText = vi + .fn() + .mockRejectedValue(new Error("Document is not focused.")); + vi.stubGlobal("navigator", { + clipboard: { writeText }, + }); + const exec = stubExecCommand(vi.fn().mockReturnValue(true)); + + await expect(copyTextToClipboard("fallback text")).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith("fallback text"); + expect(exec).toHaveBeenCalledWith("copy"); + }); + + it("falls back to execCommand when Clipboard API is missing", async () => { + vi.stubGlobal("navigator", {}); + const exec = stubExecCommand(vi.fn().mockReturnValue(true)); + + await expect(copyTextToClipboard("no api")).resolves.toBe(true); + expect(exec).toHaveBeenCalledWith("copy"); + }); + + it("returns false when both Clipboard API and execCommand fail", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("denied")); + vi.stubGlobal("navigator", { + clipboard: { writeText }, + }); + stubExecCommand(vi.fn().mockReturnValue(false)); + + await expect(copyTextToClipboard("nope")).resolves.toBe(false); + }); + + it("returns false when execCommand throws", async () => { + vi.stubGlobal("navigator", { + clipboard: { + writeText: vi.fn().mockRejectedValue(new Error("denied")), + }, + }); + stubExecCommand( + vi.fn(() => { + throw new Error("exec failed"); + }), + ); + + await expect(copyTextToClipboard("boom")).resolves.toBe(false); + }); +}); diff --git a/package/src/utils/clipboard.ts b/package/src/utils/clipboard.ts new file mode 100644 index 00000000..890b5d30 --- /dev/null +++ b/package/src/utils/clipboard.ts @@ -0,0 +1,56 @@ +/** + * Copy text to the system clipboard. + * + * Tries the async Clipboard API first, then falls back to a temporary + * textarea + `document.execCommand("copy")` for contexts where + * `navigator.clipboard.writeText` is denied (unfocused documents, + * embedded browsers, missing permissions, non-HTTPS). + * + * @returns `true` if text was written to the clipboard, otherwise `false`. + */ +export async function copyTextToClipboard(text: string): Promise { + if (typeof window === "undefined") return false; + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Fall through to execCommand fallback + } + + return copyTextViaExecCommand(text); +} + +function copyTextViaExecCommand(text: string): boolean { + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.cssText = + "position:fixed;left:-9999px;top:0;opacity:0;pointer-events:none;"; + document.body.appendChild(textarea); + + const selection = document.getSelection(); + const previousRange = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, text.length); + + const ok = document.execCommand("copy"); + + document.body.removeChild(textarea); + + if (previousRange && selection) { + selection.removeAllRanges(); + selection.addRange(previousRange); + } + + return ok; + } catch { + return false; + } +} diff --git a/package/src/utils/index.ts b/package/src/utils/index.ts index 65e86d12..47bde969 100644 --- a/package/src/utils/index.ts +++ b/package/src/utils/index.ts @@ -2,3 +2,4 @@ export * from "./element-identification"; export * from "./storage"; export * from "./source-location"; export * from "./sync"; +export * from "./clipboard";