diff --git a/apps/app/src/lib/clipboard.test.ts b/apps/app/src/lib/clipboard.test.ts new file mode 100644 index 0000000000..8778b9a542 --- /dev/null +++ b/apps/app/src/lib/clipboard.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), +})); + +vi.mock("@/components/ui/app-toast", () => ({ + appToast: toastMocks, +})); + +import { + copyTextToClipboard, + copyToClipboardWithToast, +} from "./clipboard"; + +function installClipboard(writeText: (text: string) => Promise): void { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); +} + +function removeClipboard(): void { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: undefined, + }); +} + +function installEditingCommand( + implementation: (command: string) => boolean, +) { + const execCommand = vi.fn(implementation); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + return execCommand; +} + +afterEach(() => { + document.body.replaceChildren(); + toastMocks.error.mockReset(); + toastMocks.success.mockReset(); + vi.restoreAllMocks(); + removeClipboard(); +}); + +describe("copyTextToClipboard", () => { + it("uses the Clipboard API when it succeeds", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const editingCopy = installEditingCommand(() => true); + installClipboard(writeText); + + await expect(copyTextToClipboard("hello")).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith("hello"); + expect(editingCopy).not.toHaveBeenCalled(); + }); + + it.each([ + ["is unavailable", false], + ["rejects", true], + ])( + "falls back to the editing command when the Clipboard API %s", + async (_label, clipboardRejects) => { + if (clipboardRejects) { + installClipboard( + vi.fn().mockRejectedValue(new DOMException("Not allowed")), + ); + } else { + removeClipboard(); + } + const editingCopy = installEditingCommand(() => { + const textarea = document.querySelector("textarea"); + expect(textarea?.value).toBe("LAN copy"); + return true; + }); + + await expect(copyTextToClipboard("LAN copy")).resolves.toBe(true); + + expect(editingCopy).toHaveBeenCalledWith("copy"); + expect(document.querySelector("textarea")).toBeNull(); + }, + ); + + it("restores focus and reports failure when both copy methods fail", async () => { + removeClipboard(); + installEditingCommand(() => false); + const button = document.createElement("button"); + document.body.append(button); + button.focus(); + + await expect(copyTextToClipboard("nope")).resolves.toBe(false); + + expect(document.activeElement).toBe(button); + expect(document.querySelector("textarea")).toBeNull(); + }); +}); + +describe("copyToClipboardWithToast", () => { + it("shows the configured error only after both copy methods fail", async () => { + installClipboard(vi.fn().mockRejectedValue(new Error("denied"))); + installEditingCommand(() => false); + + await expect( + copyToClipboardWithToast("text", { + errorMessage: "Couldn't copy", + successMessage: "Copied it", + }), + ).resolves.toBe(false); + + expect(toastMocks.error).toHaveBeenCalledWith("Couldn't copy"); + expect(toastMocks.success).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/lib/clipboard.ts b/apps/app/src/lib/clipboard.ts index ae97e330dc..fc4a14a142 100644 --- a/apps/app/src/lib/clipboard.ts +++ b/apps/app/src/lib/clipboard.ts @@ -8,6 +8,90 @@ interface CopyToClipboardOptions { errorMessage?: string | null; } +/** + * Copies through the browser's legacy editing command. Unlike the async + * Clipboard API, this remains available on plain-HTTP LAN origins when it is + * called synchronously from a user gesture. + */ +function copyWithEditingCommand(text: string): boolean { + if ( + typeof document === "undefined" || + document.body === null || + typeof document.execCommand !== "function" + ) { + return false; + } + + const activeElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const selection = document.getSelection(); + const selectedRanges = selection + ? Array.from({ length: selection.rangeCount }, (_, index) => + selection.getRangeAt(index).cloneRange(), + ) + : []; + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.readOnly = true; + textarea.setAttribute("aria-hidden", "true"); + Object.assign(textarea.style, { + border: "0", + height: "1px", + left: "0", + opacity: "0", + padding: "0", + pointerEvents: "none", + position: "fixed", + top: "0", + width: "1px", + }); + document.body.append(textarea); + + let copied = false; + try { + textarea.focus({ preventScroll: true }); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + copied = document.execCommand("copy"); + } catch { + copied = false; + } finally { + textarea.remove(); + if (activeElement?.isConnected) { + activeElement.focus({ preventScroll: true }); + } + if (selection) { + selection.removeAllRanges(); + for (const range of selectedRanges) { + selection.addRange(range); + } + } + } + return copied; +} + +/** + * Copies text using the modern API where available, with a user-gesture + * fallback for browsers serving bb from a non-secure LAN origin. + */ +export async function copyTextToClipboard(text: string): Promise { + if ( + typeof navigator !== "undefined" && + typeof navigator.clipboard?.writeText === "function" + ) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // A present Clipboard API can still reject because of origin policy or + // permissions. The synchronous editing command may remain available. + } + } + return copyWithEditingCommand(text); +} + /** * Copies text to the clipboard and surfaces success/failure via appToast. * Returns `true` on success, `false` on failure. @@ -19,18 +103,13 @@ export async function copyToClipboardWithToast( errorMessage = "Failed to copy", }: CopyToClipboardOptions = {}, ): Promise { - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { - if (errorMessage) appToast.error(errorMessage); - return false; - } - try { - await navigator.clipboard.writeText(text); + const copied = await copyTextToClipboard(text); + if (copied) { if (successMessage) appToast.success(successMessage); return true; - } catch { - if (errorMessage) appToast.error(errorMessage); - return false; } + if (errorMessage) appToast.error(errorMessage); + return false; } export interface ClipboardCopyOptions extends CopyToClipboardOptions {