From fafb118db63ccb1325d9b0fdd527f9dd2f9bae3b Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Wed, 2 Sep 2026 10:35:35 +0200 Subject: [PATCH] Keep marking the copy source's first cell after the selection moves on Copying a range showed the dashed marquee around the whole source, but the distinct border on its first (anchor) cell disappeared the moment you selected something else -- that border was drawn from the *live* selection's anchor, which had just moved to the new (paste-destination) cell. - ClipboardData now also stores the copy/cut selection's own anchor -- the cell the drag/shift-selection actually started from, which isn't always rect's top-left corner (a drag can start at any corner). - clipboardRect exposes it; TableGrid renders a second small overlay, .clip-anchor, positioned on just that one cell -- independent of the current selection, so it stays visible for as long as the marquee does. Verified interactively: copy B2:B5, click a cell elsewhere -- the marquee and the solid border on B2 both remain, while the new cell gets its own live-selection border. Co-Authored-By: Claude Sonnet 5 --- src/components/TableGrid.vue | 23 +++++++++++++++++++++++ src/store/__tests__/document.spec.ts | 23 +++++++++++++++++++++++ src/store/document.ts | 12 ++++++++++-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/components/TableGrid.vue b/src/components/TableGrid.vue index 5076940..e0b9b75 100644 --- a/src/components/TableGrid.vue +++ b/src/components/TableGrid.vue @@ -137,6 +137,20 @@ const clipMarqueeStyle = computed(() => { height: rowTop(k.r1 + 1) - top + 'px', }; }); +/** The copy/cut selection's *own* first cell, called out inside the marquee + * even once the live selection has moved on to pick a paste destination + * (which is when `is-active` no longer sits there). */ +const clipAnchorStyle = computed(() => { + const k = clipRect.value; + if (!k || k.tableId !== props.tableId) return null; + const { col, row } = k.anchor; + return { + left: colLeft(col) + 'px', + top: rowTop(row) + 'px', + width: colWidth(col) + 'px', + height: rowH(row) + 'px', + }; +}); const clampC = (c: number) => Math.max(0, Math.min((table.value?.cols ?? 1) - 1, c)); const clampR = (r: number) => Math.max(0, Math.min((table.value?.rows ?? 1) - 1, r)); @@ -771,6 +785,7 @@ defineExpose({
+
@@ -909,6 +924,14 @@ defineExpose({ opacity: 0.45; } } +/* the copy/cut selection's own first cell -- called out inside the marquee + even once the live selection has moved elsewhere to pick a paste target */ +.clip-anchor { + position: absolute; + pointer-events: none; + box-shadow: inset 0 0 0 2px var(--accent); + z-index: 8; +} .handle { position: absolute; right: -3px; diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 901bfd8..5fda3e4 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -565,6 +565,29 @@ describe('clipboard: copy / cut / paste', () => { expect(s.hasClipboard.value).toBe(false); }); + it('clipboardRect keeps the copy source\'s own anchor after the live selection moves elsewhere', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + s.setSelection({ tableId: id, anchor: coords('B2'), focus: coords('B5') }); + s.copySelection(); + expect(s.clipboardRect.value?.anchor).toEqual(coords('B2')); + + // picking a paste destination elsewhere must not move the source's anchor marker + s.setSelection({ tableId: id, anchor: coords('D2'), focus: coords('D5') }); + expect(s.clipboardRect.value?.anchor).toEqual(coords('B2')); + expect(s.clipboardRect.value).toMatchObject({ tableId: id, c0: 1, c1: 1, r0: 1, r1: 4 }); + }); + + it('the source anchor is the drag\'s own start cell, not the rectangle\'s top-left corner', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + // dragged bottom-right -> top-left: anchor is B5, not the normalised B2 + s.setSelection({ tableId: id, anchor: coords('B5'), focus: coords('B2') }); + s.copySelection(); + expect(s.clipboardRect.value?.anchor).toEqual(coords('B5')); + expect(s.clipboardRect.value).toMatchObject({ c0: 1, c1: 1, r0: 1, r1: 4 }); + }); + it('copy leaves $-locked axes exactly as typed; a relative axis still shifts', () => { const s = fresh(); const id = s.addTable(s.doc.activeSheetId); diff --git a/src/store/document.ts b/src/store/document.ts index cf86af1..44c1cde 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -93,6 +93,11 @@ interface ClipboardData { cells: Map; /** cut (Strg+X) vs. copy (Strg+C) -- a cut buffer is consumed by its first paste. */ cut: boolean; + /** the copy/cut selection's own anchor -- the *first* cell picked, which is + * not necessarily `rect`'s top-left corner (a drag can start anywhere). + * Kept so the source marker can still point it out after the current + * selection has moved elsewhere -- see `clipboardRect`. */ + anchor: CellCoord; } /** @@ -884,10 +889,12 @@ function createStore() { // ---- clipboard (copy / cut / paste) -------------------------- /** True while a copy/cut buffer is held (enables "Einfügen" in menus). */ const hasClipboard = computed(() => clipboard.value !== null); - /** Source rectangle of the current buffer -- drives the dashed source marker. */ + /** Source rectangle of the current buffer -- drives the dashed source marker + * and its anchor-cell indicator (which stays put once the live selection + * has moved on to pick a paste destination). */ const clipboardRect = computed(() => { const cb = clipboard.value; - return cb ? { tableId: cb.tableId, ...cb.rect, cut: cb.cut } : null; + return cb ? { tableId: cb.tableId, ...cb.rect, cut: cb.cut, anchor: cb.anchor } : null; }); function copyOrCut(cut: boolean) { @@ -912,6 +919,7 @@ function createStore() { height: r.r1 - r.r0 + 1, cells, cut, + anchor: { ...s.anchor }, }; } const copySelection = () => copyOrCut(false);