diff --git a/src/app/editor/canvas/menu/actions.ts b/src/app/editor/canvas/menu/actions.ts index 278d4ec8b..0c01c2bd3 100644 --- a/src/app/editor/canvas/menu/actions.ts +++ b/src/app/editor/canvas/menu/actions.ts @@ -26,7 +26,9 @@ export function createCanvasMenuActions(store: EditorStore, selectedIds: Ref { + 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 }) diff --git a/src/app/editor/clipboard/memory.ts b/src/app/editor/clipboard/memory.ts new file mode 100644 index 000000000..ba483bc8e --- /dev/null +++ b/src/app/editor/clipboard/memory.ts @@ -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 = '' +} diff --git a/src/app/editor/clipboard/paste-to-replace.ts b/src/app/editor/clipboard/paste-to-replace.ts index 123c1dac3..15c2123f4 100644 --- a/src/app/editor/clipboard/paste-to-replace.ts +++ b/src/app/editor/clipboard/paste-to-replace.ts @@ -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).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) { diff --git a/src/app/editor/clipboard/system.ts b/src/app/editor/clipboard/system.ts index 8440e0494..6a0f5140e 100644 --- a/src/app/editor/clipboard/system.ts +++ b/src/app/editor/clipboard/system.ts @@ -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() + + 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 { + 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).clipboard?.write === 'function' + ) { + try { + const itemData: Record = {} + 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).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 { + if ( + typeof navigator !== 'undefined' && + typeof (navigator as Partial).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) } diff --git a/src/app/editor/session/create.ts b/src/app/editor/session/create.ts index 802ffbded..75f65f5d0 100644 --- a/src/app/editor/session/create.ts +++ b/src/app/editor/session/create.ts @@ -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) diff --git a/src/app/shell/keyboard/clipboard.ts b/src/app/shell/keyboard/clipboard.ts index 7d62cbb24..954960b57 100644 --- a/src/app/shell/keyboard/clipboard.ts +++ b/src/app/shell/keyboard/clipboard.ts @@ -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 { + 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) + } }) } diff --git a/tests/engine/app/clipboard/keyboard.test.ts b/tests/engine/app/clipboard/keyboard.test.ts new file mode 100644 index 000000000..0563a6c3a --- /dev/null +++ b/tests/engine/app/clipboard/keyboard.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test' + +import { copyAndDeleteSelection } from '@/app/shell/keyboard/clipboard' + +function storeWithCopyResult(result: Promise) { + 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) + }) +}) diff --git a/tests/engine/app/clipboard/memory.test.ts b/tests/engine/app/clipboard/memory.test.ts new file mode 100644 index 000000000..71b461817 --- /dev/null +++ b/tests/engine/app/clipboard/memory.test.ts @@ -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 = '' + 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 = {} + 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') + }) +})