ZDDC/tables/js/undo.js
2026-06-11 13:32:31 -05:00

115 lines
3.9 KiB
JavaScript

// undo.js — Phase 5 of editable-cell mode.
//
// Linear command stack, depth 50, session-local. Every successful
// per-cell edit and every bulk operation (paste, fill, delete) push
// a Command onto the stack. Ctrl/Cmd+Z pops the most recent and
// replays the inverse — sets each affected cell's draft buffer
// back to its `oldValue` (or clears the draft when oldValue was
// the row's stored value), then triggers a re-paint and the
// row-blur save flow picks the change up like any other edit.
//
// Why local-only: shared undo across multiple users is conceptually
// broken under last-writer-wins (undoing my edit might revert
// someone else's intervening edit). Every production grid keeps
// undo per-tab; we follow.
//
// Why no redo: minimum viable. Adding redo is a parallel forward
// stack cleared on any new edit. Cheap to add later if users miss
// it.
//
// Command shape:
// { cells: [ {rowId, field, oldValue, newValue}, ... ] }
//
// One-cell edits push a single-cell Command. Bulk operations push
// one Command with N cells so a single Ctrl+Z reverts the whole
// group.
(function (app) {
'use strict';
const STACK_MAX = 50;
const _stack = [];
function push(cmd) {
if (!cmd || !cmd.cells || cmd.cells.length === 0) return;
_stack.push(cmd);
if (_stack.length > STACK_MAX) {
_stack.shift();
}
}
function depth() { return _stack.length; }
function clear() { _stack.length = 0; }
function undo() {
const cmd = _stack.pop();
if (!cmd || !cmd.cells || cmd.cells.length === 0) return null;
const editor = app.modules.editor;
if (!editor) return null;
for (let i = 0; i < cmd.cells.length; i++) {
const c = cmd.cells[i];
// Compare oldValue to the row's stored data — if they
// match, clear the draft (the user's edit is being
// reversed back to baseline). Otherwise set draft = old.
const row = findRow(c.rowId);
if (!row) continue;
const stored = app.modules.util.resolveField(row.data, c.field);
if (sameValue(stored, c.oldValue)) {
editor.clearDraftField(c.rowId, c.field);
} else {
editor.setDraft(c.rowId, c.field, c.oldValue);
}
}
if (typeof app.repaint === 'function') app.repaint();
return cmd;
}
function findRow(rowId) {
const editor = app.modules.editor;
const all = (app.state && app.state.rows) || [];
for (let i = 0; i < all.length; i++) {
if (editor.rowKey(all[i]) === rowId) return all[i];
}
return null;
}
function sameValue(a, b) {
if (a === b) return true;
if (a == null && b == null) return true;
if (a == null || b == null) return false;
if (typeof a === 'object' || typeof b === 'object') {
try { return JSON.stringify(a) === JSON.stringify(b); }
catch (_) { return false; }
}
return String(a) === String(b);
}
// Hotkey: Ctrl+Z (Cmd+Z on macOS). Bound at the document level
// so the user can undo from anywhere on the page, not just from
// within a focused cell.
function onKey(ev) {
const isMod = ev.ctrlKey || ev.metaKey;
if (!isMod) return;
if (ev.key === 'z' || ev.key === 'Z') {
// Skip when the active element is a text-input-like; we
// don't want to override the browser's intra-input undo.
const ae = document.activeElement;
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) {
return;
}
ev.preventDefault();
undo();
}
}
document.addEventListener('keydown', onKey);
app.modules.undo = {
push: push,
undo: undo,
depth: depth,
clear: clear,
};
})(window.tablesApp);