Ctrl/Cmd+A: zweistufige Auswahl (zusammenhängender Bereich, dann alles Befüllte)

Erster Druck markiert den zusammenhängenden Bereich um die aktive Zelle
(Excel-Verhalten, iterative Rechteck-Expansion bis zur ersten leeren Zeile/
Spalte oder dem Tabellenrand); ein zweiter, unmittelbar folgender Druck
erweitert auf alle befüllten Zellen der Tabelle. Der Zyklus erkennt sich
selbst über einen Abgleich der aktuellen Auswahl mit der zuletzt gesetzten
und setzt sich bei jeder anderen Aktion dazwischen zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-08 23:38:44 +02:00
co-authored by Claude Sonnet 5
parent 3594b12615
commit 40adaa2e1c
2 changed files with 111 additions and 1 deletions
+93 -1
View File
@@ -13,7 +13,7 @@
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { numberToCol, cycleReferenceLocks } from '../engine';
import { useDocument } from '../store/document';
import { DEFAULT_ROW_H } from '../store/types';
import { DEFAULT_ROW_H, type SelectionState } from '../store/types';
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import ContextMenu from './ContextMenu.vue';
import FormulaSuggest from './FormulaSuggest.vue';
@@ -620,6 +620,93 @@ function selectAll() {
focusRoot();
}
// ---- Ctrl/Cmd+A: two-stage "select current region, then everything" ------
// Same idea as Excel: the first press selects the contiguous block of
// filled cells around the active cell (bounded by a fully-empty row/column
// or the table edge); a *second, immediately-following* press escalates to
// every filled cell in the whole table. Detected by comparing the current
// selection to whatever the previous Ctrl+A press actually set -- if
// anything else changed the selection in between (a click, an arrow key, an
// edit, ...), the next press starts over at stage one, exactly like Excel
// forgets the cycle the moment you do something else.
function isCellEmpty(t: { cells: Record<string, string> }, c: number, r: number): boolean {
return !(`${c},${r}` in t.cells);
}
function isRowEmpty(t: { cells: Record<string, string> }, r: number, c0: number, c1: number): boolean {
for (let c = c0; c <= c1; c++) if (!isCellEmpty(t, c, r)) return false;
return true;
}
function isColEmpty(t: { cells: Record<string, string> }, c: number, r0: number, r1: number): boolean {
for (let r = r0; r <= r1; r++) if (!isCellEmpty(t, c, r)) return false;
return true;
}
/** Excel's "current region": the maximal rectangle around (`ac`,`ar`) such
* that expanding it any further in any direction would immediately cross
* a row/column that is *completely* empty (or the table's own edge). */
function currentRegion(t: { cells: Record<string, string>; rows: number; cols: number }, ac: number, ar: number) {
let c0 = ac, c1 = ac, r0 = ar, r1 = ar;
let grew = true;
while (grew) {
grew = false;
if (r0 > 0 && !isRowEmpty(t, r0 - 1, c0, c1)) { r0--; grew = true; }
if (r1 < t.rows - 1 && !isRowEmpty(t, r1 + 1, c0, c1)) { r1++; grew = true; }
if (c0 > 0 && !isColEmpty(t, c0 - 1, r0, r1)) { c0--; grew = true; }
if (c1 < t.cols - 1 && !isColEmpty(t, c1 + 1, r0, r1)) { c1++; grew = true; }
}
return { c0, r0, c1, r1 };
}
/** Bounding box of every filled cell in the whole table, or `null` if it's
* entirely empty. */
function filledBounds(t: { cells: Record<string, string> }) {
let c0 = Infinity, c1 = -Infinity, r0 = Infinity, r1 = -Infinity;
for (const key of Object.keys(t.cells)) {
const [c, r] = key.split(',').map(Number);
if (c < c0) c0 = c;
if (c > c1) c1 = c;
if (r < r0) r0 = r;
if (r > r1) r1 = r;
}
return r1 === -Infinity ? null : { c0, r0, c1, r1 };
}
type Rect4 = { c0: number; r0: number; c1: number; r1: number };
/** The rect the *previous* Ctrl/Cmd+A press set (so the next press can tell
* whether it's a genuine second press, selection unchanged since) and
* which stage it represents. */
let ctrlARect: Rect4 | null = null;
let ctrlAStage: 1 | 2 = 1;
function rectMatchesSelection(rect: Rect4, sel: SelectionState | null): boolean {
if (!sel || sel.ranges?.length) return false; // a multi-range selection never came from this cycle
const c0 = Math.min(sel.anchor.col, sel.focus.col);
const c1 = Math.max(sel.anchor.col, sel.focus.col);
const r0 = Math.min(sel.anchor.row, sel.focus.row);
const r1 = Math.max(sel.anchor.row, sel.focus.row);
return c0 === rect.c0 && c1 === rect.c1 && r0 === rect.r0 && r1 === rect.r1;
}
function selectAllCycle() {
const t = table.value;
if (!t) return;
clearFlow();
const stillAtLastPress = !!ctrlARect && rectMatchesSelection(ctrlARect, store.doc.selection);
let target: Rect4;
if (stillAtLastPress && ctrlAStage === 1) {
target = filledBounds(t) ?? { c0: 0, r0: 0, c1: t.cols - 1, r1: t.rows - 1 };
ctrlAStage = 2;
} else if (stillAtLastPress && ctrlAStage === 2) {
target = ctrlARect!; // already at "everything" -- stays there, like Excel
} else {
const anchor = mine.value?.focus ?? { col: 0, row: 0 };
target = currentRegion(t, anchor.col, anchor.row);
ctrlAStage = 1;
}
store.setSelection({
tableId: props.tableId,
anchor: { col: target.c0, row: target.r0 },
focus: { col: target.c1, row: target.r1 },
});
ctrlARect = target;
focusRoot();
}
// ---- editing ---------------------------------------------------
// The editor overlays the cell (positioned via colLeft/rowTop, like the
// clip/fill overlays) rather than living inside the <td>, so it's free to
@@ -856,6 +943,11 @@ function onKeydown(e: KeyboardEvent) {
if (mod) {
const k = e.key.toLowerCase();
if (k === 'a') {
e.preventDefault();
e.stopPropagation();
return selectAllCycle();
}
if (k === 'c') {
e.preventDefault();
e.stopPropagation();