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
20 changes: 11 additions & 9 deletions package/src/components/page-toolbar-css/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -3106,22 +3107,23 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {
}
}

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,
Expand Down
80 changes: 80 additions & 0 deletions package/src/utils/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
56 changes: 56 additions & 0 deletions package/src/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
}
1 change: 1 addition & 0 deletions package/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./element-identification";
export * from "./storage";
export * from "./source-location";
export * from "./sync";
export * from "./clipboard";