Fix 50k-row edit performance: replace deep-watch undo/persistence with explicit dirty-marking

The debounced undo-history and localStorage persistence were driven by
watch(() => doc, ..., { deep: true }) -- Vue's deep watch re-traverses
the entire reactive object graph on every single mutation just to
re-establish tracking, an O(document size) cost per edit regardless of
what actually changed. Measured on a 50k-row table: one setCellInput
call went from ~450ms to <1ms with the deep watch disabled.

Both watches are replaced by scheduleSave()/markDirty(), called
explicitly by every content-mutating store function (mirroring the
existing bump()/recalcTick convention), with pure selection/navigation
functions calling scheduleSave() only so cursor movement still never
becomes an undo step. applyDocumentState (undo/redo/replaceDocument)
schedules persistence but never marks history dirty, since those
callers already reconcile history state themselves -- this also makes
the old applyingHistory guard flag dead code, now removed.

setCellInput gets an O(1) same-value no-op guard (the highest-frequency
mutator) so retyping an unchanged value creates no history noise; the
remaining no-op safety net (a real fingerprint diff) still lives in
commitHistoryStep, now running once per debounced burst instead of per
keystroke.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-09 22:20:27 +02:00
co-authored by Claude Sonnet 5
parent 47c99bbeb6
commit 1c5e57ceb4
3 changed files with 212 additions and 73 deletions
+165 -73
View File
@@ -17,7 +17,7 @@
* qualifier, so `Umsatz!B2` resolves the same from any sheet.
*/
import { computed, reactive, ref, watch } from 'vue';
import { computed, reactive, ref } from 'vue';
import {
Workbook,
toA1,
@@ -570,21 +570,27 @@ function createStore() {
}
// ---- persistence (debounced) --------------------------------------
// A manually-called signal, not a `watch(() => doc, ..., { deep: true })`
// -- Vue's deep watch re-traverses the *entire* reactive object graph on
// every single mutation just to re-establish tracking, an O(total document
// size) cost per edit regardless of what actually changed (measured: on a
// 50k-row table, one cell edit went from ~450ms with a deep watch driving
// this and the history tracking below to <1ms without it). Every
// content-mutating function in this store calls `markDirty()` below (or,
// for a mutation that must never itself become an undo step --
// selection/navigation -- `scheduleSave()` alone) explicitly, the same
// convention `bump()`/`recalcTick` above already uses for the engine cache.
let persistTimer: ReturnType<typeof setTimeout> | undefined;
watch(
() => doc,
() => {
clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(doc));
} catch {
/* quota / privacy mode -- ignore */
}
}, 250);
},
{ deep: true },
);
function scheduleSave() {
clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(doc));
} catch {
/* quota / privacy mode -- ignore */
}
}, 250);
}
// ---- undo / redo (debounced content snapshots) -----------------------
/**
@@ -631,17 +637,13 @@ function createStore() {
// 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;
// true for the duration of one runMacro() call (set/cleared there) -- a
// macro with a GET/POST can genuinely take longer than HISTORY_COALESCE_MS
// between two mutations (a real network round-trip), which would otherwise
// let the debounced commit below fire *during* the run and split one
// macro into two+ undo steps, silently breaking the documented "one
// synchronous run = one undo step" guarantee (see README's "Makros"
// section). While this is true, the watch below still tracks
// section). While this is true, `markDirty()` below still tracks
// historyPending/redoStack (so a bound Undo/Redo button stays accurate
// mid-run) but never *schedules* commitHistoryStep -- runMacro() calls it
// exactly once itself, in its `finally`, regardless of how long the
@@ -659,33 +661,35 @@ function createStore() {
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);
if (macroRunActive) return; // runMacro() commits exactly once itself, see its own comment above
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' },
);
/**
* Called explicitly by every content-mutating store function below (never
* by applyDocumentState/undo/redo, which reconcile history state
* themselves) -- marks the doc dirty, kills any reachable redo branch on
* the *first* mutation of a new burst (matching real undo/redo semantics:
* a new edit kills "redo" immediately, not once the debounce settles), and
* (re)schedules the debounced commit.
*
* Unlike the deep-watch design this replaces, this always treats the call
* as a genuine change -- no whole-document fingerprint diff here, since
* that per-mutation traversal is exactly the O(document size) cost being
* eliminated. The one safety net that still runs a real diff,
* `commitHistoryStep`'s own fingerprint check above, still guarantees a
* burst that settles back to a no-op never lands on the undo stack -- it
* just now runs once per debounced burst instead of on every mutation.
* `setCellInput` additionally guards its own no-op case (retyping the same
* value) up front so the common "F2, Enter" case doesn't even call this.
*/
function markDirty() {
scheduleSave();
if (!historyPending.value) {
redoStack.length = 0;
historyTick.value++;
}
historyPending.value = true;
clearTimeout(historyTimer);
if (macroRunActive) return; // runMacro() commits exactly once itself, see its own comment above
historyTimer = setTimeout(commitHistoryStep, HISTORY_COALESCE_MS);
}
/** Wipes undo/redo history and re-baselines it on the current doc -- used
* when a *different* document is loaded wholesale (replaceDocument()),
@@ -709,11 +713,8 @@ function createStore() {
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
applyDocumentState(prev); // schedules a save itself; never calls markDirty
// reconcile manually -- applyDocumentState() doesn't touch history state
historySnapshot = snapshot();
historyFingerprint = contentFingerprint(doc);
historyPending.value = false;
@@ -725,9 +726,7 @@ function createStore() {
if (!next) return;
undoStack.push(snapshot());
if (undoStack.length > MAX_HISTORY) undoStack.shift();
applyingHistory = true;
applyDocumentState(next);
applyingHistory = false;
applyDocumentState(next); // schedules a save itself; never calls markDirty
historySnapshot = snapshot();
historyFingerprint = contentFingerprint(doc);
historyPending.value = false;
@@ -799,12 +798,14 @@ function createStore() {
doc.selection = first
? { tableId: first.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } }
: null;
scheduleSave(); // navigation only -- never an undo step
}
function addSheet(name?: string): string {
const id = uid('sheet');
doc.sheets.push({ id, name: uniqueName(name || 'Blatt', sheetNames()), objects: [] });
setActiveSheet(id);
markDirty();
return id;
}
function renameSheet(id: string, name: string) {
@@ -813,6 +814,7 @@ function createStore() {
if (!s || !clean) return;
const others = new Set(doc.sheets.filter((o) => o.id !== id).map((o) => o.name.toLowerCase()));
s.name = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean;
markDirty();
}
function deleteSheet(id: string) {
if (doc.sheets.length <= 1) return;
@@ -822,12 +824,14 @@ function createStore() {
doc.sheets.splice(idx, 1);
if (doc.activeSheetId === id) setActiveSheet(doc.sheets[Math.max(0, idx - 1)].id);
if (hadTables) syncWorkbook(); // dependents elsewhere become #REF
markDirty();
}
function moveSheet(from: number, to: number) {
if (from < 0 || to < 0 || from >= doc.sheets.length || to >= doc.sheets.length || from === to)
return;
const [s] = doc.sheets.splice(from, 1);
doc.sheets.splice(to, 0, s);
markDirty();
}
// ---- per-sheet appearance + zoom -----------------------------
@@ -836,20 +840,26 @@ function createStore() {
if (!s) return;
if (color) s.background = color;
else delete s.background;
markDirty();
}
function setSheetForeground(sheetId: string, color: string | null) {
const s = sheetById(sheetId);
if (!s) return;
if (color) s.foreground = color;
else delete s.foreground;
markDirty();
}
function setSheetShowGrid(sheetId: string, show: boolean) {
const s = sheetById(sheetId);
if (s) s.showGrid = show;
if (!s) return;
s.showGrid = show;
markDirty();
}
function setSheetZoom(sheetId: string, z: number) {
const s = sheetById(sheetId);
if (s) s.zoom = clampZoom(z);
if (!s) return;
s.zoom = clampZoom(z);
markDirty();
}
/** Zoom of the currently active sheet (used by the canvas + zoom bar). */
const activeZoom = computed(() => activeSheet.value.zoom ?? 1);
@@ -862,6 +872,7 @@ function createStore() {
if (color) o.bg = color;
else delete o.bg;
if (o.kind !== 'table') bump();
markDirty();
}
function setObjectTextColor(objId: string, color: string | null) {
const o = objectById(objId);
@@ -875,10 +886,12 @@ function createStore() {
else delete o.fg;
if (o.kind === 'chart') bump();
}
markDirty();
}
function setCsvDelimiter(d: ',' | ';') {
doc.csvDelimiter = d === ';' ? ';' : ',';
markDirty();
}
// ---- canvas objects ----------------------------------------
@@ -909,6 +922,7 @@ function createStore() {
doc.activeSheetId = sheetId;
doc.selection = { tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
syncWorkbook();
markDirty();
return id;
}
@@ -937,6 +951,7 @@ function createStore() {
doc.activeObjectId = id;
doc.activeSheetId = sheetId;
bump();
markDirty();
return id;
}
@@ -961,11 +976,14 @@ function createStore() {
doc.activeObjectId = id;
doc.activeSheetId = sheetId;
bump();
markDirty();
return id;
}
function setTextContent(objId: string, text: string) {
const o = objectById(objId);
if (o?.kind === 'text') o.text = text;
if (o?.kind !== 'text') return;
o.text = text;
markDirty();
}
function setTextFormat(objId: string, patch: Partial<Omit<TextObject, 'kind' | 'id'>>) {
const o = objectById(objId);
@@ -976,12 +994,14 @@ function createStore() {
if (patch.italic !== undefined) o.italic = patch.italic || undefined;
if ('color' in patch) o.color = patch.color || undefined;
if ('bg' in patch) o.bg = patch.bg || undefined;
markDirty();
}
function resizeText(objId: string, width: number, height: number) {
const o = objectById(objId);
if (o?.kind !== 'text') return;
o.width = Math.max(80, Math.round(width));
o.height = Math.max(32, Math.round(height));
markDirty();
}
// ---- buttons (canvas objects that run a macro on click) ---------------
@@ -1005,21 +1025,27 @@ function createStore() {
doc.activeObjectId = id;
doc.activeSheetId = sheetId;
bump();
markDirty();
return id;
}
function setButtonLabel(objId: string, label: string) {
const o = objectById(objId);
if (o?.kind === 'button') o.label = label;
if (o?.kind !== 'button') return;
o.label = label;
markDirty();
}
function setButtonMacroId(objId: string, macroId: string | null) {
const o = objectById(objId);
if (o?.kind === 'button') o.macroId = macroId;
if (o?.kind !== 'button') return;
o.macroId = macroId;
markDirty();
}
function resizeButton(objId: string, width: number, height: number) {
const o = objectById(objId);
if (o?.kind !== 'button') return;
o.width = Math.max(60, Math.round(width));
o.height = Math.max(28, Math.round(height));
markDirty();
}
function renameObject(objId: string, name: string) {
@@ -1047,6 +1073,7 @@ function createStore() {
} else {
o.name = clean;
}
markDirty();
}
function deleteObject(objId: string) {
@@ -1061,6 +1088,7 @@ function createStore() {
// charts bound to a deleted table keep a dangling id -> they render empty
if (isTable) syncWorkbook();
else bump();
markDirty();
}
// ---- custom functions (document-level formula library) ----------------
@@ -1283,6 +1311,7 @@ function createStore() {
};
doc.customFunctions.push(custom);
syncWorkbook();
markDirty();
return id;
}
@@ -1302,11 +1331,13 @@ function createStore() {
f.body = body.startsWith('=') ? body.slice(1) : body;
f.kind = kind;
syncWorkbook();
markDirty();
}
function deleteCustomFunction(id: string) {
doc.customFunctions = doc.customFunctions.filter((f) => f.id !== id);
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
markDirty();
}
// ---- named ranges (document-level names for a fixed cell/range) -------
@@ -1366,6 +1397,7 @@ function createStore() {
const id = uid('nr');
doc.namedRanges.push({ id, name: name.trim(), tableId, start: { ...start }, end: { ...end } });
syncWorkbook();
markDirty();
return id;
}
@@ -1385,11 +1417,13 @@ function createStore() {
r.start = { ...start };
r.end = { ...end };
syncWorkbook();
markDirty();
}
function deleteNamedRange(id: string) {
doc.namedRanges = doc.namedRanges.filter((r) => r.id !== id);
syncWorkbook(); // affected formulas become #NAME? on rebuild, same as a deleted table
markDirty();
}
// ---- macros (document-level, explicitly-triggered write scripts) ------
@@ -1457,6 +1491,7 @@ function createStore() {
const macro: Macro = { id, name: name.trim(), body: body.startsWith('=') ? body.slice(1) : body };
doc.macros.push(macro);
bump();
markDirty();
return id;
}
@@ -1468,11 +1503,13 @@ function createStore() {
m.name = name.trim();
m.body = body.startsWith('=') ? body.slice(1) : body;
bump();
markDirty();
}
function deleteMacro(id: string) {
doc.macros = doc.macros.filter((m) => m.id !== id);
bump();
markDirty();
}
/** Resolve a macro action's plain "Tabelle" / "Blatt!Tabelle" string
@@ -1616,6 +1653,7 @@ function createStore() {
throw new Error(`${method}: Verbindung zu '${host}' abgelehnt`);
}
(macro.approvedHosts ??= []).push(host);
markDirty();
}
let res: Response;
try {
@@ -1659,12 +1697,14 @@ function createStore() {
if (!o) return;
o.x = Math.max(0, Math.round(x));
o.y = Math.max(0, Math.round(y));
markDirty();
}
function resizeChart(objId: string, width: number, height: number) {
const o = objectById(objId);
if (!o || o.kind !== 'chart') return;
o.width = Math.max(200, Math.round(width));
o.height = Math.max(140, Math.round(height));
markDirty();
}
function bringToFront(objId: string) {
const sheetId = sheetIdOfObject(objId);
@@ -1674,24 +1714,31 @@ function createStore() {
if (idx < 0 || idx === sheet.objects.length - 1) return;
const [o] = sheet.objects.splice(idx, 1);
sheet.objects.push(o);
markDirty();
}
function setChartView(objId: string, view: 'bar' | 'line') {
const o = objectById(objId);
if (o?.kind === 'chart') o.view = view;
if (o?.kind !== 'chart') return;
o.view = view;
markDirty();
}
function setChartSource(objId: string, tableId: string) {
const o = objectById(objId);
if (o?.kind === 'chart') o.sourceTableId = tableId;
if (o?.kind !== 'chart') return;
o.sourceTableId = tableId;
markDirty();
}
function selectObject(objId: string | null) {
doc.activeObjectId = objId;
if (!objId) return;
const sheetId = sheetIdOfObject(objId);
if (sheetId) doc.activeSheetId = sheetId;
const o = objectById(objId);
if (o?.kind === 'table' && doc.selection?.tableId !== objId) {
doc.selection = { tableId: objId, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
if (objId) {
const sheetId = sheetIdOfObject(objId);
if (sheetId) doc.activeSheetId = sheetId;
const o = objectById(objId);
if (o?.kind === 'table' && doc.selection?.tableId !== objId) {
doc.selection = { tableId: objId, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
}
}
scheduleSave(); // navigation only -- never an undo step
}
// ---- cell reads (reactive via recalcTick) -----------------
@@ -1728,19 +1775,23 @@ function createStore() {
const activeLocale = computed(() => effectiveLocale(activeTable.value?.id ?? null));
function setDocumentLocale(l: string) {
if (isNumberLocale(l)) doc.locale = l;
if (!isNumberLocale(l)) return;
doc.locale = l;
markDirty();
}
function setSheetLocale(sheetId: string, l: string | null) {
const s = sheetById(sheetId);
if (!s) return;
if (l && isNumberLocale(l)) s.locale = l;
else delete s.locale;
markDirty();
}
function setObjectLocale(objId: string, l: string | null) {
const o = objectById(objId);
if (!o || (o.kind !== 'table' && o.kind !== 'chart')) return;
if (l && isNumberLocale(l)) o.locale = l;
else delete o.locale;
markDirty();
}
const cellIsNumeric = (t: string, c: number, r: number) => isNumeric(cellValue(t, c, r));
const cellIsError = (t: string, c: number, r: number) => isError(cellValue(t, c, r));
@@ -1816,6 +1867,15 @@ function createStore() {
if (!table) return;
const key = `${col},${row}`;
const trimmed = raw.replace(/\s+$/, '');
// Cheap, O(1) no-op guard: re-committing a cell's already-current value
// (e.g. F2, Enter with nothing changed; a clear over a range that's
// already empty) must not mark history dirty or clear the redo stack --
// this is the one place that gets that check back after the old
// whole-document deep watch (which got it for free as a byproduct) was
// replaced by explicit, unconditional markDirty() calls (see that
// function's own doc comment) -- chosen here specifically because it's
// by far the highest-frequency mutator.
if (trimmed === (table.cells[key] ?? '')) return;
if (trimmed === '') delete table.cells[key];
else table.cells[key] = trimmed;
if (col >= table.cols) table.cols = col + 1;
@@ -1832,6 +1892,7 @@ function createStore() {
}
}
bump();
markDirty();
}
function clearRange(tableId: string, a: CellCoord, b: CellCoord) {
const c0 = Math.min(a.col, b.col);
@@ -1844,11 +1905,15 @@ function createStore() {
// ---- table grid growth -----------------------------
const addRows = (tableId: string, n = 1) => {
const t = tableById(tableId);
if (t) t.rows += n;
if (!t) return;
t.rows += n;
markDirty();
};
const addColumns = (tableId: string, n = 1) => {
const t = tableById(tableId);
if (t) t.cols += n;
if (!t) return;
t.cols += n;
markDirty();
};
function removeLastRow(tableId: string) {
const t = tableById(tableId);
@@ -1857,6 +1922,7 @@ function createStore() {
for (let c = 0; c < t.cols; c++) delete t.cells[`${c},${r}`];
t.rows -= 1;
syncWorkbook();
markDirty();
}
function removeLastColumn(tableId: string) {
const t = tableById(tableId);
@@ -1866,13 +1932,16 @@ function createStore() {
delete t.colWidths[c];
t.cols -= 1;
syncWorkbook();
markDirty();
}
const MIN_COL_W = 24;
const MIN_ROW_H = 14;
function setColWidth(tableId: string, col: number, width: number) {
const t = tableById(tableId);
if (t) t.colWidths[col] = Math.max(MIN_COL_W, Math.round(width));
if (!t) return;
t.colWidths[col] = Math.max(MIN_COL_W, Math.round(width));
markDirty();
}
/** Height of one row (per-row override, else the table default). */
function rowHeightOf(tableId: string, row: number): number {
@@ -1884,11 +1953,14 @@ function createStore() {
if (!t) return;
if (!t.rowHeights) t.rowHeights = {};
t.rowHeights[row] = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
markDirty();
}
/** Default row height for the whole table. */
function setTableRowHeight(tableId: string, height: number) {
const t = tableById(tableId);
if (t) t.rowHeight = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
if (!t) return;
t.rowHeight = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
markDirty();
}
function pruneEmptyMaps(t: TableObject) {
@@ -1912,6 +1984,7 @@ function createStore() {
clampTableView(t);
clampSelection(tableId);
syncWorkbook();
markDirty();
}
function setTableCols(tableId: string, target: number) {
const t = tableById(tableId);
@@ -1928,6 +2001,7 @@ function createStore() {
clampTableView(t);
clampSelection(tableId);
syncWorkbook();
markDirty();
}
// ---- row/col insert & delete (mid-grid, not just edge growth) ---------
@@ -2045,6 +2119,7 @@ function createStore() {
rewriteDocumentRefs(tableId, 'row', clamped, 1);
clampSelection(tableId);
syncWorkbook();
markDirty();
}
function deleteRow(tableId: string, at: number) {
const t = tableById(tableId);
@@ -2060,6 +2135,7 @@ function createStore() {
clampTableView(t);
clampSelection(tableId);
syncWorkbook();
markDirty();
}
function insertColumn(tableId: string, at: number) {
const t = tableById(tableId);
@@ -2073,6 +2149,7 @@ function createStore() {
rewriteDocumentRefs(tableId, 'col', clamped, 1);
clampSelection(tableId);
syncWorkbook();
markDirty();
}
function deleteColumn(tableId: string, at: number) {
const t = tableById(tableId);
@@ -2088,6 +2165,7 @@ function createStore() {
clampTableView(t);
clampSelection(tableId);
syncWorkbook();
markDirty();
}
// ---- cell formatting (alignment, number format, colours, bold/italic) ---
@@ -2126,6 +2204,7 @@ function createStore() {
}
if (!Object.keys(t.formats).length) delete t.formats;
bump();
markDirty();
}
function clearRangeFormat(tableId: string, a: CellCoord, b: CellCoord) {
const t = tableById(tableId);
@@ -2137,6 +2216,7 @@ function createStore() {
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) delete t.formats[fmtKey(c, r)];
if (!Object.keys(t.formats).length) delete t.formats;
bump();
markDirty();
}
/** Format shared by every cell of the current (possibly multi-) selection. */
const selectionFormat = computed<CellFormat>(() => {
@@ -2210,6 +2290,7 @@ function createStore() {
if (patch.width !== undefined) t.view.width = Math.max(200, Math.round(t.view.width));
if (patch.height !== undefined) t.view.height = Math.max(120, Math.round(t.view.height));
clampTableView(t);
markDirty();
}
// ---- selection --------------------------------------
@@ -2247,6 +2328,7 @@ function createStore() {
const sheetId = sheetIdOfObject(sel.tableId);
if (sheetId) doc.activeSheetId = sheetId;
}
scheduleSave(); // selection only -- never an undo step
}
/** Replace the whole selection with one rectangle (or grow the primary one). */
function selectCell(tableId: string, col: number, row: number, extend = false) {
@@ -2268,6 +2350,7 @@ function createStore() {
// toggle an extra 1x1 range off
ranges.splice(idx, 1);
doc.selection = { ...cur, ranges: ranges.length ? ranges : undefined };
scheduleSave();
return;
}
if (isOneByOne(cur.anchor, cur.focus, col, row) && ranges.length) {
@@ -2279,6 +2362,7 @@ function createStore() {
focus: last.focus,
ranges: ranges.length ? ranges : undefined,
};
scheduleSave();
return;
}
// keep the current primary as an extra range, make the clicked cell primary
@@ -2290,6 +2374,7 @@ function createStore() {
const cur = doc.selection;
if (!cur || cur.tableId !== tableId) return;
doc.selection = { ...cur, focus: { col, row } };
scheduleSave();
}
/** Ctrl/Cmd-click on a column/row header: add that whole strip. */
function addRangeToSelection(tableId: string, a: CellCoord, b: CellCoord) {
@@ -2623,6 +2708,11 @@ function createStore() {
doc.namedRanges = next.namedRanges ?? [];
doc.macros = next.macros ?? [];
syncWorkbook();
// never markDirty(): shared by undo/redo (which reconcile history state
// themselves right after calling this) and replaceDocument (which resets
// it outright via resetHistory()) -- none of the three want this counted
// as a new edit. Persistence, unlike undo history, has no such carve-out.
scheduleSave();
}
/** Loading a different document outright (JSON import "replace", "reset to
* seed") -- unlike undo/redo, this starts a fresh undo history rather
@@ -2644,6 +2734,7 @@ function createStore() {
doc.activeSheetId = id;
}
syncWorkbook();
markDirty();
}
/** Add a new table object built from a cell map (CSV "as new table"). */
function addTableFromCells(
@@ -2675,6 +2766,7 @@ function createStore() {
t.cols = Math.max(cols, 1);
t.colWidths = {};
syncWorkbook();
markDirty();
}
function snapshot(): DocumentState {
return JSON.parse(JSON.stringify(doc)) as DocumentState;