Implement document-wide undo/redo

Snapshot-based, like the existing JSON export/import round-trip
(snapshot()/replaceDocument()) rather than a patch/command log -- simple and
provably correct for a document this size.

- document.ts: a deep watch on `doc` tracks a "content fingerprint" (sheets +
  locale + csvDelimiter, deliberately excluding selection/activeObjectId, so
  moving the cursor is never an undo step) and debounces commits by 150ms --
  long enough to coalesce a resize-drag's per-mousemove calls into one step,
  short enough that two genuinely separate quick actions don't merge. A new
  edit after an undo immediately drops the redo branch (not debounced, so a
  bound button is never briefly wrong). undo()/redo() flush any pending
  change first, then pop/apply a snapshot via a new applyDocumentState() core
  that replaceDocument() also uses -- replaceDocument() (JSON import
  "replace", "reset to seed") additionally resets history, since loading a
  different document shouldn't leave the old one undoable into it; undo/redo
  themselves must not (that's the very stack they're unwinding).
- TableGrid.vue: Strg/Cmd+Z undoes, Strg/Cmd+Y or +Shift+Z redoes, deferring
  to a cell editor's native undo while one is open (the existing `if
  (editing.value) return` guard already covers this).
- App.vue: a window-level fallback for when no table has focus, skipped
  whenever focus sits in any text field so their own native undo keeps
  working.
- AppToolbar.vue: ↶/↷ buttons, disabled via store.canUndo/canRedo.

Verified interactively against the dev server with Playwright: the toolbar
button enables the instant a change happens (not after the debounce);
separately-timed edits each undo as their own step; a multi-tick resize drag
undoes in one step back to the original width; Ctrl+Z, Ctrl+Y and the
toolbar buttons all work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 14:19:43 +02:00
co-authored by Claude Sonnet 5
parent 67b011637d
commit 5a06d08a6a
6 changed files with 351 additions and 5 deletions
+25 -2
View File
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash
npm install
npm run dev # Vite-Devserver
npm run test # Vitest-Suite (Engine + Store + IO), 132 Tests
npm run test # Vitest-Suite (Engine + Store + IO), 138 Tests
npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config)
```
@@ -97,6 +97,7 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
| `Pos1` / `Strg`+`Pos1` / `Ende` / `Strg`+`Ende` | Zeilenanfang / A1 / Zeilenende / letzte Zelle |
| `Bild↑` / `Bild↓` | seitenweise (bei festen Tabellen nach Viewport-Höhe) |
| **`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 |
| **`Alt`+`Enter`** bzw. **`Cmd`+`Enter`** (im Editor) | Zeilenumbruch in der Zelle einfügen, statt zu committen — `Cmd`+`Enter` als Mac-Fallback, da `⌥` (Option) dort eine Compose-Taste ist und nicht überall zuverlässig als reines Modifier ankommt |
@@ -104,6 +105,23 @@ Bei **festen (scrollbaren) Tabellen** wird die aktive Zelle bei jeder Navigation
automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter
Zeilen/Spalten).
**Rückgängig/Wiederholen**: dokumentweiter Undo-Stack (Snapshot-basiert, wie
der bereits vorhandene JSON-Export/Import-Round-Trip — einfach und
nachweisbar korrekt für ein Dokument dieser Größe, statt eines Patch-/
Kommando-Logs). Reine Auswahl-/Cursor-Bewegungen zählen nie als Schritt; eine
schnelle Folge von Mutationen (ein Spalten-/Zeilen-Resize-Drag feuert bei
jeder Mausbewegung) wird zu **einem** Schritt zusammengefasst, sofern sie
innerhalb von 150 ms aufeinanderfolgen — knapp unter normalem Tipp-/Klick-
Tempo, damit zwei tatsächlich getrennte schnelle Aktionen nicht verschmelzen,
aber weit über dem Abstand aufeinanderfolgender Drag-Ereignisse (~16–30 ms).
Ein neuer Bearbeitungsschritt verwirft sofort einen etwaigen Wiederholen-Ast.
„Auf Beispiel zurücksetzen" und JSON-Import „Ersetzen" starten eine **neue**
Historie (das vorherige Dokument ist danach nicht mehr zurückholbar), Undo/
Redo selbst tun das nicht. Symbolleiste zeigt zwei Buttons (↶ ↷, deaktiviert
wenn nichts zu tun ist); greift auch ohne Tabellenfokus, außer während eine
Texteingabe (Zell-Editor, Formelleiste, Blatt-Umbenennen, …) den nativen
Undo dieses Feldes benutzen soll.
**In-Zellen-Editor**: das Eingabefeld liegt als Overlay über der Zelle (nicht
in ihr) und wächst mit dem Inhalt — in der Breite bis zu einer angenäherten
„sichtbaren Anzeigebreite", danach umbrechend; in der Höhe mit `Alt`+`Enter`
@@ -330,7 +348,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests
`npm run test` — Vitest, 132 Tests:
`npm run test` — Vitest, 138 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
@@ -382,6 +400,11 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
ein mehrzeiliger/-spaltiger Quellbereich wiederholt sich zyklisch, auch bei
einer unvollständigen letzten Wiederholung; füllt ebenso hoch/links;
Formatierung wird mitkopiert.
- **Rückgängig/Wiederholen** — Einzel-Edit rückgäng-/wiederholbar; mehrere
Edits schrittweise in der richtigen Reihenfolge zurück; ein neuer Edit nach
einem Undo verwirft den Wiederholen-Ast; reine Auswahländerungen sind nie
ein Schritt; deckt Kopieren/Einfügen, Ausfüllen und Formatierung ab (nicht
nur `setCellInput`); „Auf Beispiel zurücksetzen" beginnt eine neue Historie.
- **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei →
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,