/** * 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, watch } from 'vue'; import { Workbook, toA1, isError, tokenize, looksLikeCell, parseA1, parseFormula, parseFunctionBlock, hasFunction, collectCalledFunctionNames, collectCalledFunctionNamesInBlock, declaredBlockNames, findInvalidForSource, blockAlwaysReturns, type CellValue, type TableModel, type Token, type FunctionDef, } from '../engine'; import { formatValue, isNumeric, isNumberLocale, DEFAULT_LOCALE, type NumberLocale, } from './format'; import type { CellCoord, CellFormat, ChartObject, CustomFunction, DocumentState, Sheet, SelectionState, SheetObject, TableObject, TableView, TextObject, } from './types'; import { defaultTableView, DEFAULT_ROW_H } from './types'; import { createSeedDocument } from '../data/seed'; import { validateDocument } from '../io/json'; const STORAGE_KEY = 'numbers-study:doc:v2'; 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, })); } /** 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). */ 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]; 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; } function loadInitial(): DocumentState { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { // validateDocument migrates v1, validates + *normalises* v2 (fills in // `view` and other defaults for documents saved by an older build). return validateDocument(JSON.parse(raw)); } } catch { /* missing / corrupt / stale-schema -> clean fallback */ } return createSeedDocument(); } // ---- store singleton -------------------------------------------------- function createStore() { const doc = reactive(loadInitial()); let wb = new Workbook(toTableModels(doc), toFunctionDefs(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 }); /** 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; 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)); bump(); } // ---- persistence (debounced) -------------------------------------- let persistTimer: ReturnType | undefined; watch( () => doc, () => { clearTimeout(persistTimer); persistTimer = setTimeout(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(doc)); } catch { /* quota / privacy mode -- ignore */ } }, 250); }, { deep: true }, ); // ---- 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[] = []; const contentFingerprint = (d: DocumentState) => JSON.stringify({ sheets: d.sheets, locale: d.locale, csvDelimiter: d.csvDelimiter, customFunctions: d.customFunctions, }); 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 while undo()/redo() are themselves applying a snapshot -- the watch // below must not treat that as "a new edit" (which would immediately wipe // the very redoStack/undoStack entry undo()/redo() just pushed). let applyingHistory = false; 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++; } watch( () => doc, () => { if (applyingHistory) return; // undo()/redo() reconciles historyPending/Fingerprint itself const dirty = contentFingerprint(doc) !== historyFingerprint; // the *first* mutation of a new burst -- a fresh action just started, // so any redo branch from a previous undo is no longer reachable. // Done here (synchronously, on the transition) rather than debounced // in commitHistoryStep(), so it matches real undo/redo semantics // everywhere else: a new edit kills "redo" immediately, not 500ms later. if (dirty && !historyPending.value) { redoStack.length = 0; historyTick.value++; } historyPending.value = dirty; if (!dirty) return; // pure selection/UI change, or settled back to a no-op clearTimeout(historyTimer); historyTimer = setTimeout(commitHistoryStep, HISTORY_COALESCE_MS); }, // sync: historyPending/redoStack must be accurate the instant a mutation // happens (so a bound "Undo"/"Redo" button is never briefly wrong) -- // safe to fire many times during one multi-cell action (paste, fill, a // resize drag) because this only *reschedules* the debounced commit, // never calls it directly; that stays coalesced into one step either way. { deep: true, flush: 'sync' }, ); /** 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 canUndo = computed(() => (historyTick.value, undoStack.length > 0 || historyPending.value)); const canRedo = computed(() => (historyTick.value, redoStack.length > 0)); function 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(); applyingHistory = true; applyDocumentState(prev); applyingHistory = false; // reconcile manually, matching what applyDocumentState() just did -- // the watch above skipped this while applyingHistory was true historySnapshot = snapshot(); historyFingerprint = contentFingerprint(doc); historyPending.value = false; historyTick.value++; } function redo() { clearTimeout(historyTimer); const next = redoStack.pop(); if (!next) return; undoStack.push(snapshot()); if (undoStack.length > MAX_HISTORY) undoStack.shift(); applyingHistory = true; applyDocumentState(next); applyingHistory = false; historySnapshot = snapshot(); historyFingerprint = contentFingerprint(doc); historyPending.value = false; historyTick.value++; } // ---- id / name helpers ------------------------------------------ let seq = 0; const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}`; 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); 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; } function addSheet(name?: string): string { const id = uid('sheet'); doc.sheets.push({ id, name: uniqueName(name || 'Blatt', sheetNames()), objects: [] }); setActiveSheet(id); 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; } 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 } 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); } // ---- 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; } function setSheetForeground(sheetId: string, color: string | null) { const s = sheetById(sheetId); if (!s) return; if (color) s.foreground = color; else delete s.foreground; } function setSheetShowGrid(sheetId: string, show: boolean) { const s = sheetById(sheetId); if (s) s.showGrid = show; } function setSheetZoom(sheetId: string, z: number) { const s = sheetById(sheetId); if (s) s.zoom = clampZoom(z); } /** 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(); } 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(); } } function setCsvDelimiter(d: ',' | ';') { doc.csvDelimiter = d === ';' ? ';' : ','; } // ---- 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(); 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(); 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(); return id; } function setTextContent(objId: string, text: string) { const o = objectById(objId); if (o?.kind === 'text') o.text = text; } 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; } 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)); } 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()), ); o.name = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean; syncWorkbook(); // formulas referencing the old/new name must re-resolve } else { o.name = clean; } } 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(); } // ---- 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']); /** * 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); } 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(); 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(); } 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 } 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)); } 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)); } 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); } function setChartView(objId: string, view: 'bar' | 'line') { const o = objectById(objId); if (o?.kind === 'chart') o.view = view; } function setChartSource(objId: string, tableId: string) { const o = objectById(objId); if (o?.kind === 'chart') o.sourceTableId = tableId; } function selectObject(objId: string | null) { doc.activeObjectId = objId; if (!objId) return; const sheetId = sheetIdOfObject(objId); if (sheetId) doc.activeSheetId = sheetId; const o = objectById(objId); if (o?.kind === 'table' && doc.selection?.tableId !== objId) { doc.selection = { tableId: objId, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } }; } } // ---- 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), tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto', ); } // ---- number-format locale (object -> sheet -> document cascade) --- /** Effective locale for a canvas object (table or chart). */ function effectiveLocale(objId: string | null): NumberLocale { const o = objId ? objectById(objId) : undefined; if (o && o.kind !== 'text' && 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)) doc.locale = l; } 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; } function setObjectLocale(objId: string, l: string | null) { const o = objectById(objId); if (!o || o.kind === 'text') return; if (l && isNumberLocale(l)) o.locale = l; else delete o.locale; } 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('='); // ---- 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+$/, ''); if (trimmed === '') delete table.cells[key]; else table.cells[key] = trimmed; if (col >= table.cols) table.cols = col + 1; if (row >= table.rows) table.rows = row + 1; wb.setCell(tableId, col, row, trimmed); bump(); } 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) t.rows += n; }; const addColumns = (tableId: string, n = 1) => { const t = tableById(tableId); if (t) t.cols += n; }; 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(); } 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(); } const MIN_COL_W = 24; const MIN_ROW_H = 14; function setColWidth(tableId: string, col: number, width: number) { const t = tableById(tableId); if (t) t.colWidths[col] = Math.max(MIN_COL_W, Math.round(width)); } /** Numbers-style proportional resize: scale every column width by `factor`. */ function scaleTableColumns(tableId: string, factor: number, base: number) { const t = tableById(tableId); if (!t) return; for (let c = 0; c < t.cols; c++) { const cur = t.colWidths[c] ?? base; t.colWidths[c] = Math.max(MIN_COL_W, Math.round(cur * factor)); } } /** 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))); } /** Default row height for the whole table. */ function setTableRowHeight(tableId: string, height: number) { const t = tableById(tableId); if (t) t.rowHeight = Math.min(240, Math.max(MIN_ROW_H, Math.round(height))); } 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(); } 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(); } // ---- cell formatting (alignment, number format, colours, bold/italic) --- 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(); } 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(); } /** 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); } // ---- 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; } } /** 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 }; 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, }; 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 } }; } /** 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; } /** 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; } /** 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('='); } /** `'` needs quoting whenever a name isn't a bare identifier (spaces, * punctuation, ...) -- shared by the table- and sheet-qualifier cases. */ function quoteQualifier(name: string): string { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`; } 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; } /** 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 { return applyPointRef(tableId, toA1(col, row)); } /** 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 a = toA1(Math.min(c0, c1), Math.min(r0, r1)); const b = toA1(Math.max(c0, c1), Math.max(r0, r1)); return applyPointRef(tableId, a === b ? a : `${a}:${b}`); } // 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 ?? []; syncWorkbook(); } /** 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(); } 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(); } /** 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(); } function snapshot(): DocumentState { return JSON.parse(JSON.stringify(doc)) as DocumentState; } return { 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, renameObject, deleteObject, moveObject, // custom functions customFunctionNames, addCustomFunction, updateCustomFunction, deleteCustomFunction, resizeChart, bringToFront, setChartView, setChartSource, selectObject, setObjectBg, setObjectTextColor, // table grid tableById, cellInput, cellValue, cellDisplay, cellIsNumeric, cellIsError, cellIsFormula, setCellInput, clearRange, addRows, addColumns, removeLastRow, removeLastColumn, setColWidth, scaleTableColumns, setRowHeight, setTableRowHeight, rowHeightOf, setTableRows, setTableCols, 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, 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, pointClick, pointDrag, // document replaceDocument, resetToSeed, appendSheets, addTableFromCells, replaceTableCells, snapshot, engine: () => wb, }; } let instance: ReturnType | null = null; export function useDocument() { if (!instance) instance = createStore(); return instance; } export type DocumentStore = ReturnType;