Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions package/src/components/page-toolbar-css/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -3106,11 +3107,7 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {
}

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)
Expand Down
74 changes: 74 additions & 0 deletions package/src/utils/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
44 changes: 44 additions & 0 deletions package/src/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
// 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);
}
}