/** * Reactive document store (Numbers-style canvas model). * * This is the "own reactive() module" the brief allows instead of Pinia. It * owns the persisted {@link DocumentState} v2 and a single {@link Workbook} * engine instance, and keeps them in sync: * * - cell edits go straight into the engine (`wb.setCell`) for an incremental * recalculation; * - structural edits (add/rename/delete a table, grid growth, ...) rebuild the * engine from the document; * - `recalcTick` is bumped after every recalculation. UI code reads * `store.cellValue(...)`, which touches `recalcTick`, so every formula cell * re-renders exactly like a `computed` would. * * The engine is table-centric: a table object's `name` is a document-unique * qualifier, so `Umsatz!B2` resolves the same from any sheet. */ import { computed, reactive, ref, shallowRef, type Ref } from 'vue'; import { Workbook, toA1, isError, tokenize, looksLikeCell, parseA1, wholeLineAt, formatLinePart, formulaRefSpans, parseFormula, parseFunctionBlock, parseMacroBlock, collectRefsInBlock, hasFunction, collectCalledFunctionNames, collectCalledFunctionNamesInBlock, declaredBlockNames, findInvalidForSource, findInvalidActionTarget, blockAlwaysReturns, buildFunctionIndex, expandMacroBlock, runMacroActions, parseDateTimeLiteral, BUILTIN_SIGNATURES, type CellRef, type CellValue, type TableModel, type Token, type FunctionDef, type NamedRangeDef, type MacroWriteHost, type MacroNetworkHost, type EngineHost, } from '../engine'; import { formatValue, isNumeric, isNumberLocale, DEFAULT_LOCALE, type NumberLocale, } from './format'; import type { ButtonObject, CellCoord, CellFormat, CellNumFmt, CellGraphNode, ChartObject, CustomFunction, DocumentState, FnSuggestion, Macro, NameSuggestion, Sheet, SelectionState, SheetObject, TableObject, TableView, TextObject, } from './types'; import { defaultTableView, DEFAULT_ROW_H, MIN_GRID_SIZE, MAX_GRID_SIZE } from './types'; import { createBlankDocument, createSeedDocument } from '../data/seed'; /** Where the document is persisted. Set by the document library * (src/store/library.ts) to write into IndexedDB; unset (tests, or before * the library has started) nothing is persisted. Called debounced with the * live reactive document -- the handler serialises it itself. */ let persistHandler: ((doc: DocumentState) => void) | null = null; export function setPersistHandler(fn: ((doc: DocumentState) => void) | null) { persistHandler = fn; } /** Live collaboration hooks (src/collab/binding.ts), unset otherwise: * `onChange` fires synchronously on every content or view change (the * binding debounces), `onStructure` right when a row/column is inserted or * deleted mid-table -- the binding needs to know that a whole row moved, * not just that many cells changed. */ export interface CollabHooks { onChange(): void; onStructure(tableId: string, axis: 'row' | 'col', at: number, delta: 1 | -1): void; } let collabHooks: CollabHooks | null = null; export function setCollabHooks(h: CollabHooks | null) { collabHooks = h; } /** Replaces the store's own snapshot undo while a document is edited * together (per-person undo, see src/collab/session.ts). */ export interface UndoProvider { undo(): void; redo(): void; canUndo: Ref; canRedo: Ref; } /** The part of a document that is *content* -- everything but the per-view * state (selection, active sheet/object). Used by undo history to tell an * edit from a mere cursor move, and by the library for the same distinction * when deciding whether a save changed anything other tabs care about. */ export const contentFingerprint = (d: DocumentState) => JSON.stringify({ sheets: d.sheets, locale: d.locale, csvDelimiter: d.csvDelimiter, customFunctions: d.customFunctions, namedRanges: d.namedRanges, macros: d.macros, }); const CASCADE = 28; // px offset for successive inserted objects // ---- (de)serialisation helpers ------------------------------------------ function allTables(doc: DocumentState): TableObject[] { const out: TableObject[] = []; for (const s of doc.sheets) { for (const o of s.objects) if (o.kind === 'table') out.push(o); } return out; } function toTableModels(doc: DocumentState): TableModel[] { return doc.sheets.flatMap((s) => s.objects .filter((o): o is TableObject => o.kind === 'table') .map((t) => ({ id: t.id, name: t.name, sheetName: s.name, cols: t.cols, rows: t.rows, inputs: new Map(Object.entries(t.cells)), })), ); } function toFunctionDefs(doc: DocumentState): FunctionDef[] { return doc.customFunctions.map((f) => ({ id: f.id, name: f.name, params: f.params, body: f.body, kind: f.kind, })); } function toNamedRangeDefs(doc: DocumentState): NamedRangeDef[] { return doc.namedRanges.map((r) => ({ id: r.id, name: r.name, tableId: r.tableId, start: r.start, end: r.end, })); } /** A normalised rectangle (corners in either order) used by selection + clipboard. */ type Rect = { c0: number; c1: number; r0: number; r1: number }; /** One cell of a copy/cut buffer, relative to the buffer's top-left corner. */ interface ClipboardCell { raw: string; format?: CellFormat; } interface ClipboardData { tableId: string; rect: Rect; width: number; height: number; /** "dc,dr" (offset from the buffer's top-left) -> cell. Empty/unformatted cells are absent. */ cells: Map; /** cut (Strg+X) vs. copy (Strg+C) -- a cut buffer is consumed by its first paste. */ cut: boolean; /** the copy/cut selection's own anchor -- the *first* cell picked, which is * not necessarily `rect`'s top-left corner (a drag can start anywhere). * Kept so the source marker can still point it out after the current * selection has moved elsewhere -- see `clipboardRect`. */ anchor: CellCoord; } /** * Whatever's currently editing a formula (the in-cell editor, or the formula * bar) registers one of these so a click on the grid -- possibly in a * *different* table entirely -- can insert a reference into it. `tableId` is * the table the edited cell itself lives in (the "home" table for deciding * whether an inserted reference needs a `Name!` qualifier). */ /** Colours for the references of the formula being edited, in text order * (cycling), same idea as Excel: the n-th reference in the text and its * outline in the grid share a colour. */ const REF_COLORS = ['#2f6fde', '#d6453d', '#8e44ad', '#1e9e5a', '#c77c02', '#0b9bb5', '#d6338a']; /** One reference of the formula being edited, as the grid outlines it. */ export interface FormulaRefHighlight { /** position among the formula's references -- what `moveFormulaRef` takes */ index: number; tableId: string; c0: number; r0: number; c1: number; r1: number; color: string; /** whole column/row: outlined, but not draggable */ whole: boolean; } interface FormulaEditorHandle { tableId: string; getState(): { text: string; start: number; end: number }; replace(start: number, end: number, value: string): void; } /** * Rewrite every cell reference in a formula by (dCol, dRow) -- the same * relative-reference adjustment Excel/Numbers apply on copy. * * A `$`-locked axis (`$A1`, `A$1`, `$A$1`) is exempt from a *copy*'s shift -- * that is the whole point of locking a reference -- but not from a *cut*'s: * a cut moves the referenced cell itself, so any reference to it, `$` or not, * must follow to keep pointing at the same (now relocated) cell. * * `restrict`, when given (a cut/move), narrows the shift to *only* references * that point inside that rectangle -- i.e. cells that moved along with the * formula. A cut otherwise leaves references untouched, since the cells they * point at did not move (and a qualified `Table!A1` reference is never * "inside" a local rectangle, so it is always left alone during a cut). */ function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect): string { if (!raw.startsWith('=') || (dCol === 0 && dRow === 0)) return raw; const body = raw.slice(1); let toks: Token[]; try { toks = tokenize(body); } catch { return raw; // unparsable -- leave as-is, the evaluator will surface #ERR } let out = ''; let last = 0; for (let i = 0; i < toks.length; i++) { const tok = toks[i]; // whole column `B:B` / whole row `1:1`: shifts along its own axis only. // A cut never moves a whole column/row, so it's left alone then. const line = wholeLineAt(toks, i); if (line) { const d = line.axis === 'col' ? dCol : dRow; if (!restrict && d !== 0) { for (const [t, v, abs] of [ [line.a, line.start, line.startAbs], [line.b, line.end, line.endAbs], ] as const) { if (abs) continue; out += body.slice(last, t.pos) + formatLinePart(line.axis, Math.max(0, v + d), abs); last = t.pos + t.value.length; } } i += 2; continue; } if (tok.type !== 'IDENT' || !looksLikeCell(tok.value)) continue; const next = toks[i + 1]; if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell const prev = toks[i - 1]; const qualified = !!prev && (prev.type === 'BANG' || prev.type === 'DBLCOLON'); if (restrict && qualified) continue; // cut: a reference into another table never moved const { col, row, colAbs, rowAbs } = parseA1(tok.value); if ( restrict && !qualified && !(col >= restrict.c0 && col <= restrict.c1 && row >= restrict.r0 && row <= restrict.r1) ) { continue; } const nc = !restrict && colAbs ? col : Math.max(0, col + dCol); const nr = !restrict && rowAbs ? row : Math.max(0, row + dRow); out += body.slice(last, tok.pos) + toA1(nc, nr, colAbs, rowAbs); last = tok.pos + tok.value.length; } out += body.slice(last); return '=' + out; } // ---- row/col insert & delete: document-wide reference rewriting --------- // A mid-grid row/col insert/delete (unlike edge growth/shrink, or copy/paste's // shiftFormula above) can invalidate/relocate references from *any* formula // in the *whole document*, not just the edited table's own -- another table's // `Tabelle!Zelle` qualifier can point straight at it. `rewriteRefsForStructuralChange` // below is the token-level rewriter that does that (same splice technique as // shiftFormula: only the affected cell/range tokens' exact text is touched, // everything else -- including whitespace -- is byte-identical); it's the // engine used by `insertRow`/`deleteRow`/`insertColumn`/`deleteColumn` (in the // store proper, below) via `rewriteDocumentRefs`. type Axis = 'row' | 'col'; /** Insert-side shift for one coordinate: everything at/after `at` moves by * +1. Applied independently to a single cell and to each range endpoint -- * for a range straddling `at` (start < at <= end), only the end moves, so * the range *grows* by one to include the newly inserted row/col (the same * behaviour Excel has for "insert a row inside a SUM range"). */ function insertShift(v: number, at: number): number { return v >= at ? v + 1 : v; } /** Delete-side shift for a range's *start* endpoint: `at` itself stays `at` * (the next surviving row/col slides into that slot); only something * strictly after `at` moves down by one. */ function deleteShiftStart(v: number, at: number): number { return v > at ? v - 1 : v; } /** Delete-side shift for a range's *end* endpoint: unlike the start * endpoint, `at` itself also moves down by one here -- that row/col is * gone, so the range's last surviving row/col is one less than before. */ function deleteShiftEnd(v: number, at: number): number { return v >= at ? v - 1 : v; } function axisValue(axis: Axis, c: { col: number; row: number }): number { return axis === 'row' ? c.row : c.col; } function withAxisValue( axis: Axis, c: { col: number; row: number; colAbs: boolean; rowAbs: boolean }, v: number, ): string { return axis === 'row' ? toA1(c.col, v, c.colAbs, c.rowAbs) : toA1(v, c.row, c.colAbs, c.rowAbs); } /** True iff, with `toks[i]` a known "!" (BANG), the shape ahead is * IDENT/QUOTED "::" -- i.e. this "!" really introduces a SHEET name * followed by a table qualifier (the 3-part `Blatt!Tabelle::Zelle` form), * not a bare (old-style) table qualifier of its own. Exact same lookahead * as `parser.ts#Parser.looksLikeSheetThenTable`. */ function looksLikeSheetThenTable(toks: Token[], i: number): boolean { const n1 = toks[i + 1]; const n2 = toks[i + 2]; return !!n1 && (n1.type === 'IDENT' || n1.type === 'QUOTED') && !!n2 && n2.type === 'DBLCOLON'; } /** * Rewrite every reference to `targetTableId` inside one formula for a * structural row/col insert (`delta` `1`) or delete (`delta` `-1`) at index * `at` on `axis`. `homeTableId` is the formula's *own* table (needed to * resolve a bare, unqualified reference, and -- via a transiently-set * `wb.homeTableId` -- to break a same-table-name-on-two-sheets ambiguity * the exact same way `wb.resolveTableName` already does for evaluation; * this function literally calls it). * * A reference landing exactly on a deleted row/col -- or a range that * collapses entirely (every row/col it covered was the one deleted) -- * becomes a literal `#REF` (see engine/parser.ts's `ErrorLiteralNode`), * the same thing Excel shows inline for a broken reference. A range that * merely *shrinks* (only part of it was the deleted line) stays a valid, * smaller range, exactly like Excel. */ function rewriteRefsForStructuralChange( raw: string, homeTableId: string, targetTableId: string, axis: Axis, at: number, delta: 1 | -1, wb: Workbook, ): string { if (!raw.startsWith('=')) return raw; const body = raw.slice(1); let toks: Token[]; try { toks = tokenize(body); } catch { return raw; // unparsable -- leave as-is, the evaluator already surfaces #ERR } const savedHome = wb.homeTableId; wb.homeTableId = homeTableId; const resolve = (name: string, sheetName: string | null): string | null => wb.resolveTableName(name, sheetName); interface Repl { pos: number; end: number; text: string; } const repls: Repl[] = []; let i = 0; while (i < toks.length && toks[i].type !== 'EOF') { const tok = toks[i]; let cellIdx = -1; let resolvedTableId: string | null = null; if ((tok.type === 'IDENT' || tok.type === 'QUOTED') && toks[i + 1]) { if (toks[i + 1].type === 'BANG' && looksLikeSheetThenTable(toks, i + 1)) { resolvedTableId = resolve(toks[i + 2].value, tok.value); // sheet=tok, table=toks[i+2] cellIdx = i + 4; // consumed: IDENT/QUOTED "!" IDENT/QUOTED "::" } else if (toks[i + 1].type === 'BANG' || toks[i + 1].type === 'DBLCOLON') { resolvedTableId = resolve(tok.value, null); cellIdx = i + 2; } } if (cellIdx === -1 && (tok.type === 'IDENT' || tok.type === 'NUMBER') && wholeLineAt(toks, i)) { resolvedTableId = homeTableId; cellIdx = i; } if (cellIdx === -1 && tok.type === 'IDENT' && looksLikeCell(tok.value)) { resolvedTableId = homeTableId; cellIdx = i; } if (cellIdx === -1) { i++; continue; } // whole column `B:B` / whole row `1:1`: only a change on its own axis // moves it -- same grow/shrink/#REF rules as a range's endpoints const line = wholeLineAt(toks, cellIdx); if (line) { if (resolvedTableId === targetTableId && line.axis === axis) { const lo = Math.min(line.start, line.end); const hi = Math.max(line.start, line.end); const newLo = delta > 0 ? insertShift(lo, at) : deleteShiftStart(lo, at); const newHi = delta > 0 ? insertShift(hi, at) : deleteShiftEnd(hi, at); if (newLo > newHi) { repls.push({ pos: line.a.pos, end: line.b.pos + line.b.value.length, text: '#REF' }); } else { const aNew = line.start === lo ? newLo : newHi; const bNew = line.end === lo ? newLo : newHi; if (aNew !== line.start) { repls.push({ pos: line.a.pos, end: line.a.pos + line.a.value.length, text: formatLinePart(axis, aNew, line.startAbs) }); } if (bNew !== line.end) { repls.push({ pos: line.b.pos, end: line.b.pos + line.b.value.length, text: formatLinePart(axis, bNew, line.endAbs) }); } } } i = cellIdx + 3; continue; } const cellTokA = toks[cellIdx]; if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) { i++; // qualifier not actually followed by a cell -- malformed, leave alone continue; } const isRangeStart = toks[cellIdx + 1]?.type === 'COLON'; const cellTokB = isRangeStart ? toks[cellIdx + 2] : undefined; const validRange = !!cellTokB && cellTokB.type === 'IDENT' && looksLikeCell(cellTokB.value); if (resolvedTableId === targetTableId) { const a = parseA1(cellTokA.value); if (validRange && cellTokB) { const b = parseA1(cellTokB.value); const va = axisValue(axis, a); const vb = axisValue(axis, b); const lo = Math.min(va, vb); const hi = Math.max(va, vb); if (delta > 0) { const newLo = insertShift(lo, at); const newHi = insertShift(hi, at); if (newLo !== lo) { repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, va === lo ? newLo : newHi) }); } if (newHi !== hi) { repls.push({ pos: cellTokB.pos, end: cellTokB.pos + cellTokB.value.length, text: withAxisValue(axis, b, vb === lo ? newLo : newHi) }); } } else { const newLo = deleteShiftStart(lo, at); const newHi = deleteShiftEnd(hi, at); if (newLo > newHi) { repls.push({ pos: cellTokA.pos, end: cellTokB.pos + cellTokB.value.length, text: '#REF' }); } else { if (newLo !== lo) { repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, va === lo ? newLo : newHi) }); } if (newHi !== hi) { repls.push({ pos: cellTokB.pos, end: cellTokB.pos + cellTokB.value.length, text: withAxisValue(axis, b, vb === lo ? newLo : newHi) }); } } } } else { const v = axisValue(axis, a); if (delta > 0) { const nv = insertShift(v, at); if (nv !== v) { repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, nv) }); } } else if (v === at) { repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: '#REF' }); } else if (v > at) { repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, v - 1) }); } } } i = validRange ? cellIdx + 3 : cellIdx + 1; } wb.homeTableId = savedHome; if (repls.length === 0) return raw; repls.sort((x, y) => x.pos - y.pos); let out = ''; let cursor = 0; for (const r of repls) { out += body.slice(cursor, r.pos) + r.text; cursor = r.end; } out += body.slice(cursor); return '=' + out; } /** Where a token ends in the source text. A QUOTED token's `value` is the * unquoted name ('Kosten Q3' -> Kosten Q3, '' -> '), so its source span is * longer than the value by the two quotes plus one per doubled quote. */ function tokenSourceEnd(tok: Token): number { if (tok.type !== 'QUOTED') return tok.pos + tok.value.length; return tok.pos + tok.value.length + 2 + (tok.value.match(/'/g)?.length ?? 0); } /** `'` needs quoting whenever a name isn't a bare identifier (spaces, * punctuation, ...) -- shared by the table- and sheet-qualifier cases. * Module-level (not a store method) since it's pure and both * `rewriteRefsForRename` below and the store's own `qualifyRef` need it. */ function quoteQualifier(name: string): string { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`; } /** * Rewrite every reference to `targetTableId` inside one formula so its * qualifier text uses `newName` instead of whatever name it was written * with -- called when a table is renamed (see `renameObject`), so a * formula elsewhere that still says `AlterName!B2` keeps pointing at the * SAME table instead of becoming `#REF` the next time the workbook * rebuilds ("Referenzen in Formeln müssen immer konsistent sein"). * * Same token-splice technique and qualifier-detection shape as * `rewriteRefsForStructuralChange` above (only the qualifier token's own * text is touched, everything else -- including whitespace and the cell/ * range part -- stays byte-identical), but simpler: no coordinate * shifting, and only the TABLE-name token is ever replaced -- a 3-part * `Blatt!Tabelle::Zelle` form keeps its own sheet qualifier untouched, * only `Tabelle` becomes `newName`. `homeTableId` is the formula's own * table, needed the same way as in the structural-change rewriter, to * resolve a same-name-on-two-sheets ambiguity via the real * `wb.resolveTableName` rules rather than a naive string match. */ function rewriteRefsForRename( raw: string, homeTableId: string, targetTableId: string, newName: string, wb: Workbook, ): string { if (!raw.startsWith('=')) return raw; const body = raw.slice(1); let toks: Token[]; try { toks = tokenize(body); } catch { return raw; // unparsable -- leave as-is, the evaluator already surfaces #ERR } const savedHome = wb.homeTableId; wb.homeTableId = homeTableId; const resolve = (name: string, sheetName: string | null): string | null => wb.resolveTableName(name, sheetName); interface Repl { pos: number; end: number; text: string; } const repls: Repl[] = []; let i = 0; while (i < toks.length && toks[i].type !== 'EOF') { const tok = toks[i]; let cellIdx = -1; let resolvedTableId: string | null = null; let qualTok: Token | null = null; if ((tok.type === 'IDENT' || tok.type === 'QUOTED') && toks[i + 1]) { if (toks[i + 1].type === 'BANG' && looksLikeSheetThenTable(toks, i + 1)) { qualTok = toks[i + 2]; // the TABLE token of the 3-part form -- toks[i]/tok is the sheet, left alone resolvedTableId = resolve(qualTok.value, tok.value); cellIdx = i + 4; // consumed: IDENT/QUOTED "!" IDENT/QUOTED "::" } else if (toks[i + 1].type === 'BANG' || toks[i + 1].type === 'DBLCOLON') { qualTok = tok; resolvedTableId = resolve(tok.value, null); cellIdx = i + 2; } } if (cellIdx === -1) { i++; // a bare, unqualified cell never needs rewriting on rename -- it's already "this table" continue; } if (wholeLineAt(toks, cellIdx)) { // `Tabelle!B:B` / `Tabelle::1:1` -- the qualifier renames the same way if (resolvedTableId === targetTableId && qualTok) { repls.push({ pos: qualTok.pos, end: tokenSourceEnd(qualTok), text: quoteQualifier(newName) }); } i = cellIdx + 3; continue; } const cellTokA = toks[cellIdx]; if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) { i++; // qualifier not actually followed by a cell -- malformed, leave alone continue; } const isRangeStart = toks[cellIdx + 1]?.type === 'COLON'; const cellTokB = isRangeStart ? toks[cellIdx + 2] : undefined; const validRange = !!cellTokB && cellTokB.type === 'IDENT' && looksLikeCell(cellTokB.value); if (resolvedTableId === targetTableId && qualTok) { repls.push({ pos: qualTok.pos, end: tokenSourceEnd(qualTok), text: quoteQualifier(newName) }); } i = validRange ? cellIdx + 3 : cellIdx + 1; } wb.homeTableId = savedHome; if (repls.length === 0) return raw; repls.sort((x, y) => x.pos - y.pos); let out = ''; let cursor = 0; for (const r of repls) { out += body.slice(cursor, r.pos) + r.text; cursor = r.end; } out += body.slice(cursor); return '=' + out; } /** A blank document until the library (src/store/library.ts) loads the real * one from IndexedDB via replaceDocument() -- see main.ts. */ function loadInitial(): DocumentState { return createBlankDocument(); } // ---- store singleton -------------------------------------------------- function createStore() { const doc = reactive(loadInitial()); let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc)); const recalcTick = ref(0); const bump = () => (recalcTick.value += 1); /** View-only state: not part of the document, not persisted. */ const ui = reactive({ settingsOpen: false, /** The dependency-graph drawer's centered cell, or `null` when closed * -- a single document-wide panel (like `settingsOpen`), not owned by * any one `TableGrid` instance, so navigating across tables/sheets * inside it doesn't depend on which grid happened to open it. */ depGraph: null as { tableId: string; col: number; row: number } | null, /** Why the open document can't be changed right now (shown to the user), * or `null` when it can. Set by the library for shared server documents * (view-only access, or someone else is editing). While set, every * content-changing store function is a no-op -- see READ_ONLY_GUARDED. */ readOnly: null as string | null, }); /** Copy/cut buffer -- not part of the persisted document. */ const clipboard = ref(null); /** Whichever formula editor is currently open (in-cell editor or formula * bar), if any -- drives "point mode": a grid click inserts a reference * instead of changing the selection. Not part of the document either. */ const formulaEditor = ref(null); /** The most recently inserted-by-click reference's span in the editor's * text, so the *next* click replaces it instead of appending another -- * until the user types something (a plain assignment, not a ref(), so it * never itself triggers reactivity/undo history). */ let pointSpan: { start: number; end: number } | null = null; /** The cell/range the last point-mode click, drag or arrow key picked, so * the grid can outline it while it is still the reference being chosen * (otherwise picking with the keyboard happens completely blind). Lives * exactly as long as `pointSpan`: typing, a caret click or closing the * editor ends it. View state only, never synced or saved. */ const pointRect = ref<{ tableId: string; c0: number; r0: number; c1: number; r1: number } | null>(null); const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20)); function syncWorkbook() { wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc)); bump(); } // ---- 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; function scheduleSave() { clearTimeout(persistTimer); persistTimer = setTimeout(flushSave, 250); if (!applyingExternal) collabHooks?.onChange(); } /** true while a collaborator's change is being applied -- not a local edit */ let applyingExternal = false; /** Write a pending save right now (page hidden, switching documents). */ function flushSave() { if (persistTimer === undefined) return; clearTimeout(persistTimer); persistTimer = undefined; persistHandler?.(doc); } /** A change made here that hasn't been handed to persistence yet. */ const hasPendingSave = () => persistTimer !== undefined; /** Drop a pending save -- the document was just loaded from where it * would be saved to. */ function cancelSave() { clearTimeout(persistTimer); persistTimer = undefined; } // ---- 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[] = []; let historySnapshot = snapshot(); let historyFingerprint = contentFingerprint(historySnapshot); let historyTimer: ReturnType | 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 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, `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 // run's awaits took. let macroRunActive = 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++; } /** * 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 (undoProvider.value) return; // shared document: undo lives in Yjs 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()), * 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 undoProvider = shallowRef(null); function setUndoProvider(p: UndoProvider | null) { undoProvider.value = p; resetHistory(); } const canUndo = computed(() => undoProvider.value ? undoProvider.value.canUndo.value : (historyTick.value, undoStack.length > 0 || historyPending.value), ); const canRedo = computed(() => undoProvider.value ? undoProvider.value.canRedo.value : (historyTick.value, redoStack.length > 0), ); function undo() { if (undoProvider.value) return undoProvider.value.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(); 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; historyTick.value++; } function redo() { if (undoProvider.value) return undoProvider.value.redo(); clearTimeout(historyTimer); const next = redoStack.pop(); if (!next) return; undoStack.push(snapshot()); if (undoStack.length > MAX_HISTORY) undoStack.shift(); applyDocumentState(next); // schedules a save itself; never calls markDirty historySnapshot = snapshot(); historyFingerprint = contentFingerprint(doc); historyPending.value = false; historyTick.value++; } // ---- id / name helpers ------------------------------------------ let seq = 0; // random part: ids must not collide between people editing the same document const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}${Math.random().toString(36).slice(2, 6)}`; function uniqueName(base: string, taken: Set): string { const b = base.trim() || 'Element'; if (!taken.has(b.toLowerCase())) return b; let i = 2; while (taken.has(`${b} ${i}`.toLowerCase())) i++; return `${b} ${i}`; } const objectNames = () => new Set(doc.sheets.flatMap((s) => s.objects).map((o) => o.name.toLowerCase())); const sheetNames = () => new Set(doc.sheets.map((s) => s.name.toLowerCase())); /** Table names are unique *per sheet* only -- two different sheets may * each hold a table called "Umsatz" (see engine/types.ts's EngineHost * doc comment for how a formula then resolves it). */ const tableNamesOnSheet = (sheetId: string) => new Set(tablesOf(sheetId).map((t) => t.name.toLowerCase())); const objectNamesOnSheet = (sheetId: string) => new Set((sheetById(sheetId)?.objects ?? []).map((o) => o.name.toLowerCase())); // ---- lookups -------------------------------------------------- const sheetById = (id: string): Sheet | undefined => doc.sheets.find((s) => s.id === id); /** Case-insensitive/trimmed lookup by *name* -- unlike `sheetById`, needed * for a macro's plain-string sheet references (`ADDSHEET`, `RENAMESHEET`, * `DELETESHEET`; see `runMacro` below), which address a sheet the same * way a formula's own qualifier addresses a table: by name, not id. */ const sheetByName = (name: string): Sheet | undefined => doc.sheets.find((s) => s.name.trim().toLowerCase() === name.trim().toLowerCase()); const activeSheet = computed(() => sheetById(doc.activeSheetId) ?? doc.sheets[0]); function objectById(id: string | null): SheetObject | undefined { if (!id) return undefined; for (const s of doc.sheets) { const o = s.objects.find((x) => x.id === id); if (o) return o; } return undefined; } function sheetIdOfObject(id: string): string | undefined { return doc.sheets.find((s) => s.objects.some((o) => o.id === id))?.id; } const tableById = (id: string): TableObject | undefined => { const o = objectById(id); return o && o.kind === 'table' ? o : undefined; }; const tablesOf = (sheetId: string): TableObject[] => (sheetById(sheetId)?.objects.filter((o): o is TableObject => o.kind === 'table') ?? []); const activeObject = computed(() => objectById(doc.activeObjectId)); const activeTable = computed(() => { if (doc.selection) return tableById(doc.selection.tableId); return tablesOf(doc.activeSheetId)[0]; }); // ---- sheet structure ----------------------------------------- function setActiveSheet(id: string) { if (!sheetById(id) || id === doc.activeSheetId) return; doc.activeSheetId = id; doc.activeObjectId = null; const first = tablesOf(id)[0]; 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) { const s = sheetById(id); const clean = name.trim(); 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; const idx = doc.sheets.findIndex((s) => s.id === id); if (idx < 0) return; const hadTables = tablesOf(id).length > 0; 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 ----------------------------- function setSheetBackground(sheetId: string, color: string | null) { const s = sheetById(sheetId); 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) return; s.showGrid = show; markDirty(); } function setSheetGridSize(sheetId: string, size: number) { const s = sheetById(sheetId); if (!s) return; s.gridSize = Math.round(Math.min(MAX_GRID_SIZE, Math.max(MIN_GRID_SIZE, size))); markDirty(); } function setSheetZoom(sheetId: string, z: number) { const s = sheetById(sheetId); 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); const setZoom = (z: number) => setSheetZoom(doc.activeSheetId, z); // ---- per-object background + text colour (table / chart / text) ---- function setObjectBg(objId: string, color: string | null) { const o = objectById(objId); if (!o) return; 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); if (!o) return; if (o.kind === 'text') { if (color) o.color = color; else delete o.color; bump(); } else { if (color) o.fg = color; else delete o.fg; if (o.kind === 'chart') bump(); } markDirty(); } function setTableHideChromeUnfocused(tableId: string, hide: boolean) { const t = tableById(tableId); if (!t) return; if (hide) t.hideChromeUnfocused = true; else delete t.hideChromeUnfocused; markDirty(); } function setCsvDelimiter(d: ',' | ';') { doc.csvDelimiter = d === ';' ? ';' : ','; markDirty(); } // ---- canvas objects ---------------------------------------- function nextCascade(sheet: Sheet): { x: number; y: number } { const n = sheet.objects.length; return { x: 40 + (n % 6) * CASCADE, y: 40 + (n % 6) * CASCADE }; } function addTable(sheetId: string, pos?: { x: number; y: number }): string { const sheet = sheetById(sheetId); if (!sheet) return ''; const id = uid('tbl'); const p = pos ?? nextCascade(sheet); const table: TableObject = { kind: 'table', id, name: uniqueName('Tabelle', new Set([...tableNamesOnSheet(sheetId), ...objectNamesOnSheet(sheetId)])), x: Math.max(0, Math.round(p.x)), y: Math.max(0, Math.round(p.y)), rows: 12, cols: 6, colWidths: {}, cells: {}, view: defaultTableView(), }; sheet.objects.push(table); doc.activeObjectId = id; doc.activeSheetId = sheetId; doc.selection = { tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } }; syncWorkbook(); markDirty(); return id; } function addChart( sheetId: string, pos?: { x: number; y: number }, sourceTableId?: string | null, ): string { const sheet = sheetById(sheetId); if (!sheet) return ''; const id = uid('cht'); const p = pos ?? nextCascade(sheet); const src = sourceTableId ?? tablesOf(sheetId)[0]?.id ?? allTables(doc)[0]?.id ?? null; const chart: ChartObject = { kind: 'chart', id, name: uniqueName('Diagramm', objectNames()), x: Math.max(0, Math.round(p.x)), y: Math.max(0, Math.round(p.y)), width: 460, height: 300, view: 'bar', sourceTableId: src, }; sheet.objects.push(chart); doc.activeObjectId = id; doc.activeSheetId = sheetId; bump(); markDirty(); return id; } function addText(sheetId: string, pos?: { x: number; y: number }): string { const sheet = sheetById(sheetId); if (!sheet) return ''; const id = uid('txt'); const p = pos ?? nextCascade(sheet); const text: TextObject = { kind: 'text', id, name: uniqueName('Text', objectNames()), x: Math.max(0, Math.round(p.x)), y: Math.max(0, Math.round(p.y)), width: 260, height: 96, text: 'Text …', fontSize: 15, align: 'left', }; sheet.objects.push(text); doc.activeObjectId = id; doc.activeSheetId = sheetId; bump(); markDirty(); return id; } function setTextContent(objId: string, text: string) { const o = objectById(objId); if (o?.kind !== 'text') return; o.text = text; markDirty(); } function setTextFormat(objId: string, patch: Partial>) { const o = objectById(objId); if (o?.kind !== 'text') return; if (patch.fontSize !== undefined) o.fontSize = Math.min(96, Math.max(9, Math.round(patch.fontSize))); if (patch.align !== undefined) o.align = patch.align; if (patch.bold !== undefined) o.bold = patch.bold || undefined; 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) --------------- function addButton(sheetId: string, pos?: { x: number; y: number }): string { const sheet = sheetById(sheetId); if (!sheet) return ''; const id = uid('btn'); const p = pos ?? nextCascade(sheet); const button: ButtonObject = { kind: 'button', id, name: uniqueName('Button', objectNames()), x: Math.max(0, Math.round(p.x)), y: Math.max(0, Math.round(p.y)), width: 140, height: 44, label: 'Button', macroId: null, }; sheet.objects.push(button); doc.activeObjectId = id; doc.activeSheetId = sheetId; bump(); markDirty(); return id; } function setButtonLabel(objId: string, label: string) { const o = objectById(objId); if (o?.kind !== 'button') return; o.label = label; markDirty(); } function setButtonMacroId(objId: string, macroId: string | null) { const o = objectById(objId); 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) { const o = objectById(objId); const clean = name.trim(); if (!o || !clean) return; if (o.kind === 'table') { // uniqueness is per sheet only -- see engine/types.ts's EngineHost const sheetId = sheetIdOfObject(objId); const others = new Set( (sheetId ? tablesOf(sheetId) : allTables(doc)) .filter((t) => t.id !== objId) .map((t) => t.name.toLowerCase()), ); const newName = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean; // Rewrite every OTHER formula's qualifier BEFORE renaming -- wb still // resolves the old name at this point, and rewriteRefsForRename needs // that to identify which references actually point at this table. // "Referenzen in Formeln müssen immer konsistent sein" (user request): // without this, a formula elsewhere still saying the old name would // silently become #REF the moment the workbook rebuilds below. if (newName !== o.name) renameDocumentRefs(objId, newName); o.name = newName; syncWorkbook(); // formulas referencing the old/new name must re-resolve } else { o.name = clean; } markDirty(); } function deleteObject(objId: string) { const sheetId = sheetIdOfObject(objId); const sheet = sheetId ? sheetById(sheetId) : undefined; if (!sheet) return; const o = sheet.objects.find((x) => x.id === objId); const isTable = o?.kind === 'table'; sheet.objects = sheet.objects.filter((x) => x.id !== objId); if (doc.activeObjectId === objId) doc.activeObjectId = null; if (doc.selection?.tableId === objId) doc.selection = null; // 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) ---------------- // Created/edited/removed exclusively through the "𝑓 Funktionen" UI. A call // is inlined into the caller's AST at parse time (engine/userFunctions.ts) // -- renaming/deleting one behaves exactly like renaming/deleting a table: // existing formula text is left as-is and becomes #NAME? / #REF on the next // syncWorkbook(), there is no find-and-replace across stored formulas. const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const customFunctionNames = () => new Set(doc.customFunctions.map((f) => f.name.toLowerCase())); /** Every (upper-cased) function name a stored custom function's body calls, * tolerant of a body that -- in theory -- no longer parses (shouldn't * happen; a save always validates first, but this is only used for a * best-effort cycle check, so fail open rather than block unrelated saves). */ function bodyCallSet(f: Pick): Set { try { return f.kind === 'block' ? collectCalledFunctionNamesInBlock(parseFunctionBlock(f.body, f.params)) : collectCalledFunctionNames(parseFormula(f.body, f.params)); } catch { return new Set(); } } /** Does the call graph across every custom function (built-ins are never * nodes -- they can't call back into user functions) contain a cycle? */ function callGraphHasCycle(graph: Map>): boolean { const state = new Map(); // 1 = on the current DFS path, 2 = fully explored function dfs(node: string): boolean { const st = state.get(node); if (st === 1) return true; // back-edge -> cycle if (st === 2) return false; state.set(node, 1); for (const callee of graph.get(node) ?? []) { if (graph.has(callee) && dfs(callee)) return true; } state.set(node, 2); return false; } for (const node of graph.keys()) { if (dfs(node)) return true; } return false; } /** FOR/IN/DO/RETURN double as block-body keywords (see parser.ts's * parseFunctionBlock) -- never usable as a parameter/local name, in * either kind of function (so a name stays valid if the function is * later switched to "Mit Schleife"). */ const RESERVED_NAMES = new Set(['FOR', 'IN', 'DO', 'RETURN']); /** The macro write-action vocabulary (see engine/types.ts's * `MacroWriteHost` and evaluator.ts's `execAction`) -- reserved against * use as a macro-local variable/loop-variable name, same reasoning as * `RESERVED_NAMES`: `SETVALUE = 5` would parse under the grammar (an * ordinary assignment, no ambiguity) but reads as if it might be calling * the action, so it's rejected at save time rather than left as a * confusing-but-technically-legal trap. */ const MACRO_ACTION_NAMES = new Set([ 'SETVALUE', 'INSERTROW', 'DELETEROW', 'INSERTCOL', 'DELETECOL', 'ADDTABLE', 'RENAMETABLE', 'DELETETABLE', 'ADDSHEET', 'RENAMESHEET', 'DELETESHEET', ]); /** GET/POST/JSONGET (see evaluator.ts's `execStatementAsync`) -- reserved * against use as a macro-local variable name, same reasoning as * `MACRO_ACTION_NAMES`. Also used by `validateCustomFunction` to reject * these three names as unreachable outside a macro (see there). */ const MACRO_NETWORK_NAMES = new Set(['GET', 'POST', 'JSONGET']); /** * Full validation for add/update: name & parameter shape, no collision with * a built-in or another custom function, the body parses (as an * expression, or -- for `kind: 'block'` -- as a FOR/DO/RETURN statement * sequence, with every local/loop-variable name validated the same way as * a parameter and every FOR source structurally a parameter/cell/range), * and -- simulating the *whole* call graph with this function's (possibly * new) definition in place -- no cycle. Returns a user-facing (German) * error string, or null when the definition is safe to save. `ignoreId` * excludes the function being edited from the collision/graph checks * against its own old state. */ function validateCustomFunction( name: string, params: string[], body: string, kind: CustomFunction['kind'], ignoreId: string | null, ): string | null { const cleanName = name.trim(); if (!IDENT_RE.test(cleanName)) { return `"${name}" ist kein gültiger Funktionsname.`; } if (looksLikeCell(cleanName)) { return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Funktionsname nicht erlaubt.`; } if (hasFunction(cleanName)) { return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`; } const collision = doc.customFunctions.find( (f) => f.id !== ignoreId && f.name.toLowerCase() === cleanName.toLowerCase(), ); if (collision) return `Es gibt bereits eine Funktion namens "${collision.name}".`; const cleanParams = params.map((p) => p.trim()); for (const p of cleanParams) { if (!IDENT_RE.test(p)) return `"${p}" ist kein gültiger Parametername.`; if (looksLikeCell(p)) return `Parameter "${p}" sieht wie ein Zellbezug aus.`; if (RESERVED_NAMES.has(p.toUpperCase())) { return `"${p}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Parametername verwendet werden.`; } } if (new Set(cleanParams.map((p) => p.toLowerCase())).size !== cleanParams.length) { return 'Parameternamen müssen eindeutig sein.'; } let ownCalls: Set; if (kind === 'block') { let block; try { block = parseFunctionBlock(body, cleanParams); } catch (e) { return `Code ungültig: ${e instanceof Error ? e.message : String(e)}`; } // declaredBlockNames() lists every assignment target/loop variable, but // the SAME name legitimately reappears across statements (that's the // whole point of an accumulator: `ergebnis = 1`, then later `FOR x IN // bereich DO ergebnis = ergebnis * x` re-assigns "ergebnis", it doesn't // declare a second one) -- so shape/reserved-word checks run on every // occurrence (harmless to repeat), but the "no collision" check only // looks at each *distinct* local name against the parameters, never // against other locals (re-using a local's own name is exactly the // point; two locals can even share a name across two separate loops). const localNames = declaredBlockNames(block); for (const n of localNames) { if (!IDENT_RE.test(n)) return `"${n}" ist kein gültiger Name.`; if (looksLikeCell(n)) return `"${n}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`; if (RESERVED_NAMES.has(n.toUpperCase())) { return `"${n}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`; } } const paramSet = new Set(cleanParams.map((p) => p.toLowerCase())); for (const n of new Set(localNames.map((x) => x.toLowerCase()))) { if (paramSet.has(n)) return `"${n}" ist bereits ein Parametername.`; } const badFor = findInvalidForSource(block); if (badFor) { return `FOR ${badFor.varName} IN ...: die Quelle muss ein Parameter, eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`; } // conservative static check (see blockAlwaysReturns's own doc comment) // -- a real "no RETURN reached" (e.g. an empty FOR range) still // degrades to #ERR at the affected cell regardless, this just catches // the common case immediately, before ever saving. if (!blockAlwaysReturns(block)) { return 'Nicht jeder Codepfad endet mit RETURN.'; } ownCalls = collectCalledFunctionNamesInBlock(block); } else { let ast; try { ast = parseFormula(body, cleanParams); } catch (e) { return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`; } ownCalls = collectCalledFunctionNames(ast); } // GET/POST/JSONGET are never registered as built-ins (see runMacro's // MACRO_NETWORK_NAMES/execStatementAsync in evaluator.ts) -- calling // one here would already just become #NAME? at evaluation time, same // as any other unknown function. This check exists only to catch it // immediately with a clear message, mirroring how the 11 write actions // aren't separately validated here either (parser-level containment // already makes ActionNode unreachable from a function body) -- for // GET/POST/JSONGET there's no parser-level gate (they're ordinary // FuncNode calls), so a save-time name check is the cheap equivalent. for (const bad of MACRO_NETWORK_NAMES) { if (ownCalls.has(bad)) { return `"${bad}" ist nur in Makros erlaubt, nicht in benutzerdefinierten Funktionen.`; } } const graph = new Map>(); for (const f of doc.customFunctions) { if (f.id === ignoreId) continue; graph.set(f.name.toUpperCase(), bodyCallSet(f)); } graph.set(cleanName.toUpperCase(), ownCalls); if (callGraphHasCycle(graph)) { return `"${cleanName}" führt (direkt oder über andere Funktionen) zu einem Zyklus.`; } return null; } function addCustomFunction( name: string, params: string[], body: string, kind: CustomFunction['kind'] = 'expression', ): string | { error: string } { const error = validateCustomFunction(name, params, body, kind, null); if (error) return { error }; const id = uid('fn'); const custom: CustomFunction = { id, name: name.trim(), params: params.map((p) => p.trim()), body: body.startsWith('=') ? body.slice(1) : body, kind, }; doc.customFunctions.push(custom); syncWorkbook(); markDirty(); return id; } function updateCustomFunction( id: string, name: string, params: string[], body: string, kind: CustomFunction['kind'] = 'expression', ): void | { error: string } { const f = doc.customFunctions.find((x) => x.id === id); if (!f) return { error: 'Funktion nicht gefunden.' }; const error = validateCustomFunction(name, params, body, kind, id); if (error) return { error }; f.name = name.trim(); f.params = params.map((p) => p.trim()); 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) ------- // Created/edited/removed exclusively through the "🏷 Namen" UI. Unlike a // custom function there is no expression of its own -- just fixed // coordinates on a specific table -- so there's nothing that could // cyclically reference itself, and no "does every path RETURN" check // either. `tableId` (not the table's name) is what's stored, so a named // range survives its target table being *renamed* (see // engine/workbook.ts's `resolveNamedRange`); only an outright *deletion* // of that table leaves it dangling -> #NAME? on the next rebuild, same // "nothing is auto-rewritten" convention as a plain formula reference to a // deleted table. function validateNamedRange( name: string, tableId: string, start: CellCoord, end: CellCoord, ignoreId: string | null, ): string | null { const cleanName = name.trim(); if (!IDENT_RE.test(cleanName)) { return `"${name}" ist kein gültiger Name.`; } if (looksLikeCell(cleanName)) { return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`; } if (RESERVED_NAMES.has(cleanName.toUpperCase())) { return `"${cleanName}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`; } if (hasFunction(cleanName)) { return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`; } const fnCollision = doc.customFunctions.find( (f) => f.name.toLowerCase() === cleanName.toLowerCase(), ); if (fnCollision) return `Es gibt bereits eine Funktion namens "${fnCollision.name}".`; const rangeCollision = doc.namedRanges.find( (r) => r.id !== ignoreId && r.name.toLowerCase() === cleanName.toLowerCase(), ); if (rangeCollision) return `Es gibt bereits einen Namen "${rangeCollision.name}".`; if (!tableById(tableId)) return 'Tabelle nicht gefunden.'; if (start.col < 0 || start.row < 0 || end.col < 0 || end.row < 0) { return 'Ungültiger Bereich.'; } return null; } function addNamedRange( name: string, tableId: string, start: CellCoord, end: CellCoord, ): string | { error: string } { const error = validateNamedRange(name, tableId, start, end, null); if (error) return { error }; const id = uid('nr'); doc.namedRanges.push({ id, name: name.trim(), tableId, start: { ...start }, end: { ...end } }); syncWorkbook(); markDirty(); return id; } function updateNamedRange( id: string, name: string, tableId: string, start: CellCoord, end: CellCoord, ): void | { error: string } { const r = doc.namedRanges.find((x) => x.id === id); if (!r) return { error: 'Name nicht gefunden.' }; const error = validateNamedRange(name, tableId, start, end, id); if (error) return { error }; r.name = name.trim(); r.tableId = tableId; 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) ------ // Created/edited/removed exclusively through the "▶ Makros" UI, run via // that panel's own "▶ Ausführen" button or a canvas button object's click // -- NEVER callable from a cell formula (see store/types.ts's `Macro` doc // comment for why this is a separate type from `CustomFunction`, not a // third `kind`, so that's impossible by construction). Unlike a custom // function's body, every cell/range reference in a macro must be // qualified with a table name (`Umsatz::A1`, never bare `A1`) -- a macro // has no "home table" the way a formula cell does. function validateMacro(name: string, body: string, ignoreId: string | null): string | null { const cleanName = name.trim(); if (!IDENT_RE.test(cleanName)) { return `"${name}" ist kein gültiger Makroname.`; } if (looksLikeCell(cleanName)) { return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Makroname nicht erlaubt.`; } const collision = doc.macros.find( (m) => m.id !== ignoreId && m.name.toLowerCase() === cleanName.toLowerCase(), ); if (collision) return `Es gibt bereits ein Makro namens "${collision.name}".`; let block; try { block = parseMacroBlock(body); } catch (e) { return `Makro ungültig: ${e instanceof Error ? e.message : String(e)}`; } const localNames = declaredBlockNames(block); for (const n of localNames) { if (!IDENT_RE.test(n)) return `"${n}" ist kein gültiger Name.`; if (looksLikeCell(n)) return `"${n}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`; if (RESERVED_NAMES.has(n.toUpperCase())) { return `"${n}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`; } if (MACRO_ACTION_NAMES.has(n.toUpperCase())) { return `"${n}" ist der Name einer Makro-Aktion und kann nicht als Name verwendet werden.`; } if (MACRO_NETWORK_NAMES.has(n.toUpperCase())) { return `"${n}" ist der Name einer Netzwerk-Funktion und kann nicht als Name verwendet werden.`; } } const badFor = findInvalidForSource(block); if (badFor) { return `FOR ${badFor.varName} IN ...: die Quelle muss eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`; } const badAction = findInvalidActionTarget(block); if (badAction) { return `${badAction.name}: das erste Argument muss ein Zell- oder Bereichsbezug sein, kein berechneter Ausdruck.`; } for (const ref of collectRefsInBlock(block)) { if ((ref.kind === 'cell' || ref.kind === 'range') && ref.qualifier === null) { return 'Zellbezüge in Makros müssen die Tabelle nennen, z. B. Umsatz::A1.'; } } return null; } function addMacro(name: string, body: string): string | { error: string } { const error = validateMacro(name, body, null); if (error) return { error }; const id = uid('mac'); const macro: Macro = { id, name: name.trim(), body: body.startsWith('=') ? body.slice(1) : body }; doc.macros.push(macro); bump(); markDirty(); return id; } function updateMacro(id: string, name: string, body: string): void | { error: string } { const m = doc.macros.find((x) => x.id === id); if (!m) return { error: 'Makro nicht gefunden.' }; const error = validateMacro(name, body, id); if (error) return { error }; 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 * argument the same way a formula's own qualifier resolves -- via the * live `wb.resolveTableName`. Reads the outer (closure-scoped) `wb` * directly, so it always sees whatever `syncWorkbook()` most recently * assigned -- critical when an earlier action in the SAME macro run was * structural (see `runMacro`'s own doc comment). Throws a clear error * when the table doesn't exist, instead of the primitives' own silent * no-op on an unknown id. */ function resolveMacroTableRef(ref: string): string { const bang = ref.indexOf('!'); const table = bang === -1 ? ref : ref.slice(bang + 1); const sheet = bang === -1 ? null : ref.slice(0, bang); const tableId = wb.resolveTableName(table, sheet); if (!tableId) throw new Error(`unbekannte Tabelle '${ref}'`); return tableId; } /** * Runs a macro to completion (see store/types.ts's `Macro` doc comment * for the "why explicit trigger, not recalc-embedded" design). `async` * because a `GET`/`POST` genuinely waits on a real network round-trip -- * that costs the automatic "one JS turn = one undo step" property a * fully synchronous run would otherwise get for free, so this function * restores it explicitly instead (`macroRunActive` suppresses the * debounced auto-commit for the run's whole duration; the `finally` * below commits exactly once, however long the run's `await`s took -- * see `macroRunActive`'s own comment near the history watcher). * * All three hosts handed to the interpreter re-read the outer `wb`/`doc` * on every call rather than capturing anything once at the top -- * required correctness, not a style choice: a structural action * (INSERTROW, ADDTABLE, ...) calls `syncWorkbook()`, which reassigns * `wb` to a brand-new `Workbook` instance; a cached reference from * before that point would silently go stale for every read for the * rest of the run. */ async function runMacro(macroId: string): Promise<{ ok: boolean; message: string }> { const macro = doc.macros.find((m) => m.id === macroId); if (!macro) return { ok: false, message: 'Makro nicht gefunden.' }; // addTable/addSheet/... are UI-oriented primitives that also navigate // the user to what they just inserted -- exactly right for a toolbar // click, but a surprising side effect for a macro run in the // background (e.g. a button click on "Analyse" silently switching the // view to some other sheet an ADDTABLE deep in the script happened to // touch). Found live: running a macro containing ADDTABLE("Blatt 2"; // ...) yanked the active sheet to "Blatt 2". Snapshotting + restoring // the view state (unless the macro deleted it out from under us) keeps // the mutation but undoes only its incidental navigation. const savedSheetId = doc.activeSheetId; const savedObjectId = doc.activeObjectId; const savedSelection = doc.selection ? { ...doc.selection } : null; const restoreView = () => { if (sheetById(savedSheetId)) doc.activeSheetId = savedSheetId; doc.activeObjectId = savedObjectId && objectById(savedObjectId) ? savedObjectId : null; doc.selection = savedSelection && objectById(savedSelection.tableId) ? savedSelection : null; }; const macroHost: EngineHost = { // Never actually consulted: validateMacro rejects every unqualified // cell/range reference at save time, so evalScalar's 'cell' case // never needs "this table" for a macro. A getter (not a fixed empty // string) purely so this object has the exact same "always reads // fresh" shape as every other field here. get homeTableId() { return ''; }, resolveTableName: (name, sheetName) => wb.resolveTableName(name, sheetName ?? null), resolveNamedRange: (name) => wb.resolveNamedRange(name), tableSize: (id) => wb.tableSize(id), getComputed: (tableId, col, row) => wb.getComputed(tableId, col, row), }; const writeHost: MacroWriteHost = { setValue(cells, text) { for (const c of cells) setCellInput(c.tableId, c.col, c.row, text); }, insertRow(tableRef, at) { insertRow(resolveMacroTableRef(tableRef), at); }, deleteRow(tableRef, at) { deleteRow(resolveMacroTableRef(tableRef), at); }, insertCol(tableRef, at) { insertColumn(resolveMacroTableRef(tableRef), at); }, deleteCol(tableRef, at) { deleteColumn(resolveMacroTableRef(tableRef), at); }, addTable(sheetRef, name) { const sheet = sheetByName(sheetRef); if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`); renameObject(addTable(sheet.id), name); }, renameTable(tableRef, newName) { renameObject(resolveMacroTableRef(tableRef), newName); }, deleteTable(tableRef) { deleteObject(resolveMacroTableRef(tableRef)); }, addSheet(name) { addSheet(name); }, renameSheet(sheetRef, newName) { const sheet = sheetByName(sheetRef); if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`); renameSheet(sheet.id, newName); }, deleteSheet(sheetRef) { const sheet = sheetByName(sheetRef); if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`); // deleteSheet() itself silently no-ops on the last sheet -- surface // that as a real failure here instead of a misleading success toast. if (doc.sheets.length <= 1) throw new Error('das letzte Blatt kann nicht gelöscht werden'); deleteSheet(sheet.id); }, }; // GET/POST confirmation + fetch. Per-MACRO approval (not per-document // and not per-session): an imported document with several small macros // quietly contacting the same host should not have approving one of // them silently authorize all the others too -- each macro earns its // own one-time human decision the first time IT reaches a given host, // persisted in `macro.approvedHosts` so it only ever asks once. The // host (via `new URL(url).host`, i.e. including a non-default port) is // recorded BEFORE the fetch itself, so the approval survives even if // this particular request later fails. const netHost: MacroNetworkHost = { async request(method, url, body) { let host: string; try { host = new URL(url).host; } catch { throw new Error(`${method}: '${url}' ist keine gültige URL`); } if (!macro.approvedHosts?.includes(host)) { const verb = method === 'POST' ? 'eine POST-Anfrage an' : 'eine GET-Anfrage an'; if (!confirm(`Makro "${macro.name}" möchte ${verb} "${host}" senden. Erlauben?`)) { throw new Error(`${method}: Verbindung zu '${host}' abgelehnt`); } (macro.approvedHosts ??= []).push(host); markDirty(); } let res: Response; try { res = await fetch(url, { method, headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, body, }); } catch (e) { throw new Error(`${method} ${url}: ${e instanceof Error ? e.message : String(e)}`); } if (!res.ok) { throw new Error(`${method} ${url}: HTTP ${res.status} ${res.statusText}`); } try { return await res.json(); } catch { throw new Error(`${method} ${url}: Antwort ist kein gültiges JSON`); } }, }; macroRunActive = true; try { const block = parseMacroBlock(macro.body); const index = buildFunctionIndex(toFunctionDefs(doc)); const expanded = expandMacroBlock(block, index); const result = await runMacroActions(expanded, macroHost, writeHost, netHost); return { ok: true, message: typeof result === 'string' ? result : 'Makro ausgeführt.' }; } catch (e) { return { ok: false, message: e instanceof Error ? e.message : String(e) }; } finally { restoreView(); macroRunActive = false; commitHistoryStep(); // exactly one step for everything mutated since the run started, any await duration } } function moveObject(objId: string, x: number, y: number) { const o = objectById(objId); 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); const sheet = sheetId ? sheetById(sheetId) : undefined; if (!sheet) return; const idx = sheet.objects.findIndex((o) => o.id === objId); 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') return; o.view = view; markDirty(); } function setChartSource(objId: string, tableId: string) { const o = objectById(objId); if (o?.kind !== 'chart') return; o.sourceTableId = tableId; markDirty(); } function selectObject(objId: string | null) { doc.activeObjectId = objId; 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) ----------------- function cellInput(tableId: string, col: number, row: number): string { return tableById(tableId)?.cells[`${col},${row}`] ?? ''; } function cellValue(tableId: string, col: number, row: number): CellValue { void recalcTick.value; return wb.cellValue(tableId, col, row); } function cellDisplay(tableId: string, col: number, row: number): string { return formatValue(cellValue(tableId, col, row), effectiveLocale(tableId), displayNumFmt(tableId, col, row)); } /** The cell's number format, or -- for a formula cell left on "auto" -- a * format inferred from what it computes with: a formula whose numeric * inputs are all times/durations (`=SUM(B2:B8)`, `=C2-B2` over clock * times) shows as a duration, so a total of 37.5 hours reads `37:30` * instead of wrapping to `13:30` or showing the raw day fraction. Walks * formula chains (a sum of sums), bounded by `budget` cells visited. */ function displayNumFmt( tableId: string, col: number, row: number, budget = { left: 2000 }, ): CellNumFmt { const explicit = tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto'; if (explicit !== 'auto' || !cellInput(tableId, col, row).startsWith('=')) return explicit; let sawTime = false; for (const p of wb.graphPrecedents(tableId, col, row)) { if (--budget.left < 0) return 'auto'; if (typeof wb.cellValue(p.tableId, p.col, p.row) !== 'number') continue; const f = displayNumFmt(p.tableId, p.col, p.row, budget); if (f !== 'time' && f !== 'duration') return 'auto'; sawTime = true; } return sawTime ? 'duration' : 'auto'; } // ---- number-format locale (object -> sheet -> document cascade) --- /** Effective locale for a canvas object (table or chart) -- text/button * objects have no `locale` field of their own (nothing they show is * locale-formatted), so they fall straight through to the sheet/document * cascade. */ function effectiveLocale(objId: string | null): NumberLocale { const o = objId ? objectById(objId) : undefined; if (o && (o.kind === 'table' || o.kind === 'chart') && o.locale && isNumberLocale(o.locale)) { return o.locale; } const sheetId = objId ? sheetIdOfObject(objId) : doc.activeSheetId; const sheetLoc = sheetId ? sheetById(sheetId)?.locale : undefined; if (sheetLoc && isNumberLocale(sheetLoc)) return sheetLoc; return isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE; } const activeLocale = computed(() => effectiveLocale(activeTable.value?.id ?? null)); function setDocumentLocale(l: string) { 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)); const cellIsFormula = (t: string, c: number, r: number) => cellInput(t, c, r).startsWith('='); // ---- dependency-graph view (precedents/dependents of a cell) --------- const isCellCircular = (v: CellValue): boolean => isError(v) && v.code === '#CIRC'; function describeCellRef(ref: CellRef): CellGraphNode | null { const table = tableById(ref.tableId); if (!table) return null; // dangling (table deleted mid-navigation) -- drop it const sheetId = sheetIdOfObject(ref.tableId); const sheetName = (sheetId && sheetById(sheetId)?.name) || ''; const value = wb.cellValue(ref.tableId, ref.col, ref.row); return { tableId: ref.tableId, tableName: table.name, sheetName, col: ref.col, row: ref.row, label: toA1(ref.col, ref.row), raw: cellInput(ref.tableId, ref.col, ref.row), value, isCircular: isCellCircular(value), }; } /** Stable render order -- a `Set`'s iteration order is otherwise * insertion-order-dependent and would make the graph view jump around * on every re-render for no reason. */ const graphNodeSort = (a: CellGraphNode, b: CellGraphNode): number => a.tableName.localeCompare(b.tableName) || a.row - b.row || a.col - b.col; /** The centered cell itself, described the same way as a precedent/ * dependent node -- null if its table no longer exists. */ function cellGraphSelf(tableId: string, col: number, row: number): CellGraphNode | null { void recalcTick.value; return describeCellRef({ tableId, col, row }); } /** Direct precedents/dependents of a cell (see engine/graph.ts), enriched * for display -- the data source for the interactive dependency-graph * view (`DependencyGraphView.vue`). */ function cellGraphNeighbors( tableId: string, col: number, row: number, ): { precedents: CellGraphNode[]; dependents: CellGraphNode[] } { void recalcTick.value; const toNodes = (refs: CellRef[]) => refs.map(describeCellRef).filter((n): n is CellGraphNode => n !== null).sort(graphNodeSort); return { precedents: toNodes(wb.graphPrecedents(tableId, col, row)), dependents: toNodes(wb.graphDependents(tableId, col, row)), }; } /** Opens the dependency-graph drawer centered on this cell -- also *really* * selects it (via the existing `selectCell`, which already switches the * active sheet/object as a side effect), so the sheet tabs, canvas, * formula bar and status bar all reflect where you are, not just the * drawer itself. Reused as-is for navigating *inside* the already-open * drawer (clicking a precedent/dependent node calls this again) -- a * jump across sheets/tables inside the graph is then indistinguishable * from clicking that cell directly. */ function openDependencyGraph(tableId: string, col: number, row: number) { selectCell(tableId, col, row); ui.depGraph = { tableId, col, row }; } function closeDependencyGraph() { ui.depGraph = null; } // ---- cell writes -------------------------------------- function setCellInput(tableId: string, col: number, row: number, raw: string) { const table = tableById(tableId); 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; const grows = col >= table.cols || row >= table.rows; if (col >= table.cols) table.cols = col + 1; if (row >= table.rows) table.rows = row + 1; // a grown table changes what a whole-column/-row reference (`B:B`, `1:1`) // covers -- rebuild so those formulas pick up the new cells if (grows) syncWorkbook(); else wb.setCell(tableId, col, row, trimmed); // Typing a recognizable date/time literal auto-applies the matching // display format, like Excel/Numbers -- but only while the cell has no // explicit numFmt of its own yet, so this never overrides a deliberate // choice (e.g. a cell the user already set to "Ohne Formatierung"). if (trimmed !== '' && !trimmed.startsWith('=')) { const dt = parseDateTimeLiteral(trimmed); if (dt && (cellFormat(tableId, col, row).numFmt ?? 'auto') === 'auto') { setRangeFormat(tableId, { col, row }, { col, row }, { numFmt: dt.kind }); } } bump(); markDirty(); } function clearRange(tableId: string, a: CellCoord, b: CellCoord) { const c0 = Math.min(a.col, b.col); const c1 = Math.max(a.col, b.col); const r0 = Math.min(a.row, b.row); const r1 = Math.max(a.row, b.row); for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) setCellInput(tableId, c, r, ''); } // ---- table grid growth ----------------------------- const addRows = (tableId: string, n = 1) => { const t = tableById(tableId); if (!t) return; t.rows += n; markDirty(); }; const addColumns = (tableId: string, n = 1) => { const t = tableById(tableId); if (!t) return; t.cols += n; markDirty(); }; function removeLastRow(tableId: string) { const t = tableById(tableId); if (!t || t.rows <= 1) return; const r = t.rows - 1; 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); if (!t || t.cols <= 1) return; const c = t.cols - 1; for (let r = 0; r < t.rows; r++) delete t.cells[`${c},${r}`]; 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) 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 { const t = tableById(tableId); return t?.rowHeights?.[row] ?? t?.rowHeight ?? DEFAULT_ROW_H; } function setRowHeight(tableId: string, row: number, height: number) { const t = tableById(tableId); 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) return; t.rowHeight = Math.min(240, Math.max(MIN_ROW_H, Math.round(height))); markDirty(); } function pruneEmptyMaps(t: TableObject) { if (t.formats && !Object.keys(t.formats).length) delete t.formats; if (t.rowHeights && !Object.keys(t.rowHeights).length) delete t.rowHeights; } /** Grow or shrink a table to an exact row / column count. */ function setTableRows(tableId: string, target: number) { const t = tableById(tableId); if (!t) return; const n = Math.max(1, Math.floor(target)); if (n === t.rows) return; if (n < t.rows) { for (const k of Object.keys(t.cells)) if (Number(k.split(',')[1]) >= n) delete t.cells[k]; if (t.formats) for (const k of Object.keys(t.formats)) if (Number(k.split(',')[1]) >= n) delete t.formats[k]; if (t.rowHeights) for (const k of Object.keys(t.rowHeights)) if (Number(k) >= n) delete t.rowHeights[Number(k)]; } t.rows = n; pruneEmptyMaps(t); clampTableView(t); clampSelection(tableId); syncWorkbook(); markDirty(); } function setTableCols(tableId: string, target: number) { const t = tableById(tableId); if (!t) return; const n = Math.max(1, Math.floor(target)); if (n === t.cols) return; if (n < t.cols) { for (const k of Object.keys(t.cells)) if (Number(k.split(',')[0]) >= n) delete t.cells[k]; if (t.formats) for (const k of Object.keys(t.formats)) if (Number(k.split(',')[0]) >= n) delete t.formats[k]; for (const k of Object.keys(t.colWidths)) if (Number(k) >= n) delete t.colWidths[Number(k)]; } t.cols = n; pruneEmptyMaps(t); clampTableView(t); clampSelection(tableId); syncWorkbook(); markDirty(); } // ---- row/col insert & delete (mid-grid, not just edge growth) --------- // Unlike setTableRows/setTableCols above (pure edge resize) or // removeLastRow/removeLastColumn, these shift cell data/formats/row-height // (or column-width) overrides *within* the table around the insertion/ // deletion point, and rewrite every formula in the *whole document* that // references a row/col at/after it on this table -- see // `rewriteRefsForStructuralChange` above for the "how". /** Move `doc.selection` (if it's on `tableId`) the same way a row/col * insert/delete moves cell data -- so the active cell/marquee stays on * the "same" logical cell it was on before, not wherever that grid * position now happens to be. `clampSelection` (called by every caller * right after) catches anything this pushes out of the shrunk bounds. */ function shiftSelectionForStructuralChange(tableId: string, axis: Axis, at: number, delta: 1 | -1) { const s = doc.selection; if (!s || s.tableId !== tableId) return; const shift = (c: CellCoord): CellCoord => { const v = axis === 'row' ? c.row : c.col; const nv = delta > 0 ? insertShift(v, at) : deleteShiftStart(v, at); return axis === 'row' ? { ...c, row: nv } : { ...c, col: nv }; }; doc.selection = { tableId, anchor: shift(s.anchor), focus: shift(s.focus), ranges: s.ranges?.map((r) => ({ anchor: shift(r.anchor), focus: shift(r.focus) })), }; } /** Rewrite every formula in the whole document referencing `targetTableId` * after its rows/cols and cell data have already been shifted to their * new shape (self-references inside `targetTableId` are included -- it's * just another table as far as this loop is concerned). */ function rewriteDocumentRefs(targetTableId: string, axis: Axis, at: number, delta: 1 | -1) { for (const s of doc.sheets) { for (const o of s.objects) { if (o.kind !== 'table') continue; for (const [key, raw] of Object.entries(o.cells)) { const next = rewriteRefsForStructuralChange(raw, o.id, targetTableId, axis, at, delta, wb); if (next !== raw) o.cells[key] = next; } } } } /** Same driver shape as `rewriteDocumentRefs` above, for a table rename * (see `rewriteRefsForRename`'s own doc comment) -- every OTHER table's * cells are walked too, not just the renamed table's own, since a * qualified reference to it can live anywhere in the document. Scoped * to table cells only, same as the structural-change rewriter (macro * bodies address a table by a plain string, not a formula qualifier, and * are resolved fresh at each run anyway -- see `resolveMacroTableRef` -- * so they need no rewriting; a custom function's own body is likewise * left alone, matching the existing, established scope of the sibling * row/col-insert/delete rewrite this mirrors). Called BEFORE the * object's `.name` is actually changed, so `wb` (not yet rebuilt) still * resolves the OLD name to `targetTableId`. */ function renameDocumentRefs(targetTableId: string, newName: string) { for (const s of doc.sheets) { for (const o of s.objects) { if (o.kind !== 'table') continue; for (const [key, raw] of Object.entries(o.cells)) { const next = rewriteRefsForRename(raw, o.id, targetTableId, newName, wb); if (next !== raw) o.cells[key] = next; } } } } /** Re-key every entry of a "col,row" map, shifting the given axis at/after * `at` by `delta` (dropping entries whose axis value === `at` when * `delta` is -1 -- that row/col's own cells are gone). Shared by * insertRow/deleteRow (axis 'row') and insertColumn/deleteColumn (axis * 'col'). */ function shiftCellKeyedMap( map: Record, axis: Axis, at: number, delta: 1 | -1, ): Record { const out: Record = {}; for (const [k, v] of Object.entries(map)) { const [c, r] = k.split(',').map(Number); const cur = axis === 'row' ? r : c; if (delta < 0 && cur === at) continue; // this row/col's own cell is gone const nv = delta > 0 ? insertShift(cur, at) : deleteShiftStart(cur, at); const key = axis === 'row' ? `${c},${nv}` : `${nv},${r}`; out[key] = v; } return out; } /** Same idea as shiftCellKeyedMap, for a single-axis map keyed by plain * row/col number (rowHeights, colWidths). */ function shiftAxisKeyedMap(map: Record, at: number, delta: 1 | -1): Record { const out: Record = {}; for (const [k, v] of Object.entries(map)) { const cur = Number(k); if (delta < 0 && cur === at) continue; out[delta > 0 ? insertShift(cur, at) : deleteShiftStart(cur, at)] = v; } return out; } function insertRow(tableId: string, at: number) { const t = tableById(tableId); if (!t) return; const clamped = Math.max(0, Math.min(Math.floor(at), t.rows)); collabHooks?.onStructure(tableId, 'row', clamped, 1); t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, 1); if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, 1); if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, 1); t.rows += 1; shiftSelectionForStructuralChange(tableId, 'row', clamped, 1); rewriteDocumentRefs(tableId, 'row', clamped, 1); clampSelection(tableId); syncWorkbook(); markDirty(); } function deleteRow(tableId: string, at: number) { const t = tableById(tableId); if (!t || t.rows <= 1) return; const clamped = Math.max(0, Math.min(Math.floor(at), t.rows - 1)); collabHooks?.onStructure(tableId, 'row', clamped, -1); t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, -1); if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, -1); if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, -1); t.rows -= 1; shiftSelectionForStructuralChange(tableId, 'row', clamped, -1); rewriteDocumentRefs(tableId, 'row', clamped, -1); pruneEmptyMaps(t); clampTableView(t); clampSelection(tableId); syncWorkbook(); markDirty(); } function insertColumn(tableId: string, at: number) { const t = tableById(tableId); if (!t) return; const clamped = Math.max(0, Math.min(Math.floor(at), t.cols)); collabHooks?.onStructure(tableId, 'col', clamped, 1); t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, 1); if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, 1); t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, 1); t.cols += 1; shiftSelectionForStructuralChange(tableId, 'col', clamped, 1); rewriteDocumentRefs(tableId, 'col', clamped, 1); clampSelection(tableId); syncWorkbook(); markDirty(); } function deleteColumn(tableId: string, at: number) { const t = tableById(tableId); if (!t || t.cols <= 1) return; const clamped = Math.max(0, Math.min(Math.floor(at), t.cols - 1)); collabHooks?.onStructure(tableId, 'col', clamped, -1); t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, -1); if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, -1); t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, -1); t.cols -= 1; shiftSelectionForStructuralChange(tableId, 'col', clamped, -1); rewriteDocumentRefs(tableId, 'col', clamped, -1); pruneEmptyMaps(t); clampTableView(t); clampSelection(tableId); syncWorkbook(); markDirty(); } // ---- cell formatting (alignment, number format, colours, bold/italic/underline) --- const fmtKey = (c: number, r: number) => `${c},${r}`; function cellFormat(tableId: string, col: number, row: number): CellFormat { return tableById(tableId)?.formats?.[fmtKey(col, row)] ?? {}; } /** Merge `patch` into every cell of a range; a `null`/`undefined` field clears it. */ function setRangeFormat( tableId: string, a: CellCoord, b: CellCoord, patch: Partial>, ) { const t = tableById(tableId); if (!t) return; if (!t.formats) t.formats = {}; const c0 = Math.min(a.col, b.col); const c1 = Math.max(a.col, b.col); const r0 = Math.min(a.row, b.row); const r1 = Math.max(a.row, b.row); for (let r = r0; r <= r1; r++) { for (let c = c0; c <= c1; c++) { const k = fmtKey(c, r); const cur: CellFormat = { ...(t.formats[k] ?? {}) }; for (const [field, val] of Object.entries(patch)) { if (val === null || val === undefined || val === '' || val === false || val === 'auto') { delete (cur as Record)[field]; } else { (cur as Record)[field] = val; } } if (Object.keys(cur).length) t.formats[k] = cur; else delete t.formats[k]; } } if (!Object.keys(t.formats).length) delete t.formats; bump(); markDirty(); } function clearRangeFormat(tableId: string, a: CellCoord, b: CellCoord) { const t = tableById(tableId); if (!t?.formats) return; const c0 = Math.min(a.col, b.col); const c1 = Math.max(a.col, b.col); const r0 = Math.min(a.row, b.row); const r1 = Math.max(a.row, b.row); 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(() => { void recalcTick.value; const s = doc.selection; const t = s ? tableById(s.tableId) : undefined; if (!s || !t) return {}; let acc: CellFormat | null = null; for (const key of selectedCellKeys()) { const f = t.formats?.[key] ?? {}; if (acc === null) acc = { ...f }; else { for (const k of Object.keys(acc) as (keyof CellFormat)[]) { if (acc[k] !== f[k]) delete acc[k]; } } } return acc ?? {}; }); /** Apply / clear formatting across *all* rectangles of the current selection. */ function applySelectionFormat(patch: Partial>) { const s = doc.selection; if (!s) return; for (const k of selectionRects(s)) { setRangeFormat(s.tableId, { col: k.c0, row: k.r0 }, { col: k.c1, row: k.r1 }, patch); } } function clearSelectionFormat() { const s = doc.selection; if (!s) return; for (const k of selectionRects(s)) { clearRangeFormat(s.tableId, { col: k.c0, row: k.r0 }, { col: k.c1, row: k.r1 }); } } /** Delete/Backspace over a multi-selection. */ function clearSelectionCells() { const s = doc.selection; if (!s) return; for (const key of selectedCellKeys()) { const [c, r] = key.split(',').map(Number); setCellInput(s.tableId, c, r, ''); } } function clampTableView(t: TableObject) { t.view.frozenRows = Math.min(Math.max(0, t.view.frozenRows), Math.max(0, t.rows - 1)); t.view.frozenCols = Math.min(Math.max(0, t.view.frozenCols), Math.max(0, t.cols - 1)); } function clampSelection(tableId: string) { const s = doc.selection; const t = tableById(tableId); if (!s || !t || s.tableId !== tableId) return; const cl = (c: CellCoord) => ({ col: Math.min(Math.max(0, c.col), t.cols - 1), row: Math.min(Math.max(0, c.row), t.rows - 1), }); doc.selection = { tableId, anchor: cl(s.anchor), focus: cl(s.focus), ranges: s.ranges?.map((r) => ({ anchor: cl(r.anchor), focus: cl(r.focus) })), }; } /** Patch a table object's view (sizing mode, viewport size, frozen rows/cols). */ function setTableView(tableId: string, patch: Partial) { const t = tableById(tableId); if (!t) return; t.view = { ...t.view, ...patch }; 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 -------------------------------------- function rect(a: CellCoord, b: CellCoord): Rect { return { c0: Math.min(a.col, b.col), c1: Math.max(a.col, b.col), r0: Math.min(a.row, b.row), r1: Math.max(a.row, b.row), }; } /** Every rectangle of `sel` (primary first), normalised. */ function selectionRects(sel: SelectionState): Rect[] { const out = [rect(sel.anchor, sel.focus)]; for (const r of sel.ranges ?? []) out.push(rect(r.anchor, r.focus)); return out; } /** Deduplicated set of "c,r" keys covered by the current selection. */ function selectedCellKeys(): Set { const s = doc.selection; const keys = new Set(); if (!s) return keys; for (const k of selectionRects(s)) { for (let r = k.r0; r <= k.r1; r++) for (let c = k.c0; c <= k.c1; c++) keys.add(`${c},${r}`); } return keys; } const isOneByOne = (a: CellCoord, b: CellCoord, c: number, r: number) => a.col === c && a.row === r && b.col === c && b.row === r; function setSelection(sel: SelectionState | null) { doc.selection = sel; if (sel) { doc.activeObjectId = sel.tableId; 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) { const cur = doc.selection; const anchor = extend && cur && cur.tableId === tableId ? cur.anchor : { col, row }; setSelection({ tableId, anchor, focus: { col, row } }); } /** Ctrl/Cmd-click: add the cell as a new rectangle, or toggle it off. */ function addToSelection(tableId: string, col: number, row: number) { const cur = doc.selection; if (!cur || cur.tableId !== tableId) { selectCell(tableId, col, row); return; } const ranges = [...(cur.ranges ?? [])]; const idx = ranges.findIndex((r) => isOneByOne(r.anchor, r.focus, col, row)); if (idx >= 0) { // 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) { // toggle the primary off -> promote the most recent extra range const last = ranges.pop()!; doc.selection = { tableId, anchor: last.anchor, focus: last.focus, ranges: ranges.length ? ranges : undefined, }; scheduleSave(); return; } // keep the current primary as an extra range, make the clicked cell primary ranges.push({ anchor: cur.anchor, focus: cur.focus }); setSelection({ tableId, anchor: { col, row }, focus: { col, row }, ranges }); } /** Ctrl/Cmd-drag: grow the just-added (primary) rectangle. */ function extendActiveRange(tableId: string, col: number, row: number) { 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) { const cur = doc.selection; if (!cur || cur.tableId !== tableId) { setSelection({ tableId, anchor: a, focus: b }); return; } const ranges = [...(cur.ranges ?? []), { anchor: cur.anchor, focus: cur.focus }]; setSelection({ tableId, anchor: a, focus: b, ranges }); } // ---- clipboard (copy / cut / paste) -------------------------- /** True while a copy/cut buffer is held (enables "Einfügen" in menus). */ const hasClipboard = computed(() => clipboard.value !== null); /** Source rectangle of the current buffer -- drives the dashed source marker * and its anchor-cell indicator (which stays put once the live selection * has moved on to pick a paste destination). */ const clipboardRect = computed(() => { const cb = clipboard.value; return cb ? { tableId: cb.tableId, ...cb.rect, cut: cb.cut, anchor: cb.anchor } : null; }); function copyOrCut(cut: boolean) { const s = doc.selection; const t = s ? tableById(s.tableId) : undefined; if (!s || !t) return; const r = rect(s.anchor, s.focus); const cells = new Map(); for (let row = r.r0; row <= r.r1; row++) { for (let col = r.c0; col <= r.c1; col++) { const raw = cellInput(s.tableId, col, row); const format = t.formats?.[fmtKey(col, row)]; if (raw || format) { cells.set(`${col - r.c0},${row - r.r0}`, { raw, format: format ? { ...format } : undefined }); } } } clipboard.value = { tableId: s.tableId, rect: r, width: r.c1 - r.c0 + 1, height: r.r1 - r.r0 + 1, cells, cut, anchor: { ...s.anchor }, }; } const copySelection = () => copyOrCut(false); const cutSelection = () => copyOrCut(true); /** Drop the buffer (Escape) without pasting. */ function cancelClipboard() { clipboard.value = null; } /** * Paste the buffer at the current selection. A selection that is an exact, * larger multiple of the buffer tiles it (Numbers/Excel "fill by paste"); * a smaller or single-cell selection anchors the buffer there and overflows * past it, same as pasting a whole block onto one cell. */ function pasteClipboard() { const cb = clipboard.value; const s = doc.selection; if (!cb || !s || !tableById(s.tableId)) return; const dest = rect(s.anchor, s.focus); const destW = dest.c1 - dest.c0 + 1; const destH = dest.r1 - dest.r0 + 1; const tileX = destW > cb.width && destW % cb.width === 0 ? destW / cb.width : 1; const tileY = destH > cb.height && destH % cb.height === 0 ? destH / cb.height : 1; for (let ty = 0; ty < tileY; ty++) { for (let tx = 0; tx < tileX; tx++) { const baseC = dest.c0 + tx * cb.width; const baseR = dest.r0 + ty * cb.height; const dCol = baseC - cb.rect.c0; const dRow = baseR - cb.rect.r0; for (let dr = 0; dr < cb.height; dr++) { for (let dc = 0; dc < cb.width; dc++) { const c = baseC + dc; const r = baseR + dr; const cell = cb.cells.get(`${dc},${dr}`); const raw = cell?.raw ?? ''; setCellInput( s.tableId, c, r, raw ? shiftFormula(raw, dCol, dRow, cb.cut ? cb.rect : undefined) : raw, ); clearRangeFormat(s.tableId, { col: c, row: r }, { col: c, row: r }); if (cell?.format) { setRangeFormat( s.tableId, { col: c, row: r }, { col: c, row: r }, cell.format as Partial>, ); } } } } } if (cb.cut) { // clear the moved-from cells, except any the paste itself just wrote into const destC1 = dest.c0 + tileX * cb.width - 1; const destR1 = dest.r0 + tileY * cb.height - 1; for (let row = cb.rect.r0; row <= cb.rect.r1; row++) { for (let col = cb.rect.c0; col <= cb.rect.c1; col++) { const overlap = cb.tableId === s.tableId && col >= dest.c0 && col <= destC1 && row >= dest.r0 && row <= destR1; if (!overlap) { setCellInput(cb.tableId, col, row, ''); clearRangeFormat(cb.tableId, { col, row }, { col, row }); } } } clipboard.value = null; // a cut buffer is consumed by its first paste } setSelection({ tableId: s.tableId, anchor: { col: dest.c0, row: dest.r0 }, focus: { col: dest.c0 + tileX * cb.width - 1, row: dest.r0 + tileY * cb.height - 1 }, }); } /** * The Excel/Numbers "fill handle": repeat `source`'s cells (values, * formulas -- reference-adjusted the same way a copy is -- and formatting) * across `target`, a rectangle extending purely up, down, left or right * from `source` (never both axes at once -- the caller picks the single * dominant drag direction). Repeats cyclically, so a `target` that isn't an * exact multiple of `source`'s size still fills its last partial cycle. */ function fillSelection(tableId: string, source: Rect, target: Rect) { const t = tableById(tableId); if (!t) return; const srcW = source.c1 - source.c0 + 1; const srcH = source.r1 - source.r0 + 1; const cells = new Map(); for (let row = source.r0; row <= source.r1; row++) { for (let col = source.c0; col <= source.c1; col++) { const raw = cellInput(tableId, col, row); const format = t.formats?.[fmtKey(col, row)]; if (raw || format) { cells.set(`${col - source.c0},${row - source.r0}`, { raw, format: format ? { ...format } : undefined }); } } } for (let row = target.r0; row <= target.r1; row++) { for (let col = target.c0; col <= target.c1; col++) { // which cell of the source block this target cell cycles back to const dc = (((col - source.c0) % srcW) + srcW) % srcW; const dr = (((row - source.r0) % srcH) + srcH) % srcH; const cell = cells.get(`${dc},${dr}`); const raw = cell?.raw ?? ''; const deltaC = col - (source.c0 + dc); const deltaR = row - (source.r0 + dr); setCellInput(tableId, col, row, raw ? shiftFormula(raw, deltaC, deltaR) : raw); clearRangeFormat(tableId, { col, row }, { col, row }); if (cell?.format) { setRangeFormat(tableId, { col, row }, { col, row }, cell.format as Partial>); } } } } // ---- formula "point mode" (click/drag a cell to insert its reference) --- /** Register (or, with `null`, unregister) the editor a grid click should * insert references into. Also drops any in-flight "replace on next * click" state from whichever editor was registered before. */ function registerFormulaEditor(handle: FormulaEditorHandle | null) { formulaEditor.value = handle; pointSpan = null; pointRect.value = null; } /** The user typed (as opposed to a click inserting a reference for them): * the next click should insert a fresh reference, not replace the last * one -- typing anything ends "point mode"'s replace-on-click window, * same as Excel (not just `;`, though that's the most common case, since * it's the argument separator you'd type right after picking one cell * and before picking the next). */ function noteFormulaTyped() { pointSpan = null; pointRect.value = null; } /** True while the registered editor's buffer is a formula -- point mode * (click-to-insert-reference) only applies then. */ function formulaPointModeActive(): boolean { return !!formulaEditor.value && formulaEditor.value.getState().text.startsWith('='); } /** Function-name autocomplete candidates for a prefix already typed (built-in * + this document's custom functions), case-insensitive, name-sorted. Used * by src/composables/useFormulaAutocomplete.ts, driven by * currentIdentifierAtCaret() finding the word being typed. Empty prefix -> * no suggestions (matches Excel/Sheets: autocomplete only kicks in once * something's actually been typed, not on a bare "="). */ function functionSuggestions(prefix: string): FnSuggestion[] { const p = prefix.toUpperCase(); if (!p) return []; const builtins: FnSuggestion[] = BUILTIN_SIGNATURES.filter((s) => s.name.startsWith(p)).map( (s) => ({ name: s.name, hint: s.hint, kind: 'builtin' }), ); const custom: FnSuggestion[] = doc.customFunctions .filter((f) => f.name.toUpperCase().startsWith(p)) .map((f) => ({ name: f.name, hint: `${f.name}(${f.params.join('; ')})`, kind: 'custom' })); return [...builtins, ...custom].sort((a, b) => a.name.localeCompare(b.name)); } /** Name Box autocomplete candidates for a prefix already typed -- named * ranges, table names (any sheet), then sheet names, each group * case-insensitively prefix-matched and name-sorted, in that priority * order (matching `jumpToNameInput`'s own resolution order below). Empty * prefix -> no suggestions, same convention as `functionSuggestions`. */ function nameBoxSuggestions(prefix: string): NameSuggestion[] { const p = prefix.trim().toLowerCase(); if (!p) return []; const namedRanges: NameSuggestion[] = doc.namedRanges .filter((r) => r.name.toLowerCase().startsWith(p)) .map((r) => { const t = tableById(r.tableId); const addr = r.start.col === r.end.col && r.start.row === r.end.row ? toA1(r.start.col, r.start.row) : `${toA1(r.start.col, r.start.row)}:${toA1(r.end.col, r.end.row)}`; return { name: r.name, hint: `${t?.name ?? '?'}!${addr}`, kind: 'namedrange' as const }; }) .sort((a, b) => a.name.localeCompare(b.name)); const tables: NameSuggestion[] = allTables(doc) .filter((t) => t.name.toLowerCase().startsWith(p)) .map((t) => { const sheetId = sheetIdOfObject(t.id); const sheetName = (sheetId && sheetById(sheetId)?.name) || '?'; return { name: t.name, hint: `Tabelle · Blatt „${sheetName}"`, kind: 'table' as const }; }) .sort((a, b) => a.name.localeCompare(b.name)); const sheets: NameSuggestion[] = doc.sheets .filter((s) => s.name.toLowerCase().startsWith(p)) .map((s) => ({ name: s.name, hint: 'Blatt', kind: 'sheet' as const })) .sort((a, b) => a.name.localeCompare(b.name)); return [...namedRanges, ...tables, ...sheets]; } /** * Resolves free-form Name Box input and navigates to it -- same syntax a * formula reference would use (`Tabelle::A1`, `Blatt!Tabelle::A1:B5`, a * bare `A1`/`A1:B5` meaning "on the currently active table", or a named * range's own name), falling back to a bare **table** name (selects the * whole table) or a bare **sheet** name (switches to it) -- neither of * those two is valid formula syntax on its own, so they're only tried * once the reference parse produced nothing usable. Returns whether it * found something to jump to; the caller (the Name Box itself) reports a * failure to the user. */ function jumpToNameInput(text: string): boolean { const trimmed = text.trim(); if (!trimmed) return false; const home = doc.selection?.tableId ?? activeTable.value?.id ?? null; let node: ReturnType | null = null; try { node = parseFormula(trimmed); } catch { node = null; } if (node) { let qualifier: string | null = null; let sheetQualifier: string | undefined; let start: { col: number; row: number } | null = null; let end: { col: number; row: number } | null = null; if (node.kind === 'cell') { qualifier = node.qualifier; sheetQualifier = node.sheetQualifier; start = end = { col: node.col, row: node.row }; } else if (node.kind === 'range') { qualifier = node.qualifier; sheetQualifier = node.sheetQualifier; start = node.start; end = node.end; } else if (node.kind === 'namedref') { const r = wb.resolveNamedRange(node.name); if (r) { qualifier = r.qualifier; sheetQualifier = r.sheetQualifier; start = r.start; end = r.end; } } if (start && end) { const tableId = qualifier === null ? home : wb.resolveTableName(qualifier, sheetQualifier ?? null); const t = tableId ? tableById(tableId) : undefined; if (t) { const clampC = (c: number) => Math.max(0, Math.min(t.cols - 1, c)); const clampR = (r: number) => Math.max(0, Math.min(t.rows - 1, r)); setSelection({ tableId: t.id, anchor: { col: clampC(start.col), row: clampR(start.row) }, focus: { col: clampC(end.col), row: clampR(end.row) }, }); return true; } } } // Not a (resolvable) reference -- try a bare table or sheet name. const tableId = wb.resolveTableName(trimmed, null); const t = tableId ? tableById(tableId) : undefined; if (t) { setSelection({ tableId: t.id, anchor: { col: 0, row: 0 }, focus: { col: t.cols - 1, row: t.rows - 1 } }); return true; } const sheet = sheetByName(trimmed); if (sheet) { setActiveSheet(sheet.id); return true; } return false; } function qualifyRef(homeTableId: string, targetTableId: string, body: string): string { if (targetTableId === homeTableId) return body; const target = tableById(targetTableId); const tableQ = quoteQualifier(target?.name ?? targetTableId); // a plain "Tabelle!..." is enough as long as it resolves unambiguously -- // either the name is document-unique, or the target happens to be on the // same sheet as the formula being edited (resolveTableName()'s "prefer // the home sheet" fallback picks it correctly either way); only a name // that collides *and* lives on a different sheet needs the explicit // "Blatt!Tabelle::..." 3-part form to disambiguate. const homeSheetId = sheetIdOfObject(homeTableId); const targetSheetId = sheetIdOfObject(targetTableId); const nameCollides = target ? allTables(doc).filter((t) => t.name.toLowerCase() === target.name.toLowerCase()).length > 1 : false; if (!nameCollides || targetSheetId === homeSheetId) { return `${tableQ}!${body}`; } const sheetName = (targetSheetId && sheetById(targetSheetId)?.name) || ''; return `${quoteQualifier(sheetName)}!${tableQ}::${body}`; } function applyPointRef(targetTableId: string, body: string): boolean { const ed = formulaEditor.value; if (!ed) return false; const st = ed.getState(); if (!st.text.startsWith('=')) return false; const text = qualifyRef(ed.tableId, targetTableId, body); const span = pointSpan ?? { start: st.start, end: st.end }; ed.replace(span.start, span.end, text); pointSpan = { start: span.start, end: span.start + text.length }; return true; } /** The table a reference in the edited formula points at: its own table * when unqualified, else by name -- preferring the editor's sheet when the * name exists on several, as the engine does (Workbook.resolveTableName). */ function refTableId(homeTableId: string, qualifier: string | null, sheetQualifier?: string): string | null { if (qualifier === null) return homeTableId; const needle = qualifier.trim().toLowerCase(); const sheetNeedle = sheetQualifier?.trim().toLowerCase(); const candidates = allTables(doc).filter((t) => { if (t.name.trim().toLowerCase() !== needle) return false; if (sheetNeedle === undefined) return true; const sid = sheetIdOfObject(t.id); return (sid && sheetById(sid)?.name.trim().toLowerCase()) === sheetNeedle; }); if (candidates.length <= 1) return candidates[0]?.id ?? null; const homeSheet = sheetIdOfObject(homeTableId); return (candidates.find((t) => sheetIdOfObject(t.id) === homeSheet) ?? candidates[0]).id; } /** Every cell/range reference of the formula being edited (in-cell editor * or formula bar), resolved to its table and clamped to it, so each grid * can outline the ones that point into it. Reactive on the editor's text: * both editors' `getState()` read their own text ref. */ const formulaRefs = computed(() => { const ed = formulaEditor.value; if (!ed) return []; const colorOf = new Map(); const out: FormulaRefHighlight[] = []; formulaRefSpans(ed.getState().text).forEach((span, index) => { const tableId = refTableId(ed.tableId, span.qualifier, span.sheetQualifier); const t = tableId ? tableById(tableId) : undefined; if (!t) return; const c0 = Math.min(span.a.col, span.b.col); const r0 = Math.min(span.a.row, span.b.row); if (c0 >= t.cols || r0 >= t.rows) return; // entirely outside the table const rect = { c0, r0, c1: Math.min(Math.max(span.a.col, span.b.col), t.cols - 1), r1: Math.min(Math.max(span.a.row, span.b.row), t.rows - 1), }; // the same reference written twice keeps one colour (as in Excel) const key = `${t.id}|${rect.c0},${rect.r0},${rect.c1},${rect.r1}`; if (!colorOf.has(key)) colorOf.set(key, REF_COLORS[colorOf.size % REF_COLORS.length]); out.push({ index, tableId: t.id, ...rect, color: colorOf.get(key)!, whole: !!span.whole }); }); return out; }); /** Rewrite the `index`-th reference of the formula being edited to cover * (c0,r0)-(c1,r1) instead -- dragging a reference outline's edge (move) * or corner (resize) in the grid, Excel-style. Its qualifier and `$` * locks stay as written; a range shrunk to one cell becomes a plain cell * reference. Whole-column/-row references are left alone. */ function moveFormulaRef(index: number, c0: number, r0: number, c1: number, r1: number): boolean { const ed = formulaEditor.value; if (!ed) return false; const text = ed.getState().text; const span = formulaRefSpans(text)[index]; if (!span || span.whole) return false; const single = span.a.col === span.b.col && span.a.row === span.b.row; const lockB = single ? span.a : span.b; const lo = toA1(Math.min(c0, c1), Math.min(r0, r1), span.a.colAbs, span.a.rowAbs); const hi = toA1(Math.max(c0, c1), Math.max(r0, r1), lockB.colAbs, lockB.rowAbs); const body = c0 === c1 && r0 === r1 ? lo : `${lo}:${hi}`; const next = text.slice(span.start, span.refStart) + body; if (next === text.slice(span.start, span.end)) return true; ed.replace(span.start, span.end, next); // the text around the caret moved: a following grid click must insert a // fresh reference, not overwrite a span that no longer exists pointSpan = null; pointRect.value = null; return true; } /** A plain click on (tableId, col, row) while editing a formula: insert or * (immediately after another click/drag, before any typing) replace a * reference at the caret. Returns false when there's no formula editor * active, so the caller can fall back to its normal click handling. */ function pointClick(tableId: string, col: number, row: number): boolean { if (!applyPointRef(tableId, toA1(col, row))) return false; pointRect.value = { tableId, c0: col, r0: row, c1: col, r1: row }; return true; } /** A drag from (c0,r0) to (c1,r1) on tableId while editing a formula: * insert/replace a *range* reference, meant to be called live as the drag * updates (each call replaces the same span, growing/shrinking the range * as the user drags), not just once at the end. */ function pointDrag(tableId: string, c0: number, r0: number, c1: number, r1: number): boolean { const rect = { tableId, c0: Math.min(c0, c1), r0: Math.min(r0, r1), c1: Math.max(c0, c1), r1: Math.max(r0, r1) }; const a = toA1(rect.c0, rect.r0); const b = toA1(rect.c1, rect.r1); if (!applyPointRef(tableId, a === b ? a : `${a}:${b}`)) return false; pointRect.value = rect; return true; } // The "active cell" is always the anchor -- the first cell of the current // selection action (click, shift-click, drag start, ...) -- so it stays put // while a multi-cell selection is being made, matching Excel/Numbers. const activeCell = computed(() => doc.selection?.anchor ?? { col: 0, row: 0 }); const activeCellName = computed(() => { const sel = doc.selection; const t = sel ? tableById(sel.tableId) : activeTable.value; const cell = sel?.anchor ?? { col: 0, row: 0 }; return `${t?.name ?? '—'}::${toA1(cell.col, cell.row)}`; }); /** Like {@link activeCellName}, but for a multi-cell selection it shows the * full range (`Umsatz::B2:E6`) instead of just the anchor -- used wherever * the UI shows "where am I" (formula bar, status bar), as opposed to * `activeCellName`/`activeCell`, which stay anchor-only because that's * what a formula/edit actually targets (see the "selection aggregate & * cell name are live" test). A discontiguous multi-selection additionally * gets a `+N` suffix for the further rectangles beyond the primary one. */ const selectionRangeName = computed(() => { const sel = doc.selection; const t = sel ? tableById(sel.tableId) : activeTable.value; const tableName = t?.name ?? '—'; if (!sel) return `${tableName}::${toA1(0, 0)}`; const rects = selectionRects(sel); const [p] = rects; const addr = p.c0 === p.c1 && p.r0 === p.r1 ? toA1(p.c0, p.r0) : `${toA1(p.c0, p.r0)}:${toA1(p.c1, p.r1)}`; const extra = rects.length > 1 ? ` +${rects.length - 1}` : ''; return `${tableName}::${addr}${extra}`; }); const activeCellInput = computed(() => { const sel = doc.selection; return sel ? cellInput(sel.tableId, sel.anchor.col, sel.anchor.row) : ''; }); const selectionAggregate = computed(() => { void recalcTick.value; const sel = doc.selection; if (!sel) return { sum: 0, avg: 0, count: 0, cells: 0 }; let sum = 0; let count = 0; let cells = 0; for (const key of selectedCellKeys()) { cells++; const [c, r] = key.split(',').map(Number); const v = wb.cellValue(sel.tableId, c, r); if (typeof v === 'number' && Number.isFinite(v)) { sum += v; count++; } } return { sum, avg: count ? sum / count : 0, count, cells }; }); // ---- document-level ops --------------------------- /** 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 = next.sheets.find((s) => s.id === next.activeSheetId)?.id ?? next.sheets[0].id; doc.activeObjectId = next.activeObjectId ?? null; doc.selection = next.selection ?? null; doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE; doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ','; doc.customFunctions = next.customFunctions ?? []; 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(); } /** * A collaborator's change: take the new content, keep what is this * person's own view -- the open sheet, the selected object, the selection * and each sheet's zoom. History is untouched (undo is per person, in * Yjs) and it doesn't count as a local change. */ function applyExternalState(next: DocumentState) { const zoom = new Map(doc.sheets.map((s) => [s.id, s.zoom])); const keepSheet = next.sheets.some((s) => s.id === doc.activeSheetId); const view = { activeSheetId: keepSheet ? doc.activeSheetId : next.sheets[0]?.id, activeObjectId: doc.activeObjectId, selection: doc.selection, }; for (const s of next.sheets) { if (zoom.has(s.id)) s.zoom = zoom.get(s.id); } applyingExternal = true; try { applyDocumentState({ ...next, ...view } as DocumentState); if (doc.activeObjectId && !doc.sheets.some((s) => s.objects.some((o) => o.id === doc.activeObjectId))) { doc.activeObjectId = null; } if (doc.selection && !tableById(doc.selection.tableId)) doc.selection = null; if (doc.selection) clampSelection(doc.selection.tableId); } finally { applyingExternal = false; } cancelSave(); } /** 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(); } /** Datei › Neu: an empty document. */ function newDocument() { replaceDocument(createBlankDocument()); } /** Datei › Muster: the pre-filled sample document. */ function resetToSeed() { replaceDocument(createSeedDocument()); } /** Append fully-formed sheets (JSON-append / XLSX import). */ function appendSheets(sheets: Sheet[]) { for (const s of sheets) { let id = s.id; while (doc.sheets.some((o) => o.id === id)) id = uid('sheet'); const sn = new Set(doc.sheets.map((x) => x.name.toLowerCase())); doc.sheets.push({ ...s, id, name: uniqueName(s.name, sn) }); doc.activeSheetId = id; } syncWorkbook(); markDirty(); } /** Add a new table object built from a cell map (CSV "as new table"). */ function addTableFromCells( sheetId: string, name: string, cells: Record, rows: number, cols: number, ): string { const id = addTable(sheetId); const t = tableById(id)!; t.cells = { ...cells }; t.rows = Math.max(rows, 1); t.cols = Math.max(cols, 1); renameObject(id, name); // also syncs the workbook return id; } /** Replace an existing table's whole cell map (CSV "replace active table"). */ function replaceTableCells( tableId: string, cells: Record, rows: number, cols: number, ) { const t = tableById(tableId); if (!t) return; t.cells = { ...cells }; t.rows = Math.max(rows, 1); t.cols = Math.max(cols, 1); t.colWidths = {}; syncWorkbook(); markDirty(); } function snapshot(): DocumentState { return JSON.parse(JSON.stringify(doc)) as DocumentState; } const api = { doc, recalcTick, // undo / redo undo, redo, canUndo, canRedo, // sheets sheetById, activeSheet, setActiveSheet, addSheet, renameSheet, deleteSheet, moveSheet, // objects objectById, sheetIdOfObject, tablesOf, activeObject, activeTable, addTable, addChart, addText, setTextContent, setTextFormat, resizeText, addButton, setButtonLabel, setButtonMacroId, resizeButton, renameObject, deleteObject, moveObject, // custom functions customFunctionNames, addCustomFunction, updateCustomFunction, deleteCustomFunction, addNamedRange, updateNamedRange, deleteNamedRange, // macros addMacro, updateMacro, deleteMacro, runMacro, resizeChart, bringToFront, setChartView, setChartSource, selectObject, setObjectBg, setObjectTextColor, setTableHideChromeUnfocused, // table grid tableById, cellInput, cellValue, cellDisplay, cellIsNumeric, cellIsError, cellIsFormula, cellGraphSelf, cellGraphNeighbors, openDependencyGraph, closeDependencyGraph, setCellInput, clearRange, addRows, addColumns, removeLastRow, removeLastColumn, setColWidth, setRowHeight, setTableRowHeight, rowHeightOf, setTableRows, setTableCols, insertRow, deleteRow, insertColumn, deleteColumn, setTableView, // cell formatting cellFormat, setRangeFormat, clearRangeFormat, selectionFormat, applySelectionFormat, clearSelectionFormat, clearSelectionCells, // number-format locale effectiveLocale, activeLocale, setDocumentLocale, setSheetLocale, setObjectLocale, // per-sheet appearance + zoom + csv default setSheetBackground, setSheetForeground, setSheetShowGrid, setSheetGridSize, setSheetZoom, activeZoom, setZoom, setCsvDelimiter, // view-only ui ui, // selection setSelection, selectCell, addToSelection, extendActiveRange, addRangeToSelection, selectionRects, activeCell, activeCellName, selectionRangeName, activeCellInput, selectionAggregate, // clipboard copySelection, cutSelection, pasteClipboard, cancelClipboard, hasClipboard, clipboardRect, fillSelection, // formula "point mode" registerFormulaEditor, noteFormulaTyped, formulaPointModeActive, functionSuggestions, nameBoxSuggestions, jumpToNameInput, pointClick, pointDrag, pointRect, formulaRefs, moveFormulaRef, // document replaceDocument, newDocument, resetToSeed, appendSheets, addTableFromCells, replaceTableCells, snapshot, flushSave, hasPendingSave, cancelSave, applyExternalState, setUndoProvider, engine: () => wb, }; // one gate instead of a check in every mutation: while the document is // read-only, the content-changing entry points do nothing let lastBlockNote = 0; const table = api as unknown as Record unknown>; for (const name of READ_ONLY_GUARDED) { const fn = table[name]; table[name] = (...args: unknown[]) => { if (ui.readOnly) { if (Date.now() - lastBlockNote > 3000) { lastBlockNote = Date.now(); readOnlyNotifier?.(ui.readOnly); } return undefined; } return fn(...args); }; } return api; } /** Store functions that change document content, blocked while read-only. */ const READ_ONLY_GUARDED = [ 'undo', 'redo', 'addSheet', 'renameSheet', 'deleteSheet', 'moveSheet', 'addTable', 'addChart', 'addText', 'setTextContent', 'setTextFormat', 'resizeText', 'addButton', 'setButtonLabel', 'setButtonMacroId', 'resizeButton', 'renameObject', 'deleteObject', 'moveObject', 'resizeChart', 'bringToFront', 'setChartView', 'setChartSource', 'setObjectBg', 'setObjectTextColor', 'setTableHideChromeUnfocused', 'addCustomFunction', 'updateCustomFunction', 'deleteCustomFunction', 'addNamedRange', 'updateNamedRange', 'deleteNamedRange', 'addMacro', 'updateMacro', 'deleteMacro', 'runMacro', 'setCellInput', 'clearRange', 'addRows', 'addColumns', 'removeLastRow', 'removeLastColumn', 'setColWidth', 'setRowHeight', 'setTableRowHeight', 'setTableRows', 'setTableCols', 'insertRow', 'deleteRow', 'insertColumn', 'deleteColumn', 'setTableView', 'setRangeFormat', 'clearRangeFormat', 'applySelectionFormat', 'clearSelectionFormat', 'clearSelectionCells', 'setDocumentLocale', 'setSheetLocale', 'setObjectLocale', 'setSheetBackground', 'setSheetForeground', 'setSheetShowGrid', 'setSheetGridSize', 'setCsvDelimiter', 'cutSelection', 'pasteClipboard', 'fillSelection', 'newDocument', 'resetToSeed', 'appendSheets', 'addTableFromCells', 'replaceTableCells', ] as const; /** Tells the user why an edit was ignored (a toast) -- set by the app. */ let readOnlyNotifier: ((why: string) => void) | null = null; export function setReadOnlyNotifier(fn: ((why: string) => void) | null) { readOnlyNotifier = fn; } let instance: ReturnType | null = null; export function useDocument() { if (!instance) instance = createStore(); return instance; } export type DocumentStore = ReturnType;