fix(editor): harden clipboard fallbacks

Merges the contributor clipboard fallback fix with maintainer follow-ups for browser cut safety and isolated fallback tests. Selections are preserved when clipboard serialization fails, and clipboard fallback tests no longer depend on host APIs.
This commit is contained in:
Mikel Cabezas 2026-08-24 18:16:16 +02:00 committed by GitHub
parent b65b1bd481
commit 7a311677cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 570 additions and 35 deletions

View file

@ -26,7 +26,9 @@ export function createCanvasMenuActions(store: EditorStore, selectedIds: Ref<Set
}
function execCommand(cmd: 'copy' | 'cut' | 'paste') {
void executeClipboardCommand(store, cmd).then((ok) => {
const { cursorCanvasX: ccx, cursorCanvasY: ccy } = store.state
const cursorPos = ccx != null && ccy != null ? { x: ccx, y: ccy } : undefined
void executeClipboardCommand(store, cmd, cursorPos).then((ok) => {
if (!ok) toast.error(notificationMessages.get().clipboardAccessBlocked)
return undefined
})

View file

@ -0,0 +1,17 @@
let memoryClipboardHTML = ''
export function setInMemoryClipboardHTML(html: string): void {
memoryClipboardHTML = html
}
export function getInMemoryClipboardHTML(): string {
return memoryClipboardHTML
}
export function hasInMemoryClipboardHTML(): boolean {
return Boolean(memoryClipboardHTML)
}
export function clearInMemoryClipboardHTML(): void {
memoryClipboardHTML = ''
}

View file

@ -1,4 +1,5 @@
import type { EditorStore } from '@/app/editor/active-store'
import { getInMemoryClipboardHTML } from '@/app/editor/clipboard/memory'
import { notificationMessages } from '@/app/i18n/notifications'
import { toast } from '@/app/shell/ui'
import { readTauriClipboardText } from '@/app/tauri/clipboard'
@ -10,17 +11,34 @@ function isDesignClipboardHTML(text: string) {
async function readClipboardHTML() {
if (isTauri()) {
const text = await readTauriClipboardText()
return text && isDesignClipboardHTML(text) ? text : null
try {
const text = await readTauriClipboardText()
if (text && isDesignClipboardHTML(text)) return text
} catch (error) {
console.warn('Tauri clipboard read failed', error)
}
const memory = getInMemoryClipboardHTML()
return memory && isDesignClipboardHTML(memory) ? memory : null
}
if (typeof navigator.clipboard.read !== 'function') return null
const items = await navigator.clipboard.read()
for (const item of items) {
if (!item.types.includes('text/html')) continue
return (await item.getType('text/html')).text()
if (
typeof navigator !== 'undefined' &&
typeof (navigator as Partial<Navigator>).clipboard?.read === 'function'
) {
try {
const items = await navigator.clipboard.read()
for (const item of items) {
if (!item.types.includes('text/html')) continue
const text = await (await item.getType('text/html')).text()
if (text && isDesignClipboardHTML(text)) return text
}
} catch (error) {
console.warn('System clipboard read failed', error)
}
}
return null
const memory = getInMemoryClipboardHTML()
return memory && isDesignClipboardHTML(memory) ? memory : null
}
export async function pasteClipboardToReplace(store: EditorStore) {

View file

@ -1,12 +1,76 @@
import type { Vector } from '@open-pencil/scene-graph/primitives'
import type { EditorStore } from '@/app/editor/active-store'
import { getInMemoryClipboardHTML, setInMemoryClipboardHTML } from '@/app/editor/clipboard/memory'
import { readTauriClipboardText, writeTauriClipboardHTML } from '@/app/tauri/clipboard'
import { isTauri } from '@/app/tauri/env'
function createTransfer() {
if (typeof DataTransfer === 'undefined') return null
return new DataTransfer()
const noop = () => undefined
class MockDataTransfer implements DataTransfer {
private data = new Map<string, string>()
dropEffect: 'none' | 'copy' | 'link' | 'move' = 'none'
effectAllowed:
| 'none'
| 'copy'
| 'copyLink'
| 'copyMove'
| 'link'
| 'linkMove'
| 'move'
| 'all'
| 'uninitialized' = 'none'
files: FileList = {
length: 0,
item: () => null,
[Symbol.iterator]: function* () {
yield* []
}
} as FileList
items: DataTransferItemList = {
length: 0,
add: () => null,
clear: noop,
remove: noop,
[Symbol.iterator]: function* () {
yield* []
}
} as DataTransferItemList
setData(format: string, data: string): void {
this.data.set(format, data)
}
getData(format: string): string {
return this.data.get(format) ?? ''
}
clearData(format?: string): void {
if (format) this.data.delete(format)
else this.data.clear()
}
setDragImage(image: Element, x: number, y: number): void {
void image
void x
void y
}
get types(): readonly string[] {
return [...this.data.keys()]
}
}
function createTransfer(): DataTransfer {
if (typeof DataTransfer !== 'undefined') {
try {
return new DataTransfer()
} catch (error) {
console.warn('DataTransfer instantiation failed', error)
}
}
return new MockDataTransfer()
}
function isDesignClipboardHTML(text: string) {
@ -17,12 +81,12 @@ export async function copySelectionToTauriClipboard(store: EditorStore) {
if (!isTauri()) return false
try {
const transfer = createTransfer()
if (!transfer) return false
await store.writeCopyData(transfer)
const html = transfer.getData('text/html')
const plainText = transfer.getData('text/plain')
if (!html && !plainText) return false
await writeTauriClipboardHTML(html || plainText, plainText)
if (html) setInMemoryClipboardHTML(html)
return true
} catch (error) {
console.warn('Tauri clipboard copy failed', error)
@ -30,42 +94,146 @@ export async function copySelectionToTauriClipboard(store: EditorStore) {
}
}
export async function copySelectionToBrowserClipboard(store: EditorStore): Promise<boolean> {
try {
const transfer = createTransfer()
await store.writeCopyData(transfer)
const html = transfer.getData('text/html')
const plainText = transfer.getData('text/plain')
if (!html && !plainText) return false
if (html) setInMemoryClipboardHTML(html)
if (
typeof ClipboardItem !== 'undefined' &&
typeof Blob !== 'undefined' &&
typeof navigator !== 'undefined' &&
typeof (navigator as Partial<Navigator>).clipboard?.write === 'function'
) {
try {
const itemData: Record<string, Blob> = {}
if (html) itemData['text/html'] = new Blob([html], { type: 'text/html' })
if (plainText) itemData['text/plain'] = new Blob([plainText], { type: 'text/plain' })
await navigator.clipboard.write([new ClipboardItem(itemData)])
if (html) setInMemoryClipboardHTML(html)
return true
} catch (error) {
console.warn('Modern clipboard write failed', error)
}
}
if (
typeof document !== 'undefined' &&
typeof (document as Partial<Document>).execCommand === 'function'
) {
let listener: ((event: ClipboardEvent) => void) | null = null
try {
const copyState = { payloadCopied: false }
listener = (event: ClipboardEvent) => {
if (event.clipboardData) {
if (html) event.clipboardData.setData('text/html', html)
if (plainText) event.clipboardData.setData('text/plain', plainText)
event.preventDefault()
copyState.payloadCopied = true
}
}
document.addEventListener('copy', listener)
const success = document.execCommand('copy')
if (success && copyState.payloadCopied) {
if (html) setInMemoryClipboardHTML(html)
return true
}
} catch (error) {
console.warn('execCommand copy fallback failed', error)
} finally {
if (listener) {
document.removeEventListener('copy', listener)
}
}
}
return false
} catch (error) {
console.warn('Browser clipboard copy failed', error)
return false
}
}
export async function pasteFromTauriClipboard(store: EditorStore, cursorPos?: Vector) {
if (!isTauri()) return false
try {
const text = await readTauriClipboardText()
if (!text || !isDesignClipboardHTML(text)) return false
await store.pasteFromHTML(text, cursorPos)
return true
if (text && isDesignClipboardHTML(text)) {
await store.pasteFromHTML(text, cursorPos)
return true
}
} catch (error) {
console.warn('Tauri clipboard paste failed', error)
return false
}
const memoryHTML = getInMemoryClipboardHTML()
if (memoryHTML && isDesignClipboardHTML(memoryHTML)) {
await store.pasteFromHTML(memoryHTML, cursorPos)
return true
}
return false
}
export async function pasteFromBrowserClipboard(
store: EditorStore,
cursorPos?: Vector
): Promise<boolean> {
if (
typeof navigator !== 'undefined' &&
typeof (navigator as Partial<Navigator>).clipboard?.read === 'function'
) {
try {
const items = await navigator.clipboard.read()
for (const item of items) {
if (item.types.includes('text/html')) {
const blob = await item.getType('text/html')
const html = await blob.text()
if (html && isDesignClipboardHTML(html)) {
await store.pasteFromHTML(html, cursorPos)
return true
}
}
}
} catch (error) {
console.warn('Browser clipboard read failed', error)
}
}
const memoryHTML = getInMemoryClipboardHTML()
if (memoryHTML && isDesignClipboardHTML(memoryHTML)) {
await store.pasteFromHTML(memoryHTML, cursorPos)
return true
}
return false
}
export async function executeClipboardCommand(
store: EditorStore,
command: 'copy' | 'cut' | 'paste'
command: 'copy' | 'cut' | 'paste',
cursorPos?: Vector
) {
if (command === 'copy') {
if (await copySelectionToTauriClipboard(store)) return true
if (isTauri()) return copySelectionToTauriClipboard(store)
return copySelectionToBrowserClipboard(store)
}
if (command === 'cut') {
if (await copySelectionToTauriClipboard(store)) {
const copied = isTauri()
? await copySelectionToTauriClipboard(store)
: await copySelectionToBrowserClipboard(store)
if (copied) {
store.deleteSelected()
return true
}
}
if (command === 'paste') {
if (await pasteFromTauriClipboard(store)) return true
}
try {
return document.execCommand(command)
} catch (error) {
console.warn(`Clipboard command ${command} failed`, error)
return false
}
if (isTauri()) return pasteFromTauriClipboard(store, cursorPos)
return pasteFromBrowserClipboard(store, cursorPos)
}

View file

@ -19,7 +19,7 @@ import {
defineEditorStoreAccessors
} from '@/app/editor/session/modules'
import { createInitialAppEditorState, type AppEditorState } from '@/app/editor/session/types'
import { IS_TAURI } from '@/constants'
import { IS_BROWSER, IS_TAURI } from '@/constants'
export { EDITOR_TOOLS as TOOLS, TOOL_SHORTCUTS } from '@open-pencil/core/editor'
export type { EditorToolDef as ToolDef, Tool } from '@open-pencil/core/editor'
@ -39,7 +39,10 @@ export function createEditorStore(initialGraph?: SceneGraph) {
getViewportSize: () =>
viewportSize.width > 0 && viewportSize.height > 0
? viewportSize
: { width: window.innerWidth, height: window.innerHeight }
: {
width: IS_BROWSER ? window.innerWidth : 1920,
height: IS_BROWSER ? window.innerHeight : 1080
}
})
const io = new IORegistry(BUILTIN_IO_FORMATS)
bindClipboardNotifications(editor)

View file

@ -3,6 +3,7 @@ import { useEventListener } from '@vueuse/core'
import { extractImageFilesFromClipboard } from '@open-pencil/vue'
import type { EditorStore } from '@/app/editor/active-store'
import { getInMemoryClipboardHTML } from '@/app/editor/clipboard/memory'
import {
copySelectionToTauriClipboard,
pasteFromTauriClipboard
@ -15,6 +16,20 @@ function cursorPosition(store: EditorStore) {
return ccx != null && ccy != null ? { x: ccx, y: ccy } : undefined
}
export async function copyAndDeleteSelection(
store: EditorStore,
clipboardData: DataTransfer
): Promise<boolean> {
try {
await store.writeCopyData(clipboardData)
store.deleteSelected()
return true
} catch (error) {
console.warn('Browser clipboard cut failed', error)
return false
}
}
export function bindEditorClipboard(store: EditorStore) {
useEventListener(window, 'copy', (e: ClipboardEvent) => {
if (isEditing(e) || hasDocumentTextSelection()) return
@ -36,8 +51,7 @@ export function bindEditorClipboard(store: EditorStore) {
})
return
}
if (e.clipboardData) void store.writeCopyData(e.clipboardData)
store.deleteSelected()
if (e.clipboardData) void copyAndDeleteSelection(store, e.clipboardData)
})
useEventListener(window, 'paste', (e: ClipboardEvent) => {
@ -60,6 +74,14 @@ export function bindEditorClipboard(store: EditorStore) {
return
}
if (isTauri()) void pasteFromTauriClipboard(store, cursorPos)
if (isTauri()) {
void pasteFromTauriClipboard(store, cursorPos)
return
}
const memoryHTML = getInMemoryClipboardHTML()
if (memoryHTML) {
void store.pasteFromHTML(memoryHTML, cursorPos)
}
})
}

View file

@ -0,0 +1,30 @@
import { describe, expect, test } from 'bun:test'
import { copyAndDeleteSelection } from '@/app/shell/keyboard/clipboard'
function storeWithCopyResult(result: Promise<void>) {
let deleted = false
return {
store: {
writeCopyData: () => result,
deleteSelected: () => {
deleted = true
}
},
wasDeleted: () => deleted
}
}
describe('browser keyboard clipboard cut', () => {
test('deletes only after clipboard data is written', async () => {
const { store, wasDeleted } = storeWithCopyResult(Promise.resolve())
expect(await copyAndDeleteSelection(store as never, {} as DataTransfer)).toBe(true)
expect(wasDeleted()).toBe(true)
})
test('preserves selection when clipboard serialization fails', async () => {
const { store, wasDeleted } = storeWithCopyResult(Promise.reject(new Error('copy failed')))
expect(await copyAndDeleteSelection(store as never, {} as DataTransfer)).toBe(false)
expect(wasDeleted()).toBe(false)
})
})

View file

@ -0,0 +1,275 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import type { Vector } from '@open-pencil/scene-graph/primitives'
import {
clearInMemoryClipboardHTML,
getInMemoryClipboardHTML,
hasInMemoryClipboardHTML,
setInMemoryClipboardHTML
} from '@/app/editor/clipboard/memory'
import { pasteClipboardToReplace } from '@/app/editor/clipboard/paste-to-replace'
import {
copySelectionToBrowserClipboard,
executeClipboardCommand
} from '@/app/editor/clipboard/system'
import { createEditorStore } from '@/app/editor/session/create'
import { toast } from '@/app/shell/ui'
const originalClipboard = navigator.clipboard
beforeEach(() => {
clearInMemoryClipboardHTML()
toast.toasts.value = []
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
})
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalClipboard })
})
const noop = () => undefined
describe('in-memory clipboard', () => {
test('stores, retrieves, and clears clipboard HTML', () => {
expect(hasInMemoryClipboardHTML()).toBe(false)
expect(getInMemoryClipboardHTML()).toBe('')
const sampleHTML = '<!--(openpencil)test-->'
setInMemoryClipboardHTML(sampleHTML)
expect(hasInMemoryClipboardHTML()).toBe(true)
expect(getInMemoryClipboardHTML()).toBe(sampleHTML)
clearInMemoryClipboardHTML()
expect(hasInMemoryClipboardHTML()).toBe(false)
expect(getInMemoryClipboardHTML()).toBe('')
})
test('copySelectionToBrowserClipboard copies payload via execCommand fallback when modern clipboard is unavailable', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Copy Target',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
const capturedData: Record<string, string> = {}
let copyEventTriggered = false
const originalDocument = globalThis.document
try {
let listener: ((e: unknown) => void) | null = null
globalThis.document = {
addEventListener: (_type: string, fn: (e: unknown) => void) => {
listener = fn
},
removeEventListener: (_type: string, _fn: (e: unknown) => void) => {
listener = null
},
execCommand: (cmd: string) => {
if (cmd === 'copy' && listener) {
copyEventTriggered = true
const mockEvent = {
clipboardData: {
setData: (type: string, val: string) => {
capturedData[type] = val
}
},
preventDefault: noop
}
listener(mockEvent)
return true
}
return false
}
} as Document
const success = await copySelectionToBrowserClipboard(store)
expect(success).toBe(true)
expect(copyEventTriggered).toBe(true)
expect(capturedData['text/html']).toBeDefined()
expect(capturedData['text/plain']).toBeDefined()
expect(hasInMemoryClipboardHTML()).toBe(true)
} finally {
globalThis.document = originalDocument
}
})
test('copySelectionToBrowserClipboard returns false when execCommand fails or is unavailable', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Copy Target',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
const originalDocument = globalThis.document
try {
globalThis.document = {
addEventListener: noop,
removeEventListener: noop,
execCommand: () => false
} as Document
const success = await copySelectionToBrowserClipboard(store)
expect(success).toBe(false)
} finally {
globalThis.document = originalDocument
}
})
test('executeClipboardCommand cut does not delete nodes when clipboard copy fails', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Safe Rect',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
const cutOk = await executeClipboardCommand(store, 'cut')
expect(cutOk).toBe(false)
expect(store.graph.getNode(rect.id)).toBeDefined()
})
test('pasteToReplace uses in-memory clipboard when system clipboard is unavailable', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const target = store.graph.createNode('RECTANGLE', pageId, {
name: 'Target',
x: 10,
y: 10,
width: 100,
height: 100
})
store.select([target.id])
// Copy target (populates in-memory clipboard)
await executeClipboardCommand(store, 'copy')
expect(hasInMemoryClipboardHTML()).toBe(true)
// Create another node to replace
const replaceTarget = store.graph.createNode('RECTANGLE', pageId, {
name: 'To Replace',
x: 50,
y: 50,
width: 80,
height: 80
})
store.select([replaceTarget.id])
// Run pasteClipboardToReplace
await pasteClipboardToReplace(store)
// Verify replace succeeded without toast errors
expect(toast.toasts.value).toHaveLength(0)
expect(store.graph.getNode(replaceTarget.id)).toBeUndefined()
})
test('executeClipboardCommand cut deletes selection and returns true when copy succeeds', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Cut Target',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
const originalDocument = globalThis.document
try {
let listener: ((e: unknown) => void) | null = null
globalThis.document = {
addEventListener: (_type: string, fn: (e: unknown) => void) => {
listener = fn
},
removeEventListener: (_type: string, _fn: (e: unknown) => void) => {
listener = null
},
execCommand: (cmd: string) => {
if (cmd === 'copy' && listener) {
listener({
clipboardData: { setData: noop },
preventDefault: noop
})
return true
}
return false
}
} as Document
const cutOk = await executeClipboardCommand(store, 'cut')
expect(cutOk).toBe(true)
expect(store.graph.getNode(rect.id)).toBeUndefined()
} finally {
globalThis.document = originalDocument
}
})
test('executeClipboardCommand paste forwards cursorPos to store.pasteFromHTML', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Source',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
await executeClipboardCommand(store, 'copy')
let receivedCursorPos: Vector | undefined
const originalPaste = store.pasteFromHTML.bind(store)
store.pasteFromHTML = async (html, cursorPos, options) => {
receivedCursorPos = cursorPos
return originalPaste(html, cursorPos, options)
}
const cursorPos: Vector = { x: 150, y: 250 }
const pasteOk = await executeClipboardCommand(store, 'paste', cursorPos)
expect(pasteOk).toBe(true)
expect(receivedCursorPos).toEqual(cursorPos)
})
test('executeClipboardCommand paste falls back to memory clipboard', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Source Rect',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
await executeClipboardCommand(store, 'copy')
expect(hasInMemoryClipboardHTML()).toBe(true)
const pasteOk = await executeClipboardCommand(store, 'paste')
expect(pasteOk).toBe(true)
// An additional node should have been pasted
const selected = [...store.state.selectedIds]
expect(selected).toHaveLength(1)
expect(selected[0]).not.toBe(rect.id)
const pastedNode = store.graph.getNode(selected[0])
expect(pastedNode?.name).toBe('Source Rect')
})
})