diff --git a/packages/core/src/clipboard/index.ts b/packages/core/src/clipboard/index.ts index 00c9c93..a3fd4eb 100644 --- a/packages/core/src/clipboard/index.ts +++ b/packages/core/src/clipboard/index.ts @@ -17,6 +17,20 @@ export { deserializeClipboard, isCanvasHarnessClipboard, serializeSelection } fr const MIME_NATIVE = 'application/x-canvas-harness+json' const MIME_TEXT = 'text/plain' +// Per-store in-memory fallback clipboard. Used ONLY when a paste sees a +// completely empty DataTransfer (a WKWebView quirk where the system +// clipboard read yields nothing despite a prior in-app copy). Keyed by +// store so independent instances never leak into each other, +// and never consulted when the transfer holds real (external) content — +// otherwise a paste of outside data would be hijacked by stale nodes. +const memoryByStore = new WeakMap() + +const textFallback = (clip: SerializedClipboard): string => + clip.nodes + .map(n => n.content ?? '') + .filter(s => s.length > 0) + .join('\n') + /** * Copies the current selection to the system clipboard. Writes both a * native MIME (`application/x-canvas-harness+json`) and a `text/plain` @@ -31,6 +45,7 @@ const MIME_TEXT = 'text/plain' */ export const copy = async (store: CanvasStore): Promise => { const clip = serializeSelection(store) + memoryByStore.set(store, clip) await writeClipboard(clip) return clip } @@ -81,6 +96,12 @@ export const paste = async ( payload?: SerializedClipboard, opts?: DeserializeOptions, ): Promise<(NodeId | EdgeId)[] | null> => { + // Explicit payload, else the system clipboard. Deliberately NO + // in-memory fallback here: readClipboard() returns null for both an + // empty/unavailable clipboard AND a non-canvas one, so falling back to + // memory would paste stale nodes over legitimate external content. The + // DOM paste-event path handles the WebKit empty-transfer case instead, + // via readClipboardFromDataTransfer (which can tell the two apart). const clip = payload ?? (await readClipboard()) if (!clip) return null // Cursor-as-default: when the caller didn't specify positioning, @@ -98,13 +119,101 @@ export const paste = async ( return ids } +/** + * Copy the current selection into a `DataTransfer` — the WebKit-safe + * path, called from the DOM `copy`/`cut` events (which run inside the + * user gesture and expose a synchronous `DataTransfer`, unlike the + * restricted async `navigator.clipboard`). Also stashes an in-memory + * copy so paste works even when the system clipboard drops our payload. + * + * `` wires this to Cmd/Ctrl+C. Call directly only for a custom + * copy handler on a `copy`/`cut` event. + */ +export const writeSelectionToDataTransfer = ( + store: CanvasStore, + data: DataTransfer, +): SerializedClipboard => { + const clip = serializeSelection(store) + memoryByStore.set(store, clip) + const json = JSON.stringify(clip) + try { + // Custom MIME for engines that keep it (Chromium). WebKit drops it + // from a DataTransfer read, which is why the JSON also rides in + // text/plain below — the one type WebKit reliably round-trips. + data.setData(MIME_NATIVE, json) + } catch { + // Ignore — text/plain carries the payload. + } + // text/plain holds the JSON (not human text) so intra-app paste + // round-trips in WebKit via the synchronous paste event. Trade-off: + // pasting a selection into a plain-text field shows JSON. + data.setData(MIME_TEXT, json) + return clip +} + +/** + * Cut into a `DataTransfer`: copy the selection then remove it, in one + * undoable batch. The DOM `cut` event path. Shares the write + removal + * contract with the rest of the module so the React layer doesn't + * re-implement it. + */ +export const cutSelectionToDataTransfer = ( + store: CanvasStore, + data: DataTransfer, +): SerializedClipboard => { + const clip = writeSelectionToDataTransfer(store, data) + store.batch(() => { + for (const n of clip.nodes) store.removeNode(n.id) + for (const e of clip.edges) store.removeEdge(e.id) + }) + return clip +} + +/** + * Read a canvas-harness payload from a `DataTransfer` (the DOM `paste` + * event). Precedence: native MIME → JSON in text/plain → (only when the + * transfer is completely empty) the per-store in-memory fallback. + * + * Returns null when the transfer holds content that isn't ours (plain + * text, an image, foreign JSON) so an external paste is never hijacked + * by a previously-copied canvas selection. `store` scopes the fallback. + */ +export const readClipboardFromDataTransfer = ( + store: CanvasStore, + data: DataTransfer, +): SerializedClipboard | null => { + const native = data.getData(MIME_NATIVE) + if (native) { + try { + const parsed = JSON.parse(native) + if (isCanvasHarnessClipboard(parsed)) return parsed + } catch { + // Fall through to text/plain. + } + } + const text = data.getData(MIME_TEXT) + if (text) { + // The transfer carries real content. Use it only if it's ours; + // otherwise it's an external paste — return null, don't fall back to + // the stale in-memory clipboard. + if (text.trim().startsWith('{')) { + try { + const parsed = JSON.parse(text) + if (isCanvasHarnessClipboard(parsed)) return parsed + } catch { + // Malformed JSON text — treat as external, fall through to null. + } + } + return null + } + // Empty transfer (the WKWebView quirk) → last in-app copy for this store. + return memoryByStore.get(store) ?? null +} + const writeClipboard = async (clip: SerializedClipboard): Promise => { if (typeof navigator === 'undefined' || !navigator.clipboard) return const json = JSON.stringify(clip) - const text = clip.nodes - .map(n => n.content ?? '') - .filter(s => s.length > 0) - .join('\n') + const text = textFallback(clip) // navigator.clipboard.write expects ClipboardItem; not all engines // support arbitrary mime types. We dual-write best-effort. type ClipboardItemCtor = new (data: Record) => ClipboardItem diff --git a/packages/react/src/Canvas.tsx b/packages/react/src/Canvas.tsx index ff07070..5cdfd9a 100644 --- a/packages/react/src/Canvas.tsx +++ b/packages/react/src/Canvas.tsx @@ -4,12 +4,13 @@ import { type EditorAdapterFactory, type NodeId, type Renderer, - copy, createRenderer, - cut, + cutSelectionToDataTransfer, hitTestAny, paste, + readClipboardFromDataTransfer, screenToWorld, + writeSelectionToDataTransfer, } from '@canvas-harness/core' import { type ReactNode, useEffect, useRef, useState } from 'react' import { CanvasProvider, useCanvasStore } from './context' @@ -459,24 +460,15 @@ function CanvasSurface({ } }, [store, onCreateDrag]) - // Cmd/Ctrl+C/X/V — copy/cut/paste. Skip when an input is focused so - // the editor's native text-clipboard isn't hijacked. + // Cmd/Ctrl+[ / ] — z-order. (Copy/cut/paste are handled via the DOM + // clipboard events below, not keydown.) Skip when an input is focused. useEffect(() => { const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null if (target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT')) return const meta = e.metaKey || e.ctrlKey if (!meta) return - if (e.key === 'c' || e.key === 'C') { - e.preventDefault() - void copy(store) - } else if (e.key === 'x' || e.key === 'X') { - e.preventDefault() - void cut(store) - } else if (e.key === 'v' || e.key === 'V') { - e.preventDefault() - void paste(store) - } else if (e.key === ']') { + if (e.key === ']') { // Cmd+] = bring forward; Cmd+Shift+] = bring to front. const selection = store.getSelection() if (selection.length === 0) return @@ -496,6 +488,75 @@ function CanvasSurface({ return () => window.removeEventListener('keydown', onKey) }, [store]) + // Copy / cut / paste via the DOM clipboard events (not keydown). These + // fire inside the user gesture and expose a synchronous DataTransfer, + // which WebKit (Safari / WKWebView) allows — unlike the async + // navigator.clipboard read it restricts, which is why keydown + + // navigator.clipboard silently no-op'd there. + // + // Listeners live on the HOST element, not window: the events only + // reach it when the canvas (or a child) holds focus, so an ordinary + // page-text copy elsewhere on the page is never hijacked. Skip when a + // text editor is focused so the native text clipboard still works. + useEffect(() => { + const el = wrapRef.current + if (!el) return + const isTextTarget = (t: EventTarget | null): boolean => { + const node = t as HTMLElement | null + return ( + !!node && + (node.tagName === 'TEXTAREA' || node.tagName === 'INPUT' || node.isContentEditable) + ) + } + const onCopy = (e: ClipboardEvent) => { + if (isTextTarget(e.target) || !e.clipboardData || store.getSelection().length === 0) return + e.preventDefault() + writeSelectionToDataTransfer(store, e.clipboardData) + } + const onCut = (e: ClipboardEvent) => { + if (isTextTarget(e.target) || !e.clipboardData || store.getSelection().length === 0) return + e.preventDefault() + cutSelectionToDataTransfer(store, e.clipboardData) + } + const onPaste = (e: ClipboardEvent) => { + if (isTextTarget(e.target) || !e.clipboardData) return + const clip = readClipboardFromDataTransfer(store, e.clipboardData) + if (!clip) return + e.preventDefault() + void paste(store, clip) + } + el.addEventListener('copy', onCopy) + el.addEventListener('cut', onCut) + el.addEventListener('paste', onPaste) + return () => { + el.removeEventListener('copy', onCopy) + el.removeEventListener('cut', onCut) + el.removeEventListener('paste', onPaste) + } + }, [store]) + + // Focus the host on pointer interaction so the clipboard events above + // reliably fire in WebKit, which won't dispatch copy/cut/paste without + // a focused element. Skipped while editing, and when the pointer lands + // on an interactive control inside a custom DOM-overlay node — so we + // never steal focus from that node's own inputs/buttons. + useEffect(() => { + const el = wrapRef.current + if (!el) return + const onPointerDown = (e: PointerEvent) => { + if (store.getInteractionState().mode === 'editing') return + const t = e.target as HTMLElement | null + // Don't steal focus from a custom overlay node's own controls. + // `isContentEditable` covers every contenteditable variant (bare, + // "true", "plaintext-only") — matching the clipboard guard above. + if (t && t !== el && (t.isContentEditable || t.closest('input, textarea, select, button, a'))) + return + el.focus({ preventScroll: true }) + } + el.addEventListener('pointerdown', onPointerDown) + return () => el.removeEventListener('pointerdown', onPointerDown) + }, [store]) + // Initial transform — subsequent updates are written directly to // overlayRef.current.style by the camera-subscription effect above. const initialCamera = store.getCamera() @@ -505,9 +566,16 @@ function CanvasSurface({
event wiring, via observable store effects. + * Note: a *synthetic* ClipboardEvent in chromium doesn't reflect writes + * made inside the handler back to the DataTransfer we pass in, so the + * write path is asserted through the helper (1) and the empty-transfer + * fallback (2), not by reading a dispatched event's DataTransfer. + */ +import { + type CanvasStore, + asClientId, + asNodeId, + createCanvasStore, + readClipboardFromDataTransfer, + writeSelectionToDataTransfer, +} from '@canvas-harness/core' +import { StrictMode, act } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, test } from 'vitest' +import { Canvas, CanvasProvider } from '../src' +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const setupStore = (): CanvasStore => { + const store = createCanvasStore({ clientId: asClientId('test') }) + store.addNode({ + id: asNodeId('n1'), + type: 'rect', + x: 100, + y: 100, + w: 80, + h: 60, + angle: 0, + z: 0, + groups: [], + content: 'hello', + }) + return store +} + +const mountCanvas = async (store: CanvasStore) => { + const container = document.createElement('div') + container.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:600px' + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render( + + + + + , + ) + }) + await new Promise(resolve => setTimeout(resolve, 0)) + const wrap = container.querySelector('[data-canvas-host]') as HTMLDivElement + if (!wrap) throw new Error('canvas host not found') + return { + wrap, + cleanup: () => act(async () => root.unmount()).then(() => container.remove()), + } +} + +const clipboardEvent = (type: 'copy' | 'cut' | 'paste'): ClipboardEvent => + new ClipboardEvent(type, { clipboardData: new DataTransfer(), bubbles: true, cancelable: true }) + +describe('DataTransfer helpers (engine-agnostic clipboard I/O)', () => { + test('write then read round-trips the selection', () => { + const store = setupStore() + store.setSelection([asNodeId('n1')]) + const dt = new DataTransfer() + + const written = writeSelectionToDataTransfer(store, dt) + expect(written.nodes).toHaveLength(1) + // JSON rides in text/plain so intra-app paste round-trips in WebKit. + expect(JSON.parse(dt.getData('text/plain')).kind).toBe('canvas-harness/clipboard') + + const read = readClipboardFromDataTransfer(store, dt) + expect(read?.nodes).toHaveLength(1) + expect(read?.nodes[0]?.content).toBe('hello') + }) + + test('an EMPTY transfer falls back to this store’s in-memory clipboard', () => { + const store = setupStore() + store.setSelection([asNodeId('n1')]) + writeSelectionToDataTransfer(store, new DataTransfer()) // seeds memory + const read = readClipboardFromDataTransfer(store, new DataTransfer()) + expect(read?.nodes[0]?.content).toBe('hello') + }) + + test('external plain text is NOT hijacked by a prior copy', () => { + const store = setupStore() + store.setSelection([asNodeId('n1')]) + writeSelectionToDataTransfer(store, new DataTransfer()) // seeds memory + const external = new DataTransfer() + external.setData('text/plain', 'copied from another app') + // Transfer holds real (non-canvas) content → must not return memory. + expect(readClipboardFromDataTransfer(store, external)).toBeNull() + }) + + test('foreign JSON is not treated as a canvas payload', () => { + const store = setupStore() + store.setSelection([asNodeId('n1')]) + writeSelectionToDataTransfer(store, new DataTransfer()) // seeds memory + const external = new DataTransfer() + external.setData('text/plain', '{"foo":1}') + expect(readClipboardFromDataTransfer(store, external)).toBeNull() + }) + + test('the in-memory fallback is per-store (no cross-canvas bleed)', () => { + const a = setupStore() + a.setSelection([asNodeId('n1')]) + const b = setupStore() + writeSelectionToDataTransfer(a, new DataTransfer()) // seeds A only + // B never copied → an empty transfer yields nothing (not A's node). + expect(readClipboardFromDataTransfer(b, new DataTransfer())).toBeNull() + expect(readClipboardFromDataTransfer(a, new DataTransfer())?.nodes).toHaveLength(1) + }) +}) + +describe(' clipboard event wiring', () => { + test('copy then paste re-creates the node with a fresh id', async () => { + const store = setupStore() + const m = await mountCanvas(store) + store.setSelection([asNodeId('n1')]) + + await act(async () => { + m.wrap.dispatchEvent(clipboardEvent('copy')) + }) + expect(store.getAllNodes()).toHaveLength(1) + + await act(async () => { + m.wrap.dispatchEvent(clipboardEvent('paste')) + }) + const nodes = store.getAllNodes() + expect(nodes).toHaveLength(2) + expect(nodes.filter(n => n.id === 'n1')).toHaveLength(1) // original kept + expect(nodes.some(n => n.id !== 'n1' && n.content === 'hello')).toBe(true) // fresh id + await m.cleanup() + }) + + test('cut removes the selection, and it pastes back', async () => { + const store = setupStore() + const m = await mountCanvas(store) + store.setSelection([asNodeId('n1')]) + + await act(async () => { + m.wrap.dispatchEvent(clipboardEvent('cut')) + }) + expect(store.getAllNodes()).toHaveLength(0) + + await act(async () => { + m.wrap.dispatchEvent(clipboardEvent('paste')) + }) + expect(store.getAllNodes()).toHaveLength(1) + await m.cleanup() + }) +})