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 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 10:23:06 +02:00
co-authored by Claude Sonnet 5
parent 8e43cc294c
commit 47afe8fc20
+38 -22
View File
@@ -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({
</tr>
</tbody>
</table>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
</div>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
@@ -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 {