Просмотр исходного кода

RHL-043 feat(ui): add clipboard helper with execCommand fallback

Code_Uwe 1 месяц назад
Родитель
Сommit
a73576484c
2 измененных файлов с 145 добавлено и 0 удалено
  1. 56 0
      lib/frontend/ui/clipboard.js
  2. 89 0
      lib/frontend/ui/clipboard.test.js

+ 56 - 0
lib/frontend/ui/clipboard.js

@@ -0,0 +1,56 @@
+function canUseClipboardApi() {
+	return (
+		typeof navigator !== "undefined" &&
+		Boolean(navigator.clipboard) &&
+		typeof navigator.clipboard.writeText === "function"
+	);
+}
+
+function copyWithExecCommand(text) {
+	if (typeof document === "undefined") return false;
+	if (typeof document.execCommand !== "function") return false;
+
+	const textarea = document.createElement("textarea");
+	textarea.value = String(text ?? "");
+	textarea.setAttribute("readonly", "");
+	textarea.style.position = "fixed";
+	textarea.style.top = "-9999px";
+	textarea.style.left = "-9999px";
+	textarea.style.opacity = "0";
+
+	document.body.appendChild(textarea);
+	textarea.focus();
+	textarea.select();
+	textarea.setSelectionRange(0, textarea.value.length);
+
+	let copied = false;
+	try {
+		copied = document.execCommand("copy");
+	} catch {
+		copied = false;
+	} finally {
+		textarea.remove();
+	}
+
+	return Boolean(copied);
+}
+
+export async function writeTextToClipboard(text) {
+	const value = String(text ?? "");
+
+	if (canUseClipboardApi()) {
+		try {
+			await navigator.clipboard.writeText(value);
+			return { ok: true, method: "clipboard" };
+		} catch {
+			// Fall back to execCommand below.
+		}
+	}
+
+	if (copyWithExecCommand(value)) {
+		return { ok: true, method: "execCommand" };
+	}
+
+	return { ok: false, method: null };
+}
+

+ 89 - 0
lib/frontend/ui/clipboard.test.js

@@ -0,0 +1,89 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { writeTextToClipboard } from "./clipboard.js";
+
+function createDocumentStub({ copyResult = true } = {}) {
+	const textarea = {
+		value: "",
+		style: {},
+		setAttribute: vi.fn(),
+		focus: vi.fn(),
+		select: vi.fn(),
+		setSelectionRange: vi.fn(),
+		remove: vi.fn(),
+	};
+
+	const documentStub = {
+		createElement: vi.fn().mockReturnValue(textarea),
+		body: {
+			appendChild: vi.fn(),
+		},
+		execCommand: vi.fn().mockReturnValue(copyResult),
+	};
+
+	return { documentStub, textarea };
+}
+
+describe("lib/frontend/ui/clipboard", () => {
+	afterEach(() => {
+		vi.unstubAllGlobals();
+		vi.restoreAllMocks();
+	});
+
+	it("uses navigator.clipboard when available", async () => {
+		const writeText = vi.fn().mockResolvedValue(undefined);
+		vi.stubGlobal("navigator", {
+			clipboard: { writeText },
+		});
+
+		const { documentStub } = createDocumentStub({ copyResult: true });
+		vi.stubGlobal("document", documentStub);
+
+		const result = await writeTextToClipboard("secret");
+
+		expect(writeText).toHaveBeenCalledWith("secret");
+		expect(documentStub.execCommand).not.toHaveBeenCalled();
+		expect(result).toEqual({ ok: true, method: "clipboard" });
+	});
+
+	it("falls back to execCommand when Clipboard API is unavailable", async () => {
+		vi.stubGlobal("navigator", {});
+		const { documentStub, textarea } = createDocumentStub({ copyResult: true });
+		vi.stubGlobal("document", documentStub);
+
+		const result = await writeTextToClipboard("secret");
+
+		expect(documentStub.createElement).toHaveBeenCalledWith("textarea");
+		expect(documentStub.body.appendChild).toHaveBeenCalledWith(textarea);
+		expect(documentStub.execCommand).toHaveBeenCalledWith("copy");
+		expect(textarea.remove).toHaveBeenCalledTimes(1);
+		expect(result).toEqual({ ok: true, method: "execCommand" });
+	});
+
+	it("falls back to execCommand when Clipboard API throws", async () => {
+		const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
+		vi.stubGlobal("navigator", {
+			clipboard: { writeText },
+		});
+
+		const { documentStub } = createDocumentStub({ copyResult: true });
+		vi.stubGlobal("document", documentStub);
+
+		const result = await writeTextToClipboard("secret");
+
+		expect(writeText).toHaveBeenCalledWith("secret");
+		expect(documentStub.execCommand).toHaveBeenCalledWith("copy");
+		expect(result).toEqual({ ok: true, method: "execCommand" });
+	});
+
+	it("returns ok=false when no copy mechanism is available", async () => {
+		vi.stubGlobal("navigator", {});
+		vi.stubGlobal("document", undefined);
+
+		const result = await writeTextToClipboard("secret");
+
+		expect(result).toEqual({ ok: false, method: null });
+	});
+});
+