From 637292843cc6afc58e77d94ae51339a11112c6d4 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Mon, 3 Aug 2026 14:58:33 +0200 Subject: [PATCH 1/3] fix(clipboard): make copy/paste work in WebKit (Safari / WKWebView) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy/paste silently no-op'd in Safari and the WKWebView desktop app while working in Chrome — the same WebKit-vs-Chromium divergence as the pinch-zoom bug. Two Chromium-only assumptions were at fault: - Keydown handlers called the async navigator.clipboard. WebKit restricts it: clipboard.read/readText need a live user activation, which is spent across the awaits in paste(), so the read rejected and paste returned null. ClipboardItem also rejects our custom MIME on WebKit. Fixes, WebKit-first (Chromium path preserved): - Move copy/cut/paste onto the DOM copy/cut/paste events, which run synchronously inside the user gesture and expose a DataTransfer WebKit allows. New core helpers writeSelectionToDataTransfer / readClipboardFromDataTransfer own the (de)serialization + MIME. - Add a module-level in-memory fallback clipboard so intra-app (and cross-board, same JS context) copy/paste always works even when the system clipboard is unavailable or drops our payload — the guaranteed path in WKWebView. System clipboard stays primary for cross-app. - Make the canvas host focusable (role=application + tabIndex) so WebKit dispatches clipboard events to it; focus it on pointerdown (not while editing). Keydown now only handles the [ / ] z-order shortcuts. Adds browser tests for the DataTransfer helpers (round-trip + memory fallback) and the copy/cut/paste event wiring. Full suites (core 351, react 21 browser + unit), lint, typecheck all pass. Real Safari/WKWebView still needs a manual pass (chromium can't fully exercise WebKit clipboard restrictions). --- packages/core/src/clipboard/index.ts | 79 +++++++++- packages/react/src/Canvas.tsx | 83 +++++++++-- .../react/tests/clipboard.browser.test.tsx | 140 ++++++++++++++++++ 3 files changed, 283 insertions(+), 19 deletions(-) create mode 100644 packages/react/tests/clipboard.browser.test.tsx diff --git a/packages/core/src/clipboard/index.ts b/packages/core/src/clipboard/index.ts index 00c9c93..ac6df85 100644 --- a/packages/core/src/clipboard/index.ts +++ b/packages/core/src/clipboard/index.ts @@ -17,6 +17,19 @@ export { deserializeClipboard, isCanvasHarnessClipboard, serializeSelection } fr const MIME_NATIVE = 'application/x-canvas-harness+json' const MIME_TEXT = 'text/plain' +// In-memory fallback clipboard. Guarantees intra-app copy/paste works +// even when the system clipboard is unavailable or blocked — e.g. +// WebKit (Safari / WKWebView), where the async `navigator.clipboard` +// read is restricted, or a denied permission. The system clipboard +// stays the primary channel (cross-app / cross-tab); this is the net. +let memoryClipboard: SerializedClipboard | null = null + +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 +44,7 @@ const MIME_TEXT = 'text/plain' */ export const copy = async (store: CanvasStore): Promise => { const clip = serializeSelection(store) + memoryClipboard = clip await writeClipboard(clip) return clip } @@ -81,7 +95,9 @@ export const paste = async ( payload?: SerializedClipboard, opts?: DeserializeOptions, ): Promise<(NodeId | EdgeId)[] | null> => { - const clip = payload ?? (await readClipboard()) + // System clipboard first (cross-app), then the in-memory fallback — + // so intra-app paste still works when the system read is blocked. + const clip = payload ?? (await readClipboard()) ?? memoryClipboard if (!clip) return null // Cursor-as-default: when the caller didn't specify positioning, // and the store has tracked the pointer at least once, paste at @@ -98,13 +114,66 @@ 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) + memoryClipboard = clip + const json = JSON.stringify(clip) + try { + // Some engines reject a custom MIME on DataTransfer; the JSON also + // rides in `text/plain`, so a rejection here is non-fatal. + data.setData(MIME_NATIVE, json) + } catch { + // Ignore — text/plain below carries the payload. + } + data.setData(MIME_TEXT, textFallback(clip) || json) + return clip +} + +/** + * Read a canvas-harness payload from a `DataTransfer` (the DOM `paste` + * event). Prefers the native MIME, then a JSON `text/plain` payload, + * then the in-memory fallback (intra-app paste when the system + * clipboard didn't carry our data). Returns null if none match. + */ +export const readClipboardFromDataTransfer = (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?.trim().startsWith('{')) { + try { + const parsed = JSON.parse(text) + if (isCanvasHarnessClipboard(parsed)) return parsed + } catch { + // Fall through to the in-memory fallback. + } + } + return memoryClipboard +} + 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..b278db2 100644 --- a/packages/react/src/Canvas.tsx +++ b/packages/react/src/Canvas.tsx @@ -4,12 +4,12 @@ import { type EditorAdapterFactory, type NodeId, type Renderer, - copy, createRenderer, - cut, hitTestAny, paste, + readClipboardFromDataTransfer, screenToWorld, + writeSelectionToDataTransfer, } from '@canvas-harness/core' import { type ReactNode, useEffect, useRef, useState } from 'react' import { CanvasProvider, useCanvasStore } from './context' @@ -459,24 +459,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 +487,63 @@ 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. Skip when a text editor + // is focused so the native text clipboard isn't hijacked. + useEffect(() => { + const isTextTarget = (t: EventTarget | null): boolean => { + const el = t as HTMLElement | null + return !!el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' || el.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() + const clip = writeSelectionToDataTransfer(store, e.clipboardData) + store.batch(() => { + for (const n of clip.nodes) store.removeNode(n.id) + for (const ed of clip.edges) store.removeEdge(ed.id) + }) + } + const onPaste = (e: ClipboardEvent) => { + if (isTextTarget(e.target) || !e.clipboardData) return + const clip = readClipboardFromDataTransfer(e.clipboardData) + if (!clip) return + e.preventDefault() + void paste(store, clip) + } + window.addEventListener('copy', onCopy) + window.addEventListener('cut', onCut) + window.addEventListener('paste', onPaste) + return () => { + window.removeEventListener('copy', onCopy) + window.removeEventListener('cut', onCut) + window.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 so the text editor keeps + // focus. (The host carries tabIndex + outline:none, see below.) + useEffect(() => { + const el = wrapRef.current + if (!el) return + const onPointerDown = () => { + if (store.getInteractionState().mode === 'editing') 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 +553,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 memory-fallback + * paste (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) + // text/plain carries human-readable node text (for pasting into + // non-canvas apps); the JSON payload rides in the custom MIME. + expect(dt.getData('text/plain')).toBe('hello') + + // Round-trips back to a full clip (via the custom MIME where the + // engine keeps it, else the in-memory fallback the write just seeded). + const read = readClipboardFromDataTransfer(dt) + expect(read?.nodes).toHaveLength(1) + expect(read?.nodes[0]?.content).toBe('hello') + }) + + test('read falls back to the in-memory clipboard on an empty transfer', () => { + const store = setupStore() + store.setSelection([asNodeId('n1')]) + writeSelectionToDataTransfer(store, new DataTransfer()) // seeds memory + // A later paste whose transfer carries nothing (the WKWebView case). + const read = readClipboardFromDataTransfer(new DataTransfer()) + expect(read?.nodes[0]?.content).toBe('hello') + }) +}) + +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() + }) +}) From 07b87e3ac92d6806680762593f6f05da9516b4e0 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Mon, 3 Aug 2026 15:10:49 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(clipboard):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20no=20external-paste=20hijack,=20scope=20to=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the WebKit clipboard change: - readClipboardFromDataTransfer returns null when the transfer holds real but non-canvas content (plain text, foreign JSON), instead of falling back to the in-memory clipboard — so pasting external content is never hijacked by a previously-copied canvas selection. The memory fallback now fires only for a completely empty transfer (the WKWebView quirk). text/plain now carries the JSON so intra-app paste still round-trips in WebKit without relying on that fallback. - Scope the in-memory fallback per store (WeakMap) so independent instances can't leak clipboard content into each other. - Move the copy/cut/paste listeners from window onto the host element, so an ordinary page-text copy elsewhere on the page isn't hijacked when the canvas happens to hold a selection. - The pointerdown-to-focus effect skips interactive targets (inputs, buttons, links, contenteditable) so it never steals focus from a custom DOM-overlay node's own controls. Adds tests for the no-hijack (external text + foreign JSON), per-store isolation, and empty-transfer fallback cases. Full suites (core 351, react 24 browser + unit), lint, typecheck pass. --- packages/core/src/clipboard/index.ts | 72 ++++++++++++------- packages/react/src/Canvas.tsx | 45 ++++++++---- .../react/tests/clipboard.browser.test.tsx | 55 ++++++++++---- 3 files changed, 117 insertions(+), 55 deletions(-) diff --git a/packages/core/src/clipboard/index.ts b/packages/core/src/clipboard/index.ts index ac6df85..4d11740 100644 --- a/packages/core/src/clipboard/index.ts +++ b/packages/core/src/clipboard/index.ts @@ -17,12 +17,13 @@ export { deserializeClipboard, isCanvasHarnessClipboard, serializeSelection } fr const MIME_NATIVE = 'application/x-canvas-harness+json' const MIME_TEXT = 'text/plain' -// In-memory fallback clipboard. Guarantees intra-app copy/paste works -// even when the system clipboard is unavailable or blocked — e.g. -// WebKit (Safari / WKWebView), where the async `navigator.clipboard` -// read is restricted, or a denied permission. The system clipboard -// stays the primary channel (cross-app / cross-tab); this is the net. -let memoryClipboard: SerializedClipboard | null = null +// 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 @@ -44,7 +45,7 @@ const textFallback = (clip: SerializedClipboard): string => */ export const copy = async (store: CanvasStore): Promise => { const clip = serializeSelection(store) - memoryClipboard = clip + memoryByStore.set(store, clip) await writeClipboard(clip) return clip } @@ -95,9 +96,11 @@ export const paste = async ( payload?: SerializedClipboard, opts?: DeserializeOptions, ): Promise<(NodeId | EdgeId)[] | null> => { - // System clipboard first (cross-app), then the in-memory fallback — - // so intra-app paste still works when the system read is blocked. - const clip = payload ?? (await readClipboard()) ?? memoryClipboard + // System clipboard first (cross-app), then the per-store in-memory + // fallback — so intra-app paste still works when the system read is + // blocked. (`readClipboard` already returns null on non-canvas data, + // so this can't hijack a legitimate external clipboard.) + const clip = payload ?? (await readClipboard()) ?? memoryByStore.get(store) ?? null if (!clip) return null // Cursor-as-default: when the caller didn't specify positioning, // and the store has tracked the pointer at least once, paste at @@ -129,26 +132,36 @@ export const writeSelectionToDataTransfer = ( data: DataTransfer, ): SerializedClipboard => { const clip = serializeSelection(store) - memoryClipboard = clip + memoryByStore.set(store, clip) const json = JSON.stringify(clip) try { - // Some engines reject a custom MIME on DataTransfer; the JSON also - // rides in `text/plain`, so a rejection here is non-fatal. + // 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 below carries the payload. + // Ignore — text/plain carries the payload. } - data.setData(MIME_TEXT, textFallback(clip) || json) + // 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 } /** * Read a canvas-harness payload from a `DataTransfer` (the DOM `paste` - * event). Prefers the native MIME, then a JSON `text/plain` payload, - * then the in-memory fallback (intra-app paste when the system - * clipboard didn't carry our data). Returns null if none match. + * 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 = (data: DataTransfer): SerializedClipboard | null => { +export const readClipboardFromDataTransfer = ( + store: CanvasStore, + data: DataTransfer, +): SerializedClipboard | null => { const native = data.getData(MIME_NATIVE) if (native) { try { @@ -159,15 +172,22 @@ export const readClipboardFromDataTransfer = (data: DataTransfer): SerializedCli } } const text = data.getData(MIME_TEXT) - if (text?.trim().startsWith('{')) { - try { - const parsed = JSON.parse(text) - if (isCanvasHarnessClipboard(parsed)) return parsed - } catch { - // Fall through to the in-memory fallback. + 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 } - return memoryClipboard + // Empty transfer (the WKWebView quirk) → last in-app copy for this store. + return memoryByStore.get(store) ?? null } const writeClipboard = async (clip: SerializedClipboard): Promise => { diff --git a/packages/react/src/Canvas.tsx b/packages/react/src/Canvas.tsx index b278db2..d92ceb5 100644 --- a/packages/react/src/Canvas.tsx +++ b/packages/react/src/Canvas.tsx @@ -491,12 +491,21 @@ function CanvasSurface({ // 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. Skip when a text editor - // is focused so the native text clipboard isn't hijacked. + // 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 el = t as HTMLElement | null - return !!el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' || el.isContentEditable) + 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 @@ -514,30 +523,38 @@ function CanvasSurface({ } const onPaste = (e: ClipboardEvent) => { if (isTextTarget(e.target) || !e.clipboardData) return - const clip = readClipboardFromDataTransfer(e.clipboardData) + const clip = readClipboardFromDataTransfer(store, e.clipboardData) if (!clip) return e.preventDefault() void paste(store, clip) } - window.addEventListener('copy', onCopy) - window.addEventListener('cut', onCut) - window.addEventListener('paste', onPaste) + el.addEventListener('copy', onCopy) + el.addEventListener('cut', onCut) + el.addEventListener('paste', onPaste) return () => { - window.removeEventListener('copy', onCopy) - window.removeEventListener('cut', onCut) - window.removeEventListener('paste', onPaste) + 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 so the text editor keeps - // focus. (The host carries tabIndex + outline:none, see below.) + // 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 = () => { + const onPointerDown = (e: PointerEvent) => { if (store.getInteractionState().mode === 'editing') return + const t = e.target as HTMLElement | null + if ( + t && + t !== el && + t.closest('input, textarea, select, button, a, [contenteditable="true"]') + ) + return el.focus({ preventScroll: true }) } el.addEventListener('pointerdown', onPointerDown) diff --git a/packages/react/tests/clipboard.browser.test.tsx b/packages/react/tests/clipboard.browser.test.tsx index 971c18b..2276ac6 100644 --- a/packages/react/tests/clipboard.browser.test.tsx +++ b/packages/react/tests/clipboard.browser.test.tsx @@ -2,17 +2,17 @@ * Browser-mode tests for the DOM clipboard-event path (copy / cut / * paste). The library moved off keydown + async navigator.clipboard — * which WebKit (Safari / WKWebView) silently blocks — onto the DOM - * clipboard events (synchronous DataTransfer inside the user gesture) - * plus an in-memory fallback so intra-app paste survives even when the - * system clipboard drops our payload. + * clipboard events (synchronous DataTransfer inside the user gesture), + * with a per-store in-memory fallback used ONLY when the transfer is + * empty (a WKWebView quirk) so it never hijacks an external paste. * * Two layers are covered: - * 1. The core DataTransfer helpers, called directly (round-trip). + * 1. The core DataTransfer helpers, called directly. * 2. The 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 memory-fallback - * paste (2), not by reading a dispatched event's DataTransfer. + * 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, @@ -79,25 +79,50 @@ describe('DataTransfer helpers (engine-agnostic clipboard I/O)', () => { const written = writeSelectionToDataTransfer(store, dt) expect(written.nodes).toHaveLength(1) - // text/plain carries human-readable node text (for pasting into - // non-canvas apps); the JSON payload rides in the custom MIME. - expect(dt.getData('text/plain')).toBe('hello') + // 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') - // Round-trips back to a full clip (via the custom MIME where the - // engine keeps it, else the in-memory fallback the write just seeded). - const read = readClipboardFromDataTransfer(dt) + const read = readClipboardFromDataTransfer(store, dt) expect(read?.nodes).toHaveLength(1) expect(read?.nodes[0]?.content).toBe('hello') }) - test('read falls back to the in-memory clipboard on an empty transfer', () => { + 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 - // A later paste whose transfer carries nothing (the WKWebView case). - const read = readClipboardFromDataTransfer(new DataTransfer()) + 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', () => { From 06c39e37d7d68cd3d5daa6ef6e3eddad82313355 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Mon, 3 Aug 2026 15:19:09 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(clipboard):=20address=20re-review=20?= =?UTF-8?q?=E2=80=94=20async=20paste=20hijack,=20focus=20guard,=20cut=20de?= =?UTF-8?q?dup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the in-memory fallback from the async paste(): readClipboard() returns null for BOTH an empty clipboard and a non-canvas one, so the fallback would paste stale nodes over legitimate external content on a programmatic paste(store). The DOM paste-event path keeps the WebKit empty-transfer fallback (readClipboardFromDataTransfer can tell empty from external apart; the async path can't). - Focus-steal guard now uses isContentEditable instead of the [contenteditable="true"] selector, so it also covers bare contenteditable and plaintext-only — consistent with the clipboard guard, so it never steals focus from those overlay editors. - Extract cutSelectionToDataTransfer into core; the React cut handler called it instead of re-implementing the copy-then-batch-remove loop. Full suites (core 351, react 24 browser + unit), lint, typecheck pass. --- packages/core/src/clipboard/index.ts | 30 +++++++++++++++++++++++----- packages/react/src/Canvas.tsx | 16 ++++++--------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/core/src/clipboard/index.ts b/packages/core/src/clipboard/index.ts index 4d11740..a3fd4eb 100644 --- a/packages/core/src/clipboard/index.ts +++ b/packages/core/src/clipboard/index.ts @@ -96,11 +96,13 @@ export const paste = async ( payload?: SerializedClipboard, opts?: DeserializeOptions, ): Promise<(NodeId | EdgeId)[] | null> => { - // System clipboard first (cross-app), then the per-store in-memory - // fallback — so intra-app paste still works when the system read is - // blocked. (`readClipboard` already returns null on non-canvas data, - // so this can't hijack a legitimate external clipboard.) - const clip = payload ?? (await readClipboard()) ?? memoryByStore.get(store) ?? 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, // and the store has tracked the pointer at least once, paste at @@ -149,6 +151,24 @@ export const writeSelectionToDataTransfer = ( 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 diff --git a/packages/react/src/Canvas.tsx b/packages/react/src/Canvas.tsx index d92ceb5..5cdfd9a 100644 --- a/packages/react/src/Canvas.tsx +++ b/packages/react/src/Canvas.tsx @@ -5,6 +5,7 @@ import { type NodeId, type Renderer, createRenderer, + cutSelectionToDataTransfer, hitTestAny, paste, readClipboardFromDataTransfer, @@ -515,11 +516,7 @@ function CanvasSurface({ const onCut = (e: ClipboardEvent) => { if (isTextTarget(e.target) || !e.clipboardData || store.getSelection().length === 0) return e.preventDefault() - const clip = writeSelectionToDataTransfer(store, e.clipboardData) - store.batch(() => { - for (const n of clip.nodes) store.removeNode(n.id) - for (const ed of clip.edges) store.removeEdge(ed.id) - }) + cutSelectionToDataTransfer(store, e.clipboardData) } const onPaste = (e: ClipboardEvent) => { if (isTextTarget(e.target) || !e.clipboardData) return @@ -549,11 +546,10 @@ function CanvasSurface({ const onPointerDown = (e: PointerEvent) => { if (store.getInteractionState().mode === 'editing') return const t = e.target as HTMLElement | null - if ( - t && - t !== el && - t.closest('input, textarea, select, button, a, [contenteditable="true"]') - ) + // 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 }) }