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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user