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:
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # Vite-Devserver
|
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 build # Typecheck + Produktionsbuild
|
||||||
npm run lint # ESLint (flat config)
|
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 |
|
| `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`+`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 ↶ ↷) |
|
||||||
| **`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 |
|
||||||
| **`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 |
|
| **`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
|
automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter
|
||||||
Zeilen/Spalten).
|
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-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
|
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`
|
„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
|
## Tests
|
||||||
|
|
||||||
`npm run test` — Vitest, 132 Tests:
|
`npm run test` — Vitest, 138 Tests:
|
||||||
|
|
||||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
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
|
ein mehrzeiliger/-spaltiger Quellbereich wiederholt sich zyklisch, auch bei
|
||||||
einer unvollständigen letzten Wiederholung; füllt ebenso hoch/links;
|
einer unvollständigen letzten Wiederholung; füllt ebenso hoch/links;
|
||||||
Formatierung wird mitkopiert.
|
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 →
|
- **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei →
|
||||||
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
|
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
|
||||||
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,
|
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,
|
||||||
|
|||||||
+25
-1
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import AppToolbar from './components/AppToolbar.vue';
|
import AppToolbar from './components/AppToolbar.vue';
|
||||||
import FormulaBar from './components/FormulaBar.vue';
|
import FormulaBar from './components/FormulaBar.vue';
|
||||||
import SheetCanvas from './components/SheetCanvas.vue';
|
import SheetCanvas from './components/SheetCanvas.vue';
|
||||||
@@ -7,13 +7,37 @@ import SheetTabs from './components/SheetTabs.vue';
|
|||||||
import StatusBar from './components/StatusBar.vue';
|
import StatusBar from './components/StatusBar.vue';
|
||||||
import SettingsPanel from './components/SettingsPanel.vue';
|
import SettingsPanel from './components/SettingsPanel.vue';
|
||||||
import { useToasts } from './composables/useToasts';
|
import { useToasts } from './composables/useToasts';
|
||||||
|
import { useDocument } from './store/document';
|
||||||
|
|
||||||
const toolbar = ref<InstanceType<typeof AppToolbar> | null>(null);
|
const toolbar = ref<InstanceType<typeof AppToolbar> | null>(null);
|
||||||
const { toasts, dismiss } = useToasts();
|
const { toasts, dismiss } = useToasts();
|
||||||
|
const store = useDocument();
|
||||||
|
|
||||||
function onCanvasFiles(files: FileList) {
|
function onCanvasFiles(files: FileList) {
|
||||||
if (files[0]) toolbar.value?.handleFile(files[0]);
|
if (files[0]) toolbar.value?.handleFile(files[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fallback Strg/Cmd+Z/Y when no table grid has focus (e.g. an empty canvas
|
||||||
|
* click) -- TableGrid.vue handles it directly (and correctly defers to a
|
||||||
|
* cell editor's own native undo) whenever a table *does* have focus. Skips
|
||||||
|
* entirely while focus sits in any other text field (rename-sheet, CSV
|
||||||
|
* import, a colour input, ...), so their own native undo keeps working. */
|
||||||
|
function isTextEditable(el: Element | null): boolean {
|
||||||
|
if (!el) return false;
|
||||||
|
const tag = el.tagName;
|
||||||
|
return tag === 'INPUT' || tag === 'TEXTAREA' || (el as HTMLElement).isContentEditable;
|
||||||
|
}
|
||||||
|
function onGlobalKeydown(e: KeyboardEvent) {
|
||||||
|
if (!(e.ctrlKey || e.metaKey)) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k !== 'z' && k !== 'y') return;
|
||||||
|
if (isTextEditable(document.activeElement)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (k === 'y' || (k === 'z' && e.shiftKey)) store.redo();
|
||||||
|
else store.undo();
|
||||||
|
}
|
||||||
|
onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -209,6 +209,24 @@ function onReset() {
|
|||||||
⚙
|
⚙
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<span class="divider" />
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
title="Rückgängig (Strg/Cmd+Z)"
|
||||||
|
:disabled="!store.canUndo.value"
|
||||||
|
@click="store.undo()"
|
||||||
|
>
|
||||||
|
↶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
title="Wiederholen (Strg/Cmd+Y)"
|
||||||
|
:disabled="!store.canRedo.value"
|
||||||
|
@click="store.redo()"
|
||||||
|
>
|
||||||
|
↷
|
||||||
|
</button>
|
||||||
|
|
||||||
<span class="divider" />
|
<span class="divider" />
|
||||||
<button class="btn accent" @click="insertTable">+ Tabelle</button>
|
<button class="btn accent" @click="insertTable">+ Tabelle</button>
|
||||||
<button class="btn accent" @click="insertChart">+ Diagramm</button>
|
<button class="btn accent" @click="insertChart">+ Diagramm</button>
|
||||||
@@ -499,6 +517,11 @@ function onReset() {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.btn.accent {
|
.btn.accent {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|||||||
@@ -736,6 +736,16 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return store.pasteClipboard();
|
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) {
|
switch (e.key) {
|
||||||
|
|||||||
@@ -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 { useDocument, type DocumentStore } from '../document';
|
||||||
import { isError, toA1 } from '../../engine';
|
import { isError, toA1 } from '../../engine';
|
||||||
import { serializeDocument, parseDocument } from '../../io/json';
|
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', () => {
|
describe('text box objects', () => {
|
||||||
it('insert / edit / format / round-trip a text object', () => {
|
it('insert / edit / format / round-trip a text object', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
|
|||||||
+144
-1
@@ -201,6 +201,134 @@ function createStore() {
|
|||||||
{ deep: true },
|
{ 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<typeof setTimeout> | 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 ------------------------------------------
|
// ---- id / name helpers ------------------------------------------
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}`;
|
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}`;
|
||||||
@@ -1081,7 +1209,10 @@ function createStore() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- document-level ops ---------------------------
|
// ---- 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.version = 2;
|
||||||
doc.sheets = next.sheets;
|
doc.sheets = next.sheets;
|
||||||
doc.activeSheetId =
|
doc.activeSheetId =
|
||||||
@@ -1092,6 +1223,13 @@ function createStore() {
|
|||||||
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
||||||
syncWorkbook();
|
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() {
|
function resetToSeed() {
|
||||||
replaceDocument(createSeedDocument());
|
replaceDocument(createSeedDocument());
|
||||||
}
|
}
|
||||||
@@ -1144,6 +1282,11 @@ function createStore() {
|
|||||||
return {
|
return {
|
||||||
doc,
|
doc,
|
||||||
recalcTick,
|
recalcTick,
|
||||||
|
// undo / redo
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
// sheets
|
// sheets
|
||||||
sheetById,
|
sheetById,
|
||||||
activeSheet,
|
activeSheet,
|
||||||
|
|||||||
Reference in New Issue
Block a user