From 71a88d2de5894f682a011752feca39fd84375b3c Mon Sep 17 00:00:00 2001 From: Calum Knott Date: Sun, 1 Nov 2026 16:44:26 +0000 Subject: [PATCH] Clipboard overhaul: memory-first copy/paste, unified paste event Copy/paste no longer depends on the clipboard-read permission: copies keep a shared in-memory payload and paste prefers it, so same-page editing works offline and when access is blocked. Cmd+V is no longer intercepted, letting the native paste event carry Finder files (previously eaten) and raw image data via items.getAsFile (screenshots); quickdraw JSON still pastes from text. Silent catch blocks now warn. --- packages/core/src/editor.js | 47 +++++++++++++--- packages/core/test/editor.test.js | 89 +++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/packages/core/src/editor.js b/packages/core/src/editor.js index 236e459..8c21c80 100644 --- a/packages/core/src/editor.js +++ b/packages/core/src/editor.js @@ -1384,7 +1384,9 @@ export class Editor { if (meta && k === 'd') { e.preventDefault(); this.duplicateSelection(); return } if (meta && k === 'c') { e.preventDefault(); this.copySelection(); return } if (meta && k === 'x') { e.preventDefault(); this.copySelection().then(() => this.deleteSelection()); return } - if (meta && k === 'v') { e.preventDefault(); this.pasteFromClipboard(); return } + // Cmd/Ctrl+V is deliberately NOT intercepted: the native paste event + // carries file and image data the async clipboard API cannot see (and + // needs no permissions), so _paste handles every paste uniformly. if (meta && (k === '=' || k === '+')) { e.preventDefault(); this._zoomCenter(1.25); return } if (meta && k === '-') { e.preventDefault(); this._zoomCenter(1 / 1.25); return } if (k === 'escape') { @@ -1488,6 +1490,11 @@ export class Editor { } // ---- clipboard / images -------------------------------------------------- + // Same-page clipboard: copy/paste without touching the system clipboard — + // no permissions, works offline and when clipboard access is blocked. A + // class-level slot (not per instance) so boards on one page exchange + // shapes; the payload is the same envelope copySelection writes out. + static localClipboard = null async copySelection() { if (!this.selection.size) return const shapes = [] @@ -1501,12 +1508,15 @@ export class Editor { if (a) assets[a.id] = a } } + Editor.localClipboard = { quickdraw: 1, shapes, assets } try { - await navigator.clipboard.writeText(JSON.stringify({ quickdraw: 1, shapes, assets })) + await navigator.clipboard.writeText(JSON.stringify(Editor.localClipboard)) } catch (e) { console.warn('board copy failed', e) } } + // Programmatic paste for hosts without a paste event: same order as _paste + // minus the event-only sources (files and items need the real event). async pasteFromClipboard() { - // images first, then our own shape payloads + if (Editor.localClipboard?.quickdraw) { this._pasteShapes(Editor.localClipboard); return } try { if (navigator.clipboard.read) { const items = await navigator.clipboard.read() @@ -1519,12 +1529,12 @@ export class Editor { } } } - } catch {} + } catch (e) { console.warn('board clipboard read failed', e) } try { const text = await navigator.clipboard.readText() const data = JSON.parse(text) if (data && data.quickdraw && Array.isArray(data.shapes)) this._pasteShapes(data) - } catch {} + } catch (e) { console.warn('board clipboard text read failed', e) } } _pasteShapes(data) { let z = this.store.maxZ() @@ -1548,10 +1558,31 @@ export class Editor { if (this.tool !== 'select') this.setTool('select') this.setSelection(ids) } - _paste(e) { + async _paste(e) { if (this.readonly || this.editing) return - const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/')) - if (files.length) { e.preventDefault(); this.importImageBlobs(files) } + const dt = e.clipboardData + // 1. image files (Finder/file copies): synchronous, permission-free, and + // unambiguous — image data on the clipboard never comes from us. + const files = [...(dt?.files || [])].filter((f) => f.type.startsWith('image/')) + if (files.length) { e.preventDefault(); this.importImageBlobs(files); return } + // 2. raw image data (screenshots, copy-image): same reasoning via items. + const blobs = [...(dt?.items || [])] + .filter((it) => it.type && it.type.startsWith('image/')) + .map((it) => { try { return it.getAsFile() } catch { return null } }) + .filter(Boolean) + if (blobs.length) { e.preventDefault(); this.importImageBlobs(blobs); return } + // 3. same-page shape copy: needs no permissions at all. + if (Editor.localClipboard?.quickdraw) { e.preventDefault(); this._pasteShapes(Editor.localClipboard); return } + // 4. cross-app shape payload as text; anything else is not ours to handle. + let text = '' + try { + text = dt?.getData('text/plain') || '' + } catch (err) { console.warn('board paste read failed', err); return } + if (!text) return + try { + const data = JSON.parse(text) + if (data && data.quickdraw && Array.isArray(data.shapes)) { e.preventDefault(); this._pasteShapes(data) } + } catch { /* plain text from elsewhere: leave it alone */ } } _drop(e) { if (this.readonly) return diff --git a/packages/core/test/editor.test.js b/packages/core/test/editor.test.js index 25280ff..dafb4fa 100644 --- a/packages/core/test/editor.test.js +++ b/packages/core/test/editor.test.js @@ -551,6 +551,95 @@ describe('export', () => { }) }) +describe('clipboard', () => { + const RealImage = globalThis.Image + const realCreate = URL.createObjectURL + const realRevoke = URL.revokeObjectURL + afterEach(() => { + globalThis.Image = RealImage + try { + URL.createObjectURL = realCreate + URL.revokeObjectURL = realRevoke + } catch {} + Editor.localClipboard = null + }) + + const pngBlob = () => new Blob(['fake-png-bytes'], { type: 'image/png' }) + // readImage decodes through Image + object URLs: stub both + const stubImageDecode = (w = 64, h = 48) => { + URL.createObjectURL = () => 'blob:fake' + URL.revokeObjectURL = () => {} + globalThis.Image = class { + set src(v) { + this.width = w + this.height = h + setTimeout(() => this.onload && this.onload(), 0) + } + } + } + const pasteEvent = (clipboardData) => ({ clipboardData, preventDefault() {} }) + const geoInStore = () => { + editor.store.put({ + id: 's1', typeName: 'shape', type: 'geo', x: 10, y: 10, rot: 0, z: 1, + props: { geo: 'rectangle', w: 40, h: 40, color: 'black', size: 'm', dash: 'solid', fill: 'none' }, + }) + editor.setSelection(['s1']) + } + const flush = () => new Promise((r) => setTimeout(r, 30)) + + it('copies and pastes shapes from memory with no clipboard API', async () => { + expect(navigator.clipboard).toBe(undefined) // jsdom: proves no system API + geoInStore() + await editor.copySelection() + expect(Editor.localClipboard?.shapes.length).toBe(1) + await editor._paste(pasteEvent({ files: [], items: [], getData: () => '' })) + const xs = editor.store.shapes().filter((s) => s.type === 'geo').map((s) => s.x).sort((a, b) => a - b) + expect(xs).toEqual([10, 26]) // pasted copy lands offset +16 + }) + + it('pastes image files from the paste event', async () => { + stubImageDecode() + await editor._paste(pasteEvent({ files: [pngBlob()], items: [] })) + await flush() + const imgs = editor.store.shapes().filter((s) => s.type === 'image') + expect(imgs.length).toBe(1) + expect(editor.store.asset(imgs[0].props.assetId)?.src.startsWith('data:image/png')).toBe(true) + }) + + it('pastes raw image data via items.getAsFile (screenshots)', async () => { + stubImageDecode() + const blob = pngBlob() + await editor._paste(pasteEvent({ files: [], items: [{ type: 'image/png', getAsFile: () => blob }] })) + await flush() + expect(editor.store.shapes().filter((s) => s.type === 'image').length).toBe(1) + }) + + it('pastes quickdraw payloads from text when nothing else applies', async () => { + const payload = JSON.stringify({ + quickdraw: 1, + shapes: [{ + id: 'x', typeName: 'shape', type: 'geo', x: 0, y: 0, rot: 0, z: 5, + props: { geo: 'rectangle', w: 10, h: 10, color: 'black', size: 'm', dash: 'solid', fill: 'none' }, + }], + assets: {}, + }) + await editor._paste(pasteEvent({ files: [], items: [], getData: (t) => (t === 'text/plain' ? payload : '') })) + const pasted = editor.store.shapes().filter((s) => s.type === 'geo') + expect(pasted.length).toBe(1) + expect(pasted[0].id).not.toBe('x') + expect(pasted[0].x).toBe(16) + }) + + it('Cmd+V is left to the native paste event', () => { + let prevented = false + editor._keyDown({ + key: 'v', metaKey: true, ctrlKey: false, shiftKey: false, + preventDefault: () => { prevented = true }, + }) + expect(prevented).toBe(false) + }) +}) + describe('events & lifecycle', () => { it('emits change/selection/tool events and unsubscribes cleanly', () => { const changes = vi.fn(), sel = vi.fn(), tool = vi.fn()