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:
+144
-1
@@ -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<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 ------------------------------------------
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user