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
42 changes: 42 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,47 @@ 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 restoreFocus = 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 = {
focus,
remove,
select,
setAttribute,
setSelectionRange,
style: {},
value: "",
};

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

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

expect(textarea.value).toBe("remote command");
expect(textarea.style).toMatchObject({ fontSize: "16px" });
expect(appendChild).toHaveBeenCalledWith(textarea);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(select).toHaveBeenCalledOnce();
expect(setSelectionRange).toHaveBeenCalledWith(0, "remote command".length);
expect(execCommand).toHaveBeenCalledWith("copy");
expect(remove).toHaveBeenCalledOnce();
expect(restoreFocus).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
45 changes: 40 additions & 5 deletions apps/web/src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,54 @@ 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 = "0";
textarea.style.left = "0";
textarea.style.opacity = "0";
textarea.style.fontSize = "16px";

const previouslyFocused = document.activeElement;
document.body.appendChild(textarea);
try {
textarea.focus({ preventScroll: true });
Comment thread
cursor[bot] marked this conversation as resolved.
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