From c8ed63712d7403d9cdef6469a8bf7dc7f87f3c27 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 23:51:23 +0200 Subject: [PATCH] refactor: extract copyToClipboard util with HTTP fallback + tests --- frontend/src/App.tsx | 21 +---- .../src/utils/__tests__/clipboard.test.ts | 94 +++++++++++++++++++ frontend/src/utils/clipboard.ts | 34 +++++++ 3 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 frontend/src/utils/__tests__/clipboard.test.ts create mode 100644 frontend/src/utils/clipboard.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 88329cf..f8f2969 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, t import { generateUUID } from '@/utils/uuid' import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent' import { generateMarkdownTable } from '@/utils/exportMarkdown' +import { copyToClipboard } from '@/utils/clipboard' import { ExportModal } from '@/components/modals/ExportModal' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { parseYamlToCanvas } from '@/utils/importYaml' @@ -445,26 +446,10 @@ export default function App() { const handleExportMd = useCallback(async () => { const md = generateMarkdownTable(nodes) if (!md) { toast.error('No nodes to export'); return } - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(md) + if (await copyToClipboard(md)) { toast.success('Markdown table copied to clipboard') } else { - // Only allowed access to navigator.clipboard over HTTPS not HTTP - // Use the temporary 'hidden text area for alternative copy method - const textArea = document.createElement("textarea"); - textArea.value = md; - textArea.style.position = "absolute"; - textArea.style.left = "-999999px"; - document.body.prepend(textArea); - textArea.select(); - try { - document.execCommand('copy'); - toast.success("Markdown table copied to clipboard") - } catch (err) { - toast.error(`Markdown copy failed: ${err instanceof Error ? err.message : String(err)}`) - } finally { - textArea.remove(); - } + toast.error('Markdown copy failed') } }, [nodes]) diff --git a/frontend/src/utils/__tests__/clipboard.test.ts b/frontend/src/utils/__tests__/clipboard.test.ts new file mode 100644 index 0000000..022372f --- /dev/null +++ b/frontend/src/utils/__tests__/clipboard.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { copyToClipboard } from '../clipboard' + +const ORIGINAL_CLIPBOARD = Object.getOwnPropertyDescriptor(navigator, 'clipboard') +const ORIGINAL_SECURE = Object.getOwnPropertyDescriptor(window, 'isSecureContext') + +function setClipboard(value: unknown) { + Object.defineProperty(navigator, 'clipboard', { value, configurable: true, writable: true }) +} + +function setSecureContext(value: boolean) { + Object.defineProperty(window, 'isSecureContext', { value, configurable: true, writable: true }) +} + +afterEach(() => { + if (ORIGINAL_CLIPBOARD) Object.defineProperty(navigator, 'clipboard', ORIGINAL_CLIPBOARD) + if (ORIGINAL_SECURE) Object.defineProperty(window, 'isSecureContext', ORIGINAL_SECURE) + vi.restoreAllMocks() + document.body.innerHTML = '' +}) + +describe('copyToClipboard', () => { + describe('secure context (HTTPS)', () => { + it('uses navigator.clipboard.writeText and returns true', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + setClipboard({ writeText }) + setSecureContext(true) + + const ok = await copyToClipboard('hello') + + expect(ok).toBe(true) + expect(writeText).toHaveBeenCalledWith('hello') + }) + + it('returns false when writeText rejects', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')) + setClipboard({ writeText }) + setSecureContext(true) + + expect(await copyToClipboard('hello')).toBe(false) + }) + }) + + describe('non-secure context (HTTP) fallback', () => { + it('falls back to execCommand when clipboard is unavailable', async () => { + setClipboard(undefined) + setSecureContext(false) + const execCommand = vi.fn().mockReturnValue(true) + document.execCommand = execCommand + + const ok = await copyToClipboard('table-data') + + expect(ok).toBe(true) + expect(execCommand).toHaveBeenCalledWith('copy') + }) + + it('falls back when clipboard exists but context is not secure', async () => { + setClipboard({ writeText: vi.fn() }) + setSecureContext(false) + const execCommand = vi.fn().mockReturnValue(true) + document.execCommand = execCommand + + expect(await copyToClipboard('x')).toBe(true) + expect(execCommand).toHaveBeenCalledWith('copy') + }) + + it('removes the temporary textarea after copying', async () => { + setClipboard(undefined) + setSecureContext(false) + document.execCommand = vi.fn().mockReturnValue(true) + + await copyToClipboard('x') + + expect(document.querySelector('textarea')).toBeNull() + }) + + it('returns false and cleans up when execCommand throws', async () => { + setClipboard(undefined) + setSecureContext(false) + document.execCommand = vi.fn(() => { throw new Error('boom') }) + + expect(await copyToClipboard('x')).toBe(false) + expect(document.querySelector('textarea')).toBeNull() + }) + + it('returns false when execCommand reports failure', async () => { + setClipboard(undefined) + setSecureContext(false) + document.execCommand = vi.fn().mockReturnValue(false) + + expect(await copyToClipboard('x')).toBe(false) + }) + }) +}) diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000..9044940 --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -0,0 +1,34 @@ +/** + * Copy text to the clipboard. + * + * Uses the async Clipboard API when available in a secure context (HTTPS). + * Over plain HTTP, `navigator.clipboard` is undefined, so falls back to a + * hidden textarea + `document.execCommand('copy')`. + * + * @returns true if the copy succeeded, false otherwise. + */ +export async function copyToClipboard(text: string): Promise { + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + return false + } + } + + // Fallback for non-secure (HTTP) contexts where navigator.clipboard is unavailable. + const textArea = document.createElement('textarea') + textArea.value = text + textArea.style.position = 'absolute' + textArea.style.left = '-999999px' + document.body.prepend(textArea) + textArea.select() + try { + return document.execCommand('copy') + } catch { + return false + } finally { + textArea.remove() + } +}