Merge pull request #219 from hooli/fix/markdown-copy

Fix: Markdow Copy now works over non-secure HTTP
This commit is contained in:
Rémy
2026-06-27 01:55:53 +02:00
committed by GitHub
3 changed files with 134 additions and 2 deletions
+6 -2
View File
@@ -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,8 +446,11 @@ export default function App() {
const handleExportMd = useCallback(async () => {
const md = generateMarkdownTable(nodes)
if (!md) { toast.error('No nodes to export'); return }
await navigator.clipboard.writeText(md)
toast.success('Markdown table copied to clipboard')
if (await copyToClipboard(md)) {
toast.success('Markdown table copied to clipboard')
} else {
toast.error('Markdown copy failed')
}
}, [nodes])
const handleExportYaml = useCallback(() => {
@@ -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)
})
})
})
+34
View File
@@ -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()
}
}