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
+124 -1
View File
@@ -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();