diff --git a/README.md b/README.md
index da7b1db..5c7b79c 100644
--- a/README.md
+++ b/README.md
@@ -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,
diff --git a/src/App.vue b/src/App.vue
index 3828b66..4e92fdb 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,5 +1,5 @@
diff --git a/src/components/AppToolbar.vue b/src/components/AppToolbar.vue
index 6e73e51..65abff4 100644
--- a/src/components/AppToolbar.vue
+++ b/src/components/AppToolbar.vue
@@ -209,6 +209,24 @@ function onReset() {
⚙
+
+
+
+
@@ -499,6 +517,11 @@ function onReset() {
border-color: var(--accent);
color: var(--accent);
}
+.btn:disabled {
+ opacity: 0.4;
+ cursor: default;
+ pointer-events: none;
+}
.btn.accent {
border-color: var(--accent);
color: var(--accent);
diff --git a/src/components/TableGrid.vue b/src/components/TableGrid.vue
index edec5a6..e2baef1 100644
--- a/src/components/TableGrid.vue
+++ b/src/components/TableGrid.vue
@@ -736,6 +736,16 @@ function onKeydown(e: KeyboardEvent) {
e.stopPropagation();
return store.pasteClipboard();
}
+ if (k === 'z' && !e.shiftKey) {
+ e.preventDefault();
+ e.stopPropagation();
+ return store.undo();
+ }
+ if ((k === 'z' && e.shiftKey) || k === 'y') {
+ e.preventDefault();
+ e.stopPropagation();
+ return store.redo();
+ }
}
switch (e.key) {
diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts
index d676e5a..6e72ed9 100644
--- a/src/store/__tests__/document.spec.ts
+++ b/src/store/__tests__/document.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDocument, type DocumentStore } from '../document';
import { isError, toA1 } from '../../engine';
import { serializeDocument, parseDocument } from '../../io/json';
@@ -677,6 +677,129 @@ describe('fill handle (drag to copy/fill)', () => {
});
});
+describe('undo / redo', () => {
+ // rapid-fire changes (a resize drag, or several store calls in one
+ // synchronous action) coalesce into a single undo step -- tests that want
+ // *separate* steps advance the fake clock between them, past the store's
+ // debounce window, the same way real, separately-timed user actions would.
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ });
+
+ it('undoes and redoes a single cell edit', () => {
+ const s = fresh();
+ expect(s.canUndo.value).toBe(false);
+ const id = s.addTable(s.doc.activeSheetId);
+ const name = s.tableById(id)!.name;
+ vi.advanceTimersByTime(600); // creating the table settles as its own step
+
+ setCell(s, name, 'A1', '5');
+ s.undo();
+ expect(s.cellInput(id, 0, 0)).toBe('');
+ expect(s.canUndo.value).toBe(true); // the table-creation step is still there to undo
+ expect(s.canRedo.value).toBe(true);
+
+ s.redo();
+ expect(s.cellInput(id, 0, 0)).toBe('5');
+ expect(s.canRedo.value).toBe(false);
+ });
+
+ it('steps back through several edits one at a time, in order', () => {
+ const s = fresh();
+ const id = s.addTable(s.doc.activeSheetId);
+ const name = s.tableById(id)!.name;
+ vi.advanceTimersByTime(600); // creating the table settles as its own step
+ setCell(s, name, 'A1', '1');
+ vi.advanceTimersByTime(600);
+ setCell(s, name, 'A1', '2');
+ vi.advanceTimersByTime(600);
+ setCell(s, name, 'A1', '3');
+
+ expect(s.cellInput(id, 0, 0)).toBe('3');
+ s.undo();
+ expect(s.cellInput(id, 0, 0)).toBe('2');
+ s.undo();
+ expect(s.cellInput(id, 0, 0)).toBe('1');
+ s.undo();
+ expect(s.cellInput(id, 0, 0)).toBe('');
+ expect(s.tableById(id)).toBeDefined(); // only the edits undid -- table itself is its own step
+ expect(s.canUndo.value).toBe(true);
+ s.undo(); // one further -- removes the table (the step before the edits)
+ expect(s.tableById(id)).toBeUndefined();
+ expect(s.canUndo.value).toBe(false);
+ s.undo(); // one further than there is history -- no-op, doesn't throw
+ expect(s.canUndo.value).toBe(false);
+ });
+
+ it('a new edit after undoing drops the redo branch', () => {
+ const s = fresh();
+ const id = s.addTable(s.doc.activeSheetId);
+ const name = s.tableById(id)!.name;
+ vi.advanceTimersByTime(600); // creating the table settles as its own step
+ setCell(s, name, 'A1', '1');
+ setCell(s, name, 'A1', '2');
+ s.undo();
+ expect(s.canRedo.value).toBe(true);
+
+ setCell(s, name, 'A1', '9');
+ expect(s.canRedo.value).toBe(false);
+ s.redo(); // nothing to redo -- no-op
+ expect(s.cellInput(id, 0, 0)).toBe('9');
+ });
+
+ it('pure selection changes are never an undo step', () => {
+ const s = fresh();
+ const id = s.addTable(s.doc.activeSheetId);
+ vi.advanceTimersByTime(600); // creating the table settles as its own (the only) step
+ s.setSelection({ tableId: id, anchor: coords('A1'), focus: coords('A1') });
+ s.setSelection({ tableId: id, anchor: coords('B2'), focus: coords('C3') });
+ // exactly one undo fully reverts to "no table" -- proving the selection
+ // moves in between added no steps of their own
+ s.undo();
+ expect(s.tableById(id)).toBeUndefined();
+ expect(s.canUndo.value).toBe(false);
+ });
+
+ it('covers copy/paste, fill and formatting -- not just setCellInput', () => {
+ const s = fresh();
+ const id = s.addTable(s.doc.activeSheetId);
+ const name = s.tableById(id)!.name;
+ setCell(s, name, 'A1', '7');
+ vi.advanceTimersByTime(600); // settle as its own step, separate from the paste below
+
+ s.setSelection({ tableId: id, anchor: coords('A1'), focus: coords('A1') });
+ s.copySelection();
+ s.setSelection({ tableId: id, anchor: coords('B1'), focus: coords('B1') });
+ s.pasteClipboard();
+ expect(val(s, name, 'B1')).toBe(7);
+ s.undo();
+ expect(s.cellInput(id, coords('B1').col, coords('B1').row)).toBe('');
+
+ s.fillSelection(id, { c0: 0, c1: 0, r0: 0, r1: 0 }, { c0: 0, c1: 0, r0: 1, r1: 1 });
+ expect(val(s, name, 'A2')).toBe(7);
+ s.undo();
+ expect(s.cellInput(id, 0, 1)).toBe('');
+
+ s.setRangeFormat(id, coords('A1'), coords('A1'), { bold: true });
+ expect(s.cellFormat(id, 0, 0)).toEqual({ bold: true });
+ s.undo();
+ expect(s.cellFormat(id, 0, 0)).toEqual({});
+ });
+
+ it('resetToSeed starts a fresh history -- the prior document is not undoable back', () => {
+ const s = fresh();
+ const id = s.addTable(s.doc.activeSheetId);
+ setCell(s, s.tableById(id)!.name, 'A1', 'temp');
+ expect(s.canUndo.value).toBe(true);
+
+ s.resetToSeed();
+ expect(s.canUndo.value).toBe(false);
+ expect(s.canRedo.value).toBe(false);
+ });
+});
+
describe('text box objects', () => {
it('insert / edit / format / round-trip a text object', () => {
const s = fresh();
diff --git a/src/store/document.ts b/src/store/document.ts
index 80a2e9c..0ed7877 100644
--- a/src/store/document.ts
+++ b/src/store/document.ts
@@ -201,6 +201,134 @@ function createStore() {
{ deep: true },
);
+ // ---- undo / redo (debounced content snapshots) -----------------------
+ /**
+ * Snapshot-based, like the JSON export/import round-trip already is
+ * (`snapshot()`/`replaceDocument()`) rather than a patch/command log --
+ * simple and provably correct for a document this size.
+ *
+ * Selection/active-object are deliberately excluded from what counts as a
+ * "change": moving the cursor around must never itself become an undo
+ * step. A burst of rapid mutations (column-resize drag fires on every
+ * mousemove; so would per-keystroke typing, if the editors pushed straight
+ * into the store instead of committing once) is coalesced into a single
+ * step by waiting for HISTORY_COALESCE_MS of quiet before recording one --
+ * every mutating action in this store already commits once per discrete
+ * user action (a keystroke only touches a component-local draft; only
+ * `setCellInput` et al. touch `doc`), so in practice this mainly matters
+ * for the resize-drag case. Kept short (well under normal typing/click
+ * pace) so two genuinely separate quick actions still don't merge -- a
+ * drag's mousemove events are only ~16-30ms apart, comfortably under it.
+ */
+ const HISTORY_COALESCE_MS = 150;
+ const MAX_HISTORY = 100;
+ const undoStack: DocumentState[] = [];
+ const redoStack: DocumentState[] = [];
+ const contentFingerprint = (d: DocumentState) =>
+ JSON.stringify({ sheets: d.sheets, locale: d.locale, csvDelimiter: d.csvDelimiter });
+ let historySnapshot = snapshot();
+ let historyFingerprint = contentFingerprint(historySnapshot);
+ let historyTimer: ReturnType | undefined;
+ // undoStack/redoStack are plain arrays (snapshots are large; wrapping them
+ // in reactive() would make Vue deep-proxy every one for no benefit) --
+ // this tick is what canUndo/canRedo actually track. historyPending mirrors
+ // "does the doc currently differ from the last committed baseline" so
+ // canUndo goes true the instant a change happens, not once the debounce
+ // settles (undo() itself already doesn't wait either way -- it flushes any
+ // pending change synchronously -- this is purely so a bound UI button
+ // doesn't lag behind what Ctrl+Z would actually do right now).
+ const historyTick = ref(0);
+ const historyPending = ref(false);
+ // true while undo()/redo() are themselves applying a snapshot -- the watch
+ // below must not treat that as "a new edit" (which would immediately wipe
+ // the very redoStack/undoStack entry undo()/redo() just pushed).
+ let applyingHistory = false;
+
+ function commitHistoryStep() {
+ clearTimeout(historyTimer);
+ const fp = contentFingerprint(doc);
+ historyPending.value = false;
+ if (fp === historyFingerprint) return; // settled back to a no-op, or nothing pending
+ undoStack.push(historySnapshot);
+ if (undoStack.length > MAX_HISTORY) undoStack.shift();
+ historySnapshot = snapshot();
+ historyFingerprint = fp;
+ historyTick.value++;
+ }
+ watch(
+ () => doc,
+ () => {
+ if (applyingHistory) return; // undo()/redo() reconciles historyPending/Fingerprint itself
+ const dirty = contentFingerprint(doc) !== historyFingerprint;
+ // the *first* mutation of a new burst -- a fresh action just started,
+ // so any redo branch from a previous undo is no longer reachable.
+ // Done here (synchronously, on the transition) rather than debounced
+ // in commitHistoryStep(), so it matches real undo/redo semantics
+ // everywhere else: a new edit kills "redo" immediately, not 500ms later.
+ if (dirty && !historyPending.value) {
+ redoStack.length = 0;
+ historyTick.value++;
+ }
+ historyPending.value = dirty;
+ if (!dirty) return; // pure selection/UI change, or settled back to a no-op
+ clearTimeout(historyTimer);
+ historyTimer = setTimeout(commitHistoryStep, HISTORY_COALESCE_MS);
+ },
+ // sync: historyPending/redoStack must be accurate the instant a mutation
+ // happens (so a bound "Undo"/"Redo" button is never briefly wrong) --
+ // safe to fire many times during one multi-cell action (paste, fill, a
+ // resize drag) because this only *reschedules* the debounced commit,
+ // never calls it directly; that stays coalesced into one step either way.
+ { deep: true, flush: 'sync' },
+ );
+
+ /** Wipes undo/redo history and re-baselines it on the current doc -- used
+ * when a *different* document is loaded wholesale (replaceDocument()),
+ * never by undo()/redo() themselves. */
+ function resetHistory() {
+ clearTimeout(historyTimer);
+ undoStack.length = 0;
+ redoStack.length = 0;
+ historySnapshot = snapshot();
+ historyFingerprint = contentFingerprint(historySnapshot);
+ historyPending.value = false;
+ historyTick.value++;
+ }
+
+ const canUndo = computed(() => (historyTick.value, undoStack.length > 0 || historyPending.value));
+ const canRedo = computed(() => (historyTick.value, redoStack.length > 0));
+
+ function undo() {
+ commitHistoryStep(); // flush a pending (not yet coalesced) change first
+ const prev = undoStack.pop();
+ if (!prev) return;
+ redoStack.push(snapshot());
+ if (redoStack.length > MAX_HISTORY) redoStack.shift();
+ applyingHistory = true;
+ applyDocumentState(prev);
+ applyingHistory = false;
+ // reconcile manually, matching what applyDocumentState() just did --
+ // the watch above skipped this while applyingHistory was true
+ historySnapshot = snapshot();
+ historyFingerprint = contentFingerprint(doc);
+ historyPending.value = false;
+ historyTick.value++;
+ }
+ function redo() {
+ clearTimeout(historyTimer);
+ const next = redoStack.pop();
+ if (!next) return;
+ undoStack.push(snapshot());
+ if (undoStack.length > MAX_HISTORY) undoStack.shift();
+ applyingHistory = true;
+ applyDocumentState(next);
+ applyingHistory = false;
+ historySnapshot = snapshot();
+ historyFingerprint = contentFingerprint(doc);
+ historyPending.value = false;
+ historyTick.value++;
+ }
+
// ---- id / name helpers ------------------------------------------
let seq = 0;
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}`;
@@ -1081,7 +1209,10 @@ function createStore() {
});
// ---- document-level ops ---------------------------
- function replaceDocument(next: DocumentState) {
+ /** The actual content swap, shared by replaceDocument() and undo()/redo() --
+ * the latter must *not* also reset history (that's the whole stack it's
+ * currently unwinding), so they call this directly instead. */
+ function applyDocumentState(next: DocumentState) {
doc.version = 2;
doc.sheets = next.sheets;
doc.activeSheetId =
@@ -1092,6 +1223,13 @@ function createStore() {
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
syncWorkbook();
}
+ /** Loading a different document outright (JSON import "replace", "reset to
+ * seed") -- unlike undo/redo, this starts a fresh undo history rather
+ * than leaving the previous document's edits undoable into the new one. */
+ function replaceDocument(next: DocumentState) {
+ applyDocumentState(next);
+ resetHistory();
+ }
function resetToSeed() {
replaceDocument(createSeedDocument());
}
@@ -1144,6 +1282,11 @@ function createStore() {
return {
doc,
recalcTick,
+ // undo / redo
+ undo,
+ redo,
+ canUndo,
+ canRedo,
// sheets
sheetById,
activeSheet,