Skip to content

Commit 066ea07

Browse files
RootSwitchclaude
andcommitted
Paste routes by recency: one internal copy no longer blocks image paste
Reported (and reproduced to the letter): copy a device on the canvas, and for the REST OF THE SESSION pasting an image from the OS clipboard - one verified fresh in Paint - pastes the stale device copy instead. Mechanism: the Ctrl+V keydown handler preventDefault()ed whenever the object clipboard was non-empty, which suppresses the browser's paste EVENT - the only place image pasting (and the OS clipboard itself) lives. Only New Diagram or a reload cleared it, which is exactly the save/new/hard-refresh dance that "fixed" it in the field. The fix is a recency rule, not a priority swap ("image always wins" would break stamping copies of a device while an old screenshot sits in the OS clipboard). Internal copies now also STAMP the OS clipboard - a marker type plus the copied labels as plain text, so pasting into a text editor yields something meaningful - making the OS clipboard's own last-writer-wins ordering the arbiter. The paste event routes: marker present -> the objects are newest, paste them; image without marker -> the image is newest, paste it; neither -> object-clipboard fallback (old behavior, covers a failed stamp). The Ctrl+V keydown no longer touches preventDefault; its only job is an 80ms fallback timer for the one case that fires no paste event at all (empty OS clipboard). Duplicate (Ctrl+D) deliberately does not stamp - it should not clobber the user's clipboard. execCommand does the stamping because navigator.clipboard requires a secure context and plain-HTTP LAN deployments are a supported layout. Verified: five new harness checks drive the REAL listeners with synthetic ClipboardEvents - marker routes to objects, image-without-marker pastes the image (the reported bug, pinned), text-only falls back, the timer fallback fires when no paste event arrives (127/127) - and headless Chromium with real clipboard permissions confirms Ctrl+C writes the actual OS clipboard (readText returned every sample label, zone included). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7e524c9 commit 066ea07

2 files changed

Lines changed: 148 additions & 9 deletions

File tree

app.js

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3150,7 +3150,7 @@
31503150
const toolbarActions = {
31513151
'undo': () => undo(),
31523152
'redo': () => redo(),
3153-
'copy': () => copySelection(),
3153+
'copy': () => { if (copySelection()) stampOsClipboard(); },
31543154
'paste': () => pasteClipboard(),
31553155
'delete': () => deleteSelected(),
31563156
'arrange-front': () => arrangeSelected('front'),
@@ -3229,7 +3229,7 @@
32293229
'export-drawio': () => exportDrawio(),
32303230
'undo': () => undo(),
32313231
'redo': () => redo(),
3232-
'copy': () => copySelection(),
3232+
'copy': () => { if (copySelection()) stampOsClipboard(); },
32333233
'paste': () => pasteClipboard(),
32343234
'delete': () => deleteSelected(),
32353235
'arrange-front': () => arrangeSelected('front'),
@@ -4374,7 +4374,7 @@
43744374
items.push({ label: 'Redo', key: 'Ctrl+Y', disabled: redoStack.length === 0, action: () => redo() });
43754375
items.push({ sep: true });
43764376
if (anySel) {
4377-
items.push({ label: 'Copy', key: 'Ctrl+C', action: () => copySelection() });
4377+
items.push({ label: 'Copy', key: 'Ctrl+C', action: () => { if (copySelection()) stampOsClipboard(); } });
43784378
}
43794379
items.push({ label: 'Paste', key: 'Ctrl+V', disabled: !state.clipboard, action: () => pasteClipboard() });
43804380
if (anySel) {
@@ -8368,10 +8368,27 @@
83688368
}
83698369

83708370
document.addEventListener('paste', (e) => {
8371+
// Seen BEFORE the guards: a paste that lands in an input must still
8372+
// cancel the keydown handler's internal-paste fallback timer, or
8373+
// typing Ctrl+V into a label field would also stamp objects onto the
8374+
// canvas 80ms later.
8375+
pasteEventSeen = true;
83718376
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
83728377
if (state.inlineEditing && state.inlineEditing.element.contains(e.target)) return;
83738378
const items = e.clipboardData?.items;
83748379
if (!items) return;
8380+
// Recency routing: the marker means the newest thing copied was OUR
8381+
// objects (internal copies stamp it; any later external copy
8382+
// overwrites it) - so objects win. No marker + an image = the image
8383+
// is newer - it wins. Neither = fall through to the object clipboard,
8384+
// which keeps internal paste working when the stamp failed.
8385+
if (Array.from(e.clipboardData.types || []).includes(CLIPBOARD_MARKER)) {
8386+
if (state.clipboard) {
8387+
e.preventDefault();
8388+
pasteClipboard();
8389+
return;
8390+
}
8391+
}
83758392
for (const item of items) {
83768393
if (item.type.startsWith('image/')) {
83778394
e.preventDefault();
@@ -8419,6 +8436,12 @@
84198436
return;
84208437
}
84218438
}
8439+
// No image and no marker (external text, or a stamp that never took):
8440+
// the object clipboard is the only thing paste can mean here.
8441+
if (state.clipboard) {
8442+
e.preventDefault();
8443+
pasteClipboard();
8444+
}
84228445
});
84238446

84248447
// --- Find on canvas (Ctrl+F) -------------------------------------------
@@ -8647,16 +8670,62 @@
86478670
}
86488671
}
86498672

8650-
// Copy: Ctrl+C
8673+
// Copy: Ctrl+C - and stamp the OS clipboard so paste can order the
8674+
// two clipboards by recency (see stampOsClipboard).
86518675
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
8652-
if (copySelection()) e.preventDefault();
8676+
if (copySelection()) {
8677+
e.preventDefault();
8678+
stampOsClipboard();
8679+
}
86538680
}
86548681

8655-
// Paste: Ctrl+V
8682+
// Paste: Ctrl+V. The routing lives in the 'paste' EVENT - the only
8683+
// place the OS clipboard is readable - so the keydown must NOT
8684+
// preventDefault (that suppressed the paste event, and with it image
8685+
// pasting, for the rest of the session after one internal copy). The
8686+
// timer is the fallback for the one case that fires no paste event at
8687+
// all: a completely empty OS clipboard, possible when the copy stamp
8688+
// failed. 80ms is far beyond the keydown->paste dispatch gap.
86568689
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
8657-
if (state.clipboard) e.preventDefault();
8658-
pasteClipboard();
8659-
}
8690+
if (state.clipboard) {
8691+
pasteEventSeen = false;
8692+
setTimeout(() => { if (!pasteEventSeen) pasteClipboard(); }, 80);
8693+
}
8694+
}
8695+
});
8696+
8697+
// --- OS-clipboard stamp: makes paste routing RECENCY-correct ------------
8698+
// The object clipboard (state.clipboard) and the OS clipboard are two
8699+
// stores with no shared ordering, so "which did the user copy LAST?" -
8700+
// the only paste rule that matches intent - is unanswerable unless every
8701+
// internal copy also writes the OS clipboard. Stamping a marker type (plus
8702+
// the copied labels as plain text, so pasting into a text editor gives
8703+
// something meaningful) means the OS clipboard's own last-writer-wins
8704+
// ordering becomes the arbiter: marker present = the internal copy is
8705+
// newest; an image present without it = the image is newest. Without this,
8706+
// one internal Ctrl+C suppressed image paste for the whole session - the
8707+
// keydown handler preventDefault()ed the paste event that image pasting
8708+
// lives on, and a fresh screenshot pasted the morning's stale device copy.
8709+
// execCommand is the one clipboard-write API that works on plain-HTTP
8710+
// deployments (navigator.clipboard needs a secure context); the stamp is
8711+
// best-effort - if it fails, paste falls back to the object clipboard
8712+
// whenever the OS clipboard offers no image, which is the old behavior.
8713+
const CLIPBOARD_MARKER = 'application/x-crosscanvas-objects';
8714+
let stampingCopy = false;
8715+
let pasteEventSeen = false;
8716+
function stampOsClipboard() {
8717+
stampingCopy = true;
8718+
try { document.execCommand('copy'); } catch (err) { /* best-effort */ }
8719+
stampingCopy = false;
8720+
}
8721+
document.addEventListener('copy', (e) => {
8722+
if (!stampingCopy || !state.clipboard || !e.clipboardData) return;
8723+
const labels = [...state.clipboard.devices, ...state.clipboard.zones,
8724+
...state.clipboard.textBoxes, ...(state.clipboard.images || [])]
8725+
.map(o => String(o.label || o.text || '').trim()).filter(Boolean).join('\n');
8726+
e.clipboardData.setData(CLIPBOARD_MARKER, '1');
8727+
if (labels) e.clipboardData.setData('text/plain', labels);
8728+
e.preventDefault();
86608729
});
86618730

86628731
function copySelection() {

tools/tests.html

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,80 @@
304304
routingSuite(t);
305305
labelSlideSuite(t);
306306
connectIntentSuite(t);
307+
await clipboardSuite(t);
307308
await corpusSuite(t);
308309
render();
309310
}
310311

312+
// --- clipboard routing (recency marker) ----------------------------------
313+
// The object clipboard and the OS clipboard have no shared ordering, so paste
314+
// routes by a marker the app stamps into the OS clipboard on internal copy:
315+
// marker present -> objects are newest -> paste objects; image without the
316+
// marker -> the image is newest -> paste it (the bug this pins: one internal
317+
// Ctrl+C used to suppress the paste EVENT forever, so a fresh screenshot
318+
// pasted the morning's stale device); neither -> object-clipboard fallback.
319+
// Everything here goes through the REAL listeners via synthetic events.
320+
async function clipboardSuite(t) {
321+
const MARKER = 'application/x-crosscanvas-objects'; // pinned: a rename must break this test
322+
const frame = document.querySelector('iframe');
323+
const idoc = frame.contentDocument, iwin = frame.contentWindow;
324+
const counts = () => ({ d: t.state.devices.length, i: t.state.images.length });
325+
const key = (k) => idoc.body.dispatchEvent(new iwin.KeyboardEvent('keydown',
326+
{ key: k, ctrlKey: true, bubbles: true, cancelable: true }));
327+
const paste = (dt) => idoc.body.dispatchEvent(new iwin.ClipboardEvent('paste',
328+
{ clipboardData: dt, bubbles: true, cancelable: true }));
329+
const AP = (w, h) => [{ rx: w/2, ry: 0 }, { rx: w, ry: 0 }, { rx: w, ry: h/2 }, { rx: w, ry: h },
330+
{ rx: w/2, ry: h }, { rx: 0, ry: h }, { rx: 0, ry: h/2 }, { rx: 0, ry: 0 }];
331+
const dev = (i) => ({
332+
id: 'cb' + i, templateId: 'cb' + i + 't', image: '@Server', originalImage: '@Server',
333+
x: 80 + i * 150, y: 80, w: 60, h: 60, label: 'clip-' + i, labelPosition: 'bottom',
334+
fontSize: 14, fontColor: '#333333', lineFormats: [{ bold: false, italic: false }],
335+
spans: [[{ text: 'clip-' + i, bold: false, italic: false }]], tintColor: null,
336+
attachmentPoints: AP(60, 60), fields: {}
337+
});
338+
t.applyDiagramData({ version: 6, appVersion: 'cctest', diagramTitle: 'clip-test', diagramVersion: 1,
339+
devices: [dev(0), dev(1)], connections: [], zones: [], textBoxes: [], images: [], groups: [],
340+
deviceTemplates: [], imageTable: {}, nextId: 900 });
341+
key('a'); key('c'); // select all -> internal copy (real handlers)
342+
const r = {};
343+
r.base = counts();
344+
345+
let dt = new iwin.DataTransfer(); // 1. marker -> objects win
346+
dt.setData(MARKER, '1');
347+
paste(dt);
348+
r.marker = counts();
349+
350+
dt = new iwin.DataTransfer(); // 2. image, no marker -> IMAGE wins (the bug)
351+
const png = Uint8Array.from(atob(
352+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='),
353+
c => c.charCodeAt(0));
354+
dt.items.add(new iwin.File([png], 'clip.png', { type: 'image/png' }));
355+
paste(dt);
356+
// FileReader + Image decode are async, and the hidden test iframe
357+
// deprioritizes decode - poll for the image instead of guessing a delay.
358+
for (let w = 0; w < 40 && counts().i === 0; w++) { await new Promise(res => setTimeout(res, 100)); }
359+
r.image = counts();
360+
361+
dt = new iwin.DataTransfer(); // 3. neither -> object fallback
362+
dt.setData('text/plain', 'external text');
363+
paste(dt);
364+
r.text = counts();
365+
366+
key('v'); // 4. Ctrl+V, NO paste event at all -> timer fallback
367+
await new Promise(res => setTimeout(res, 180));
368+
r.timer = counts();
369+
370+
suite('Clipboard routing (recency marker)', () => {
371+
ok('setup: two devices copied internally', r.base.d === 2 && r.base.i === 0, JSON.stringify(r.base));
372+
ok('marker in OS clipboard -> objects paste', r.marker.d === 4 && r.marker.i === 0, JSON.stringify(r.marker));
373+
ok('image without marker -> the IMAGE pastes, not the stale copy',
374+
r.image.i === 1 && r.image.d === 4, JSON.stringify(r.image));
375+
ok('no image, no marker -> object-clipboard fallback', r.text.d === 6 && r.text.i === 1, JSON.stringify(r.text));
376+
ok('Ctrl+V with no paste event -> timer fallback still pastes objects',
377+
r.timer.d === 8 && r.timer.i === 1, JSON.stringify(r.timer));
378+
});
379+
}
380+
311381
// --- routing invariant ---------------------------------------------------
312382
// An orthogonal (or rounded, which is orthogonal with filleted corners) route
313383
// has NO legitimate diagonal segment, ever. That makes this an absolute

0 commit comments

Comments
 (0)