diff --git a/README.md b/README.md index 492dffb..55d8b6d 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,25 @@ wenn nichts zu tun ist); greift auch ohne Tabellenfokus, außer während eine Texteingabe (Zell-Editor, Formelleiste, Blatt-Umbenennen, …) den nativen Undo dieses Feldes benutzen soll. +**Performance bei großen Tabellen**: Undo-Tracking und die (debounced) +`localStorage`-Persistenz werden **nicht** über einen `watch(doc, ..., +{deep:true})` angestoßen — Vues Deep-Watch muss dafür bei *jeder* Mutation den +kompletten reaktiven Objektgraphen erneut durchlaufen, ein Aufwand +proportional zur Gesamtgröße des Dokuments statt zur eigentlichen Änderung +(gemessen: bei einer 50.000-Zeilen-Tabelle kostete ein einzelner +Zellen-Edit dadurch ~450 ms, unabhängig vom Umfang der Änderung). Stattdessen +ruft jede inhaltsverändernde Store-Funktion explizit `markDirty()` auf (nach +demselben Muster wie `bump()`/`recalcTick` für den Engine-Cache) — reine +Auswahl-/Navigations-Funktionen rufen nur `scheduleSave()` (Persistenz ohne +Undo-Schritt). Der Kompromiss: `markDirty()` prüft nicht mehr bei jeder +Mutation per Fingerprint-Vergleich, ob wirklich etwas geändert wurde (genau +dieser Vergleich bei jeder Mutation war der teure Teil) — dieses Sicherheitsnetz +bleibt in `commitHistoryStep()` erhalten, läuft aber nur noch einmal pro +debounced Schritt statt pro Mutation. `setCellInput` hat zusätzlich eine +eigene, günstige No-Op-Prüfung (unverändert übernommener Wert, z. B. `F2`, +`Enter` ohne Änderung), damit der häufigste Fall keinen Schritt erzeugt und +den Wiederholen-Ast nicht verwirft. + **In-Zellen-Editor**: das Eingabefeld liegt als Overlay über der Zelle (nicht in ihr) und wächst mit dem Inhalt — in der Breite bis zu einer angenäherten „sichtbaren Anzeigebreite", danach umbrechend; in der Höhe mit `Alt`+`Enter` diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 208e047..b45de81 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -2241,6 +2241,34 @@ describe('undo / redo', () => { expect(s.cellFormat(id, 0, 0)).toEqual({}); }); + it('re-committing a cell\'s unchanged value is a true no-op -- no step, redo survives', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + vi.advanceTimersByTime(600); // table creation is its own step + setCell(s, name, 'A1', '1'); + vi.advanceTimersByTime(600); + setCell(s, name, 'A1', '2'); + vi.advanceTimersByTime(600); + + s.undo(); // undoes '2' -> back to '1' + expect(s.cellInput(id, 0, 0)).toBe('1'); + expect(s.canRedo.value).toBe(true); + + // F2, Enter without changing anything -- a true no-op, must not clear redo + s.setCellInput(id, 0, 0, '1'); + expect(s.canRedo.value).toBe(true); + s.redo(); + expect(s.cellInput(id, 0, 0)).toBe('2'); + + // clearing an already-empty cell is likewise a no-op -- creates no step + s.setCellInput(id, 5, 5, ''); + vi.advanceTimersByTime(600); + s.undo(); // must undo the '1' -> '2' edit directly, not some no-op clear step + expect(s.cellInput(id, 0, 0)).toBe('1'); + expect(s.cellInput(id, 5, 5)).toBe(''); + }); + it('resetToSeed starts a fresh history -- the prior document is not undoable back', () => { const s = fresh(); const id = s.addTable(s.doc.activeSheetId); diff --git a/src/store/document.ts b/src/store/document.ts index 036087c..f02bc7a 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -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 | 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>) { 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(() => { @@ -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;