Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions packages/core/src/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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 = []
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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
Expand Down
89 changes: 89 additions & 0 deletions packages/core/test/editor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down