From b3db329ff6e971494816558409e64d7591c9991e Mon Sep 17 00:00:00 2001 From: zhaoqirong Date: Wed, 25 Mar 2026 19:38:28 +0800 Subject: [PATCH] fix: add clipboard fallback for non-secure contexts (HTTP on LAN IPs) `navigator.clipboard.writeText` requires a secure context (HTTPS, localhost, or 127.0.0.1). When the page is served over plain HTTP on a LAN IP such as `http://10.2.3.4:3000`, the Clipboard API is unavailable and the copy button silently fails. This adds a `copyTextToClipboard` utility that tries the modern Clipboard API first, then falls back to the legacy `document.execCommand("copy")` approach which works in all contexts. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/page-toolbar-css/index.tsx | 7 +- package/src/utils/clipboard.test.ts | 74 +++++++++++++++++++ package/src/utils/clipboard.ts | 44 +++++++++++ 3 files changed, 120 insertions(+), 5 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 a8497951..2e77f3dd 100644 --- a/package/src/components/page-toolbar-css/index.tsx +++ b/package/src/components/page-toolbar-css/index.tsx @@ -87,6 +87,7 @@ import styles from "./styles.module.scss"; import { generateOutput } from "../../utils/generate-output"; import { AnnotationMarker, ExitingMarker, PendingMarker } from "./annotation-marker"; import { SettingsPanel } from "./settings-panel"; +import { copyTextToClipboard } from "../../utils/clipboard"; /** * Composes element identification with React component detection. @@ -3106,11 +3107,7 @@ const [settings, setSettings] = useState(() => { } if (copyToClipboard) { - try { - await navigator.clipboard.writeText(output); - } catch { - // Clipboard may fail (permissions, not HTTPS, etc.) - continue anyway - } + await copyTextToClipboard(output); } // Fire callback with markdown output (always, regardless of clipboard success) diff --git a/package/src/utils/clipboard.test.ts b/package/src/utils/clipboard.test.ts new file mode 100644 index 00000000..727ce25b --- /dev/null +++ b/package/src/utils/clipboard.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { copyTextToClipboard } from "./clipboard"; + +describe("copyTextToClipboard", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("should use navigator.clipboard.writeText when available", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + const result = await copyTextToClipboard("hello"); + + expect(writeText).toHaveBeenCalledWith("hello"); + expect(result).toBe(true); + }); + + it("should fall back to execCommand when clipboard API throws", async () => { + const writeText = vi.fn().mockRejectedValue(new DOMException("not allowed")); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + // jsdom does not implement execCommand, so we need to define it + document.execCommand = vi.fn().mockReturnValue(true); + + const result = await copyTextToClipboard("hello"); + + expect(writeText).toHaveBeenCalled(); + expect(document.execCommand).toHaveBeenCalledWith("copy"); + expect(result).toBe(true); + }); + + it("should fall back to execCommand when clipboard API is unavailable", async () => { + vi.stubGlobal("navigator", {}); + + document.execCommand = vi.fn().mockReturnValue(true); + + const result = await copyTextToClipboard("hello"); + + expect(document.execCommand).toHaveBeenCalledWith("copy"); + expect(result).toBe(true); + }); + + it("should return false when both methods fail", async () => { + vi.stubGlobal("navigator", {}); + + document.execCommand = vi.fn().mockImplementation(() => { + throw new Error("not supported"); + }); + + const result = await copyTextToClipboard("hello"); + + expect(result).toBe(false); + }); + + it("should clean up the textarea element after fallback copy", async () => { + vi.stubGlobal("navigator", {}); + document.execCommand = vi.fn().mockReturnValue(true); + + const appendSpy = vi.spyOn(document.body, "appendChild"); + const removeSpy = vi.spyOn(document.body, "removeChild"); + + await copyTextToClipboard("hello"); + + expect(appendSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledTimes(1); + + // Verify the textarea was created with the correct value + const textarea = appendSpy.mock.calls[0][0] as HTMLTextAreaElement; + expect(textarea.tagName).toBe("TEXTAREA"); + expect(textarea.value).toBe("hello"); + }); +}); diff --git a/package/src/utils/clipboard.ts b/package/src/utils/clipboard.ts new file mode 100644 index 00000000..529f09c6 --- /dev/null +++ b/package/src/utils/clipboard.ts @@ -0,0 +1,44 @@ +/** + * Copy text to the clipboard with a fallback for non-secure contexts. + * + * `navigator.clipboard.writeText` requires a **secure context** (HTTPS, + * localhost, or 127.0.0.1). When the page is served over plain HTTP on a + * LAN IP such as `http://10.2.3.4:3000`, the Clipboard API is unavailable + * and silently fails. + * + * This helper tries the modern Clipboard API first, then falls back to the + * legacy `document.execCommand("copy")` approach which works in all contexts. + */ +export async function copyTextToClipboard(text: string): Promise { + // 1. Try modern Clipboard API (requires secure context) + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // SecurityError in non-secure contexts or permission denied – fall through + } + } + + // 2. Fallback: hidden textarea + execCommand("copy") + const textarea = document.createElement("textarea"); + textarea.value = text; + + // Move off-screen so it's invisible but still selectable + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "-9999px"; + textarea.style.opacity = "0"; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + document.body.removeChild(textarea); + } +}