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 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); 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 clipRect = computed(() => store.clipboardRect.value);
const inClipSource = (c: number, r: number) => { const clipMarqueeStyle = computed(() => {
const k = clipRect.value; 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 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));
@@ -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) { function navigate(dc: number, dr: number, ext: boolean) {
clearFlow(); clearFlow();
const s = mine.value; const s = mine.value;
if (!s) return; 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(); scrollActiveIntoView();
} }
@@ -244,7 +258,9 @@ function flowMove(axis: 'h' | 'v', back: boolean) {
scrollActiveIntoView(); 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) { function jumpTo(dc: number, dr: number, ext: boolean) {
clearFlow(); clearFlow();
const s = mine.value; const s = mine.value;
@@ -252,7 +268,7 @@ function jumpTo(dc: number, dr: number, ext: boolean) {
if (!s || !t) return; if (!s || !t) return;
const filled = (c: number, r: number) => const filled = (c: number, r: number) =>
c >= 0 && r >= 0 && c < t.cols && r < t.rows && store.cellInput(props.tableId, c, r) !== ''; 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; const inB = (c: number, r: number) => c >= 0 && r >= 0 && c < t.cols && r < t.rows;
let nc = col + dc; let nc = col + dc;
let nr = row + dr; let nr = row + dr;
@@ -498,7 +514,6 @@ function onKeydown(e: KeyboardEvent) {
const s = mine.value; const s = mine.value;
const t = table.value; const t = table.value;
if (!s || !t) return; if (!s || !t) return;
const { row } = s.focus; // Home/End jump within the cursor's current row
const ext = e.shiftKey; const ext = e.shiftKey;
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
@@ -554,13 +569,13 @@ function onKeydown(e: KeyboardEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
clearFlow(); clearFlow();
select(0, mod ? 0 : row, ext); select(0, mod ? 0 : (ext ? s.focus : s.anchor).row, ext);
return scrollActiveIntoView(); return scrollActiveIntoView();
case 'End': case 'End':
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
clearFlow(); 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(); return scrollActiveIntoView();
case 'PageUp': case 'PageUp':
e.preventDefault(); e.preventDefault();
@@ -726,7 +741,6 @@ defineExpose({
:class="{ :class="{
'is-active': isActive(c - 1, r - 1), 'is-active': isActive(c - 1, r - 1),
'in-range': inSelection(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), numeric: store.cellIsNumeric(tableId, c - 1, r - 1),
error: store.cellIsError(tableId, c - 1, r - 1), error: store.cellIsError(tableId, c - 1, r - 1),
frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows), frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows),
@@ -756,6 +770,7 @@ defineExpose({
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
</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" />
@@ -877,20 +892,21 @@ defineExpose({
.cell.is-active { .cell.is-active {
box-shadow: inset 0 0 0 2px var(--accent); box-shadow: inset 0 0 0 2px var(--accent);
} }
.cell.clip-src { /* one dashed rectangle around the whole copied range, not per cell */
outline: 1px dashed var(--accent); .clip-marquee {
outline-offset: -1px; position: absolute;
animation: clip-src-march 0.6s linear infinite; pointer-events: none;
border: 1px dashed var(--accent);
z-index: 8;
animation: clip-src-march 1.2s linear infinite;
} }
@keyframes clip-src-march { @keyframes clip-src-march {
from { 0%,
outline-offset: -1px; 100% {
opacity: 1;
} }
50% { 50% {
outline-offset: -2px; opacity: 0.45;
}
to {
outline-offset: -1px;
} }
} }
.handle { .handle {