refactor: extract copyToClipboard util with HTTP fallback + tests
This commit is contained in:
+3
-18
@@ -6,6 +6,7 @@ import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, t
|
|||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
import { ExportModal } from '@/components/modals/ExportModal'
|
import { ExportModal } from '@/components/modals/ExportModal'
|
||||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
import { parseYamlToCanvas } from '@/utils/importYaml'
|
import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||||
@@ -445,26 +446,10 @@ export default function App() {
|
|||||||
const handleExportMd = useCallback(async () => {
|
const handleExportMd = useCallback(async () => {
|
||||||
const md = generateMarkdownTable(nodes)
|
const md = generateMarkdownTable(nodes)
|
||||||
if (!md) { toast.error('No nodes to export'); return }
|
if (!md) { toast.error('No nodes to export'); return }
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (await copyToClipboard(md)) {
|
||||||
await navigator.clipboard.writeText(md)
|
|
||||||
toast.success('Markdown table copied to clipboard')
|
toast.success('Markdown table copied to clipboard')
|
||||||
} else {
|
} else {
|
||||||
// Only allowed access to navigator.clipboard over HTTPS not HTTP
|
toast.error('Markdown copy failed')
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [nodes])
|
}, [nodes])
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<boolean> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user