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 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 10:35:35 +02:00
co-authored by Claude Sonnet 5
parent 47afe8fc20
commit fafb118db6
3 changed files with 56 additions and 2 deletions
+23
View File
@@ -137,6 +137,20 @@ const clipMarqueeStyle = computed(() => {
height: rowTop(k.r1 + 1) - top + 'px', 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 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)); const clampR = (r: number) => Math.max(0, Math.min((table.value?.rows ?? 1) - 1, r));
@@ -771,6 +785,7 @@ defineExpose({
</tbody> </tbody>
</table> </table>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" /> <div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
</div> </div>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" /> <ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
@@ -909,6 +924,14 @@ defineExpose({
opacity: 0.45; 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 { .handle {
position: absolute; position: absolute;
right: -3px; right: -3px;
+23
View File
@@ -565,6 +565,29 @@ describe('clipboard: copy / cut / paste', () => {
expect(s.hasClipboard.value).toBe(false); 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', () => { it('copy leaves $-locked axes exactly as typed; a relative axis still shifts', () => {
const s = fresh(); const s = fresh();
const id = s.addTable(s.doc.activeSheetId); const id = s.addTable(s.doc.activeSheetId);
+10 -2
View File
@@ -93,6 +93,11 @@ interface ClipboardData {
cells: Map<string, ClipboardCell>; cells: Map<string, ClipboardCell>;
/** cut (Strg+X) vs. copy (Strg+C) -- a cut buffer is consumed by its first paste. */ /** cut (Strg+X) vs. copy (Strg+C) -- a cut buffer is consumed by its first paste. */
cut: boolean; 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) -------------------------- // ---- clipboard (copy / cut / paste) --------------------------
/** True while a copy/cut buffer is held (enables "Einfügen" in menus). */ /** True while a copy/cut buffer is held (enables "Einfügen" in menus). */
const hasClipboard = computed(() => clipboard.value !== null); 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 clipboardRect = computed(() => {
const cb = clipboard.value; 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) { function copyOrCut(cut: boolean) {
@@ -912,6 +919,7 @@ function createStore() {
height: r.r1 - r.r0 + 1, height: r.r1 - r.r0 + 1,
cells, cells,
cut, cut,
anchor: { ...s.anchor },
}; };
} }
const copySelection = () => copyOrCut(false); const copySelection = () => copyOrCut(false);