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
+18
View File
@@ -71,6 +71,17 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
Bewegungen und das Verlassen der Tabelle, scrollt bei festen Tabellen an der Bewegungen und das Verlassen der Tabelle, scrollt bei festen Tabellen an der
Kante automatisch mit und heilt einen verlorenen `mouseup` selbst. Kante automatisch mit und heilt einen verlorenen `mouseup` selbst.
- Kopf-Klick = ganze Spalte / Zeile, Ecke = alles. - Kopf-Klick = ganze Spalte / Zeile, Ecke = alles.
- **`Strg`/`Cmd`+`A`** (zweistufig, Excel-Verhalten): der **erste** Druck
markiert den **zusammenhängenden Bereich** um die aktive Zelle — iterative
Rechteck-Expansion, die an einer vollständig leeren Zeile/Spalte oder am
Tabellenrand stoppt (zwei durch eine leere Spalte/Zeile getrennte
Dateninseln bleiben getrennt). Ein **zweiter, unmittelbar folgender** Druck
erweitert auf **alle befüllten Zellen der Tabelle** — die tatsächlichen
Grenzen befüllter Felder, nicht die (oft größeren) Grunddimensionen der
Tabelle. Jeder weitere Druck bleibt auf dieser Vollauswahl stehen. Der
Zyklus erkennt sich selbst rein über einen Vergleich der aktuellen Auswahl
mit der beim letzten Druck gesetzten — jede andere Aktion dazwischen (Klick,
Pfeiltaste, Bearbeitung, …) setzt ihn auf Stufe 1 zurück, ganz wie in Excel.
- **`Strg`/`Cmd`+Klick** = weitere Zelle **hinzufügen** (erneut = abwählen), - **`Strg`/`Cmd`+Klick** = weitere Zelle **hinzufügen** (erneut = abwählen),
**`Strg`/`Cmd`+Ziehen** = weiteres Rechteck; auch am Spalten-/Zeilenkopf. **`Strg`/`Cmd`+Ziehen** = weiteres Rechteck; auch am Spalten-/Zeilenkopf.
- Die **diskontinuierliche Mehrfachauswahl** trägt alle folgenden Aktionen: - Die **diskontinuierliche Mehrfachauswahl** trägt alle folgenden Aktionen:
@@ -109,6 +120,7 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
| Tippen / `F2` | Zelle editieren; Enter/Tab committen und ziehen weiter (auch im Bereich) | | Tippen / `F2` | Zelle editieren; Enter/Tab committen und ziehen weiter (auch im Bereich) |
| `Pos1` / `Strg`+`Pos1` / `Ende` / `Strg`+`Ende` | Zeilenanfang / A1 / Zeilenende / letzte Zelle | | `Pos1` / `Strg`+`Pos1` / `Ende` / `Strg`+`Ende` | Zeilenanfang / A1 / Zeilenende / letzte Zelle |
| `Bild↑` / `Bild↓` | seitenweise (bei festen Tabellen nach Viewport-Höhe) | | `Bild↑` / `Bild↓` | seitenweise (bei festen Tabellen nach Viewport-Höhe) |
| **`Strg`/`Cmd`+`A`** | 1. Druck: zusammenhängender Bereich um die aktive Zelle · 2. Druck: alle befüllten Zellen der Tabelle (s. o. „Zellauswahl") |
| **`Strg`/`Cmd`+`C`/`X`/`V`** | Kopieren / Ausschneiden / Einfügen (Zellen + Formatierung, bezugsangepasst) | | **`Strg`/`Cmd`+`C`/`X`/`V`** | Kopieren / Ausschneiden / Einfügen (Zellen + Formatierung, bezugsangepasst) |
| **`Strg`/`Cmd`+`Z`** · **`Strg`/`Cmd`+`Y`** / **`Strg`/`Cmd`+`Shift`+`Z`** | Rückgängig · Wiederholen (auch als Symbolleisten-Buttons ↶ ↷) | | **`Strg`/`Cmd`+`Z`** · **`Strg`/`Cmd`+`Y`** / **`Strg`/`Cmd`+`Shift`+`Z`** | Rückgängig · Wiederholen (auch als Symbolleisten-Buttons ↶ ↷) |
| **`F4`** (im Editor/in der Formelleiste) | Bezug am Cursor sperren/zyklisch umschalten: `A1` → `$A$1` → `A$1` → `$A1` → `A1`; bei markiertem Text werden **alle** darin gefundenen Bezüge auf einmal umgeschaltet, jeder ausgehend vom eigenen Zustand | | **`F4`** (im Editor/in der Formelleiste) | Bezug am Cursor sperren/zyklisch umschalten: `A1` → `$A$1` → `A$1` → `$A1` → `A1`; bei markiertem Text werden **alle** darin gefundenen Bezüge auf einmal umgeschaltet, jeder ausgehend vom eigenen Zustand |
@@ -1097,6 +1109,12 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
vorhanden — die zugrundeliegenden Store-/Engine-Funktionen vorhanden — die zugrundeliegenden Store-/Engine-Funktionen
(`graphPrecedents`/`graphDependents`/`cellGraphNeighbors`/ (`graphPrecedents`/`graphDependents`/`cellGraphNeighbors`/
`openDependencyGraph`) sind es aber (s. „Tests" oben). `openDependencyGraph`) sind es aber (s. „Tests" oben).
- **`Strg`/`Cmd`+`A`-Zyklus** (s. o. „Zellauswahl") ist rein UI-seitig
(`TableGrid.vue`) implementiert, ohne eigene Unit-Tests — wie bei
Tabellen-Größenänderung und Abhängigkeiten-Panel oben nur live per
Playwright verifiziert (Stufe 1 auf eine von zwei durch eine leere Spalte
getrennten Dateninseln, Eskalation auf Stufe 2, Verharren auf Stufe 2 bei
weiteren Drücken, Rücksetzen des Zyklus durch einen Klick dazwischen).
- **Objekt-Bezüge beim Anhängen.** Beim JSON-*Anhängen* zweier Dokumente werden - **Objekt-Bezüge beim Anhängen.** Beim JSON-*Anhängen* zweier Dokumente werden
nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein
angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der
+93 -1
View File
@@ -13,7 +13,7 @@
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'; import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { numberToCol, cycleReferenceLocks } from '../engine'; import { numberToCol, cycleReferenceLocks } from '../engine';
import { useDocument } from '../store/document'; 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 { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import ContextMenu from './ContextMenu.vue'; import ContextMenu from './ContextMenu.vue';
import FormulaSuggest from './FormulaSuggest.vue'; import FormulaSuggest from './FormulaSuggest.vue';
@@ -620,6 +620,93 @@ function selectAll() {
focusRoot(); 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 --------------------------------------------------- // ---- editing ---------------------------------------------------
// The editor overlays the cell (positioned via colLeft/rowTop, like the // 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 // clip/fill overlays) rather than living inside the <td>, so it's free to
@@ -856,6 +943,11 @@ function onKeydown(e: KeyboardEvent) {
if (mod) { if (mod) {
const k = e.key.toLowerCase(); const k = e.key.toLowerCase();
if (k === 'a') {
e.preventDefault();
e.stopPropagation();
return selectAllCycle();
}
if (k === 'c') { if (k === 'c') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();