From 47afe8fc20857bac16dd51af3a0f2e690f13c34b Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Wed, 2 Sep 2026 10:23:06 +0200 Subject: [PATCH] Fix copy marquee (one rectangle, not per-cell) and arrow-key collapse point - Copy source marker: was outline:dashed on every td in the copied range, so each cell got its own little dashed box. Replaced with one absolutely positioned .clip-marquee overlay sized/positioned from the range's pixel bounds (colLeft/rowTop), giving a single dashed rectangle around the whole range, same as Excel/Numbers. - Arrow keys (no Shift) after selecting a multi-cell range collapsed beside the *focus* (wherever the drag/shift-selection ended), not the *anchor* (the active, first-selected cell) -- inconsistent with the anchor-stays- active fix from the previous change and with Excel/Numbers, where a plain arrow key always steps from the active cell. navigate()/jumpTo()/Home/End now step from the anchor when collapsing (ext=false) and keep stepping from the focus when extending (Shift/Ctrl+Shift), which is unchanged. Verified interactively against the dev server with Playwright (isolated empty sheet, to sidestep the seed data's cascaded/overlapping tables): selecting B2:B5 keeps the active-cell border on B2, copying shows exactly one .clip-marquee, and ArrowLeft afterwards lands on A2, not A5. Co-Authored-By: Claude Sonnet 5 --- src/components/TableGrid.vue | 60 +++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/src/components/TableGrid.vue b/src/components/TableGrid.vue index 12580d2..5076940 100644 --- a/src/components/TableGrid.vue +++ b/src/components/TableGrid.vue @@ -122,12 +122,21 @@ const inSelection = (c: number, r: number) => const colHot = (c: number) => selRects.value.some((k) => c >= k.c0 && c <= k.c1); const rowHot = (r: number) => selRects.value.some((k) => r >= k.r0 && r <= k.r1); -// ---- clipboard source marker (dashed outline, Excel-style "marching ants") -- +// ---- clipboard source marker (one dashed rectangle, Excel-style "marching +// ants" -- overlaid once over the whole range, not per cell). -------------- const clipRect = computed(() => store.clipboardRect.value); -const inClipSource = (c: number, r: number) => { +const clipMarqueeStyle = computed(() => { const k = clipRect.value; - return !!k && k.tableId === props.tableId && c >= k.c0 && c <= k.c1 && r >= k.r0 && r <= k.r1; -}; + if (!k || k.tableId !== props.tableId) return null; + const left = colLeft(k.c0); + const top = rowTop(k.r0); + return { + left: left + 'px', + top: top + 'px', + width: colLeft(k.c1 + 1) - left + 'px', + height: rowTop(k.r1 + 1) - top + '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)); @@ -176,12 +185,17 @@ function primaryRect() { }; } -/** Plain move of the active cell (arrow keys). `ext` = grow the selection. */ +/** Plain move of the active cell (arrow keys). `ext` = grow the selection. + * A plain move steps from the *active* cell (the anchor) -- collapsing a + * multi-cell selection back to just beside it, not beside whichever corner + * the selection happens to end at. Extending (Shift+Arrow) keeps growing + * from the current extension edge (the focus), same as before. */ function navigate(dc: number, dr: number, ext: boolean) { clearFlow(); const s = mine.value; if (!s) return; - select(s.focus.col + dc, s.focus.row + dr, ext); + const from = ext ? s.focus : s.anchor; + select(from.col + dc, from.row + dr, ext); scrollActiveIntoView(); } @@ -244,7 +258,9 @@ function flowMove(axis: 'h' | 'v', back: boolean) { scrollActiveIntoView(); } -/** Ctrl/Cmd+Arrow: jump to the edge of the current data block. */ +/** Ctrl/Cmd+Arrow: jump to the edge of the current data block. Steps from the + * active cell (anchor) when collapsing, or from the extension edge (focus) + * when growing the selection -- see `navigate()`. */ function jumpTo(dc: number, dr: number, ext: boolean) { clearFlow(); const s = mine.value; @@ -252,7 +268,7 @@ function jumpTo(dc: number, dr: number, ext: boolean) { if (!s || !t) return; const filled = (c: number, r: number) => c >= 0 && r >= 0 && c < t.cols && r < t.rows && store.cellInput(props.tableId, c, r) !== ''; - let { col, row } = s.focus; + let { col, row } = ext ? s.focus : s.anchor; const inB = (c: number, r: number) => c >= 0 && r >= 0 && c < t.cols && r < t.rows; let nc = col + dc; let nr = row + dr; @@ -498,7 +514,6 @@ function onKeydown(e: KeyboardEvent) { const s = mine.value; const t = table.value; if (!s || !t) return; - const { row } = s.focus; // Home/End jump within the cursor's current row const ext = e.shiftKey; const mod = e.ctrlKey || e.metaKey; @@ -554,13 +569,13 @@ function onKeydown(e: KeyboardEvent) { e.preventDefault(); e.stopPropagation(); clearFlow(); - select(0, mod ? 0 : row, ext); + select(0, mod ? 0 : (ext ? s.focus : s.anchor).row, ext); return scrollActiveIntoView(); case 'End': e.preventDefault(); e.stopPropagation(); clearFlow(); - select(t.cols - 1, mod ? t.rows - 1 : row, ext); + select(t.cols - 1, mod ? t.rows - 1 : (ext ? s.focus : s.anchor).row, ext); return scrollActiveIntoView(); case 'PageUp': e.preventDefault(); @@ -726,7 +741,6 @@ defineExpose({ :class="{ 'is-active': isActive(c - 1, r - 1), 'in-range': inSelection(c - 1, r - 1), - 'clip-src': inClipSource(c - 1, r - 1), numeric: store.cellIsNumeric(tableId, c - 1, r - 1), error: store.cellIsError(tableId, c - 1, r - 1), frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows), @@ -756,6 +770,7 @@ defineExpose({ +
@@ -877,20 +892,21 @@ defineExpose({ .cell.is-active { box-shadow: inset 0 0 0 2px var(--accent); } -.cell.clip-src { - outline: 1px dashed var(--accent); - outline-offset: -1px; - animation: clip-src-march 0.6s linear infinite; +/* one dashed rectangle around the whole copied range, not per cell */ +.clip-marquee { + position: absolute; + pointer-events: none; + border: 1px dashed var(--accent); + z-index: 8; + animation: clip-src-march 1.2s linear infinite; } @keyframes clip-src-march { - from { - outline-offset: -1px; + 0%, + 100% { + opacity: 1; } 50% { - outline-offset: -2px; - } - to { - outline-offset: -1px; + opacity: 0.45; } } .handle {