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:
+165
-73
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user