Skip to content
38 changes: 38 additions & 0 deletions apps/web/src/hooks/useCopyToClipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe("writeTextToClipboard", () => {
it("reports unavailable clipboard support with structural context", async () => {
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", {});
vi.stubGlobal("document", undefined);

const error = await writeTextToClipboard("plan contents", "plan").then(
() => undefined,
Expand All @@ -27,6 +28,43 @@ describe("writeTextToClipboard", () => {
expect((error as Error).message).not.toContain("plan contents");
});

it("falls back to a selected textarea when the Clipboard API is unavailable", async () => {
const focus = vi.fn();
const appendChild = vi.fn();
const execCommand = vi.fn(() => true);
const remove = vi.fn();
const select = vi.fn();
const setAttribute = vi.fn();
const setSelectionRange = vi.fn();
const textarea = {
remove,
select,
setAttribute,
setSelectionRange,
style: {},
value: "",
};

vi.stubGlobal("window", {});
vi.stubGlobal("navigator", {});
vi.stubGlobal("document", {
activeElement: { focus },
body: { appendChild },
createElement: vi.fn(() => textarea),
execCommand,
});

await expect(writeTextToClipboard("remote command", "command")).resolves.toBe(true);

expect(textarea.value).toBe("remote command");
expect(appendChild).toHaveBeenCalledWith(textarea);
expect(select).toHaveBeenCalledOnce();
expect(setSelectionRange).toHaveBeenCalledWith(0, "remote command".length);
expect(execCommand).toHaveBeenCalledWith("copy");
expect(remove).toHaveBeenCalledOnce();
expect(focus).toHaveBeenCalledOnce();
});

it("preserves the exact clipboard failure without exposing copied contents", async () => {
const cause = new Error("browser clipboard failure");
const writeText = vi.fn().mockRejectedValue(cause);
Expand Down
42 changes: 37 additions & 5 deletions apps/web/src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,51 @@ export class ClipboardReadError extends Schema.TaggedErrorClass<ClipboardReadErr
}
}

/** 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 = "-9999px";
textarea.style.opacity = "0";

const previouslyFocused = document.activeElement;
document.body.appendChild(textarea);
try {
textarea.select();
textarea.setSelectionRange(0, value.length);
return document.execCommand("copy");
Comment thread
Michel-Liao marked this conversation as resolved.
} 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" ||
typeof navigator === "undefined" ||
!navigator.clipboard?.writeText
) {
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;
Expand Down
Loading