diff --git a/README.md b/README.md index b37d40f..5c7dd05 100644 --- a/README.md +++ b/README.md @@ -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 Kante automatisch mit und heilt einen verlorenen `mouseup` selbst. - 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`+Ziehen** = weiteres Rechteck; auch am Spalten-/Zeilenkopf. - 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) | | `Pos1` / `Strg`+`Pos1` / `Ende` / `Strg`+`Ende` | Zeilenanfang / A1 / Zeilenende / letzte Zelle | | `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`+`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 | @@ -1097,6 +1109,12 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, vorhanden — die zugrundeliegenden Store-/Engine-Funktionen (`graphPrecedents`/`graphDependents`/`cellGraphNeighbors`/ `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 nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der diff --git a/src/components/TableGrid.vue b/src/components/TableGrid.vue index 53c7eb3..4562e00 100644 --- a/src/components/TableGrid.vue +++ b/src/components/TableGrid.vue @@ -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 }, c: number, r: number): boolean { + return !(`${c},${r}` in t.cells); +} +function isRowEmpty(t: { cells: Record }, 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 }, 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; 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 }) { + 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 , 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();