Files
numolus/app/frontend/store/document.ts
T
wkhandClaude Opus 5.5 7fce972fdc Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of
taking turns behind an edit lock.

- The document's content is a sequence of Yjs updates (document_updates);
  DocumentChannel stores and relays them immediately, checks access on
  subscribe and again whenever links or memberships change.
- app/frontend/collab/: the store stays position-based; a binding maps it
  to a Yjs form with stable row/column ids, so a row inserted by one
  person doesn't misplace what another is typing. Remote changes are
  applied only after pending local ones went into Yjs.
- Per-person undo (Y.UndoManager), presence via awareness: the others'
  selections as coloured boxes with names, avatars in the toolbar.
- Existing JSON documents convert on first open, deterministically, so
  two people opening one at once end up with the same Yjs state.
- Editors compact the stored updates after 200 and keep a JSON snapshot
  current for export and duplicating. Edit lock and polling are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:41:02 +02:00

3298 lines
129 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
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<boolean>;
canRedo: Ref<boolean>;
}
/** 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<string, ClipboardCell>;
/** 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];
// 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<DocumentState>(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<ClipboardData | null>(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<FormulaEditorHandle | null>(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), 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<typeof setTimeout> | 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<typeof setTimeout> | 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<UndoProvider | null>(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>): 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<TableObject | undefined>(() => {
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<Omit<TextObject, 'kind' | 'id'>>) {
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<CustomFunction, 'params' | 'body' | 'kind'>): Set<string> {
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<string, Set<string>>): boolean {
const state = new Map<string, 1 | 2>(); // 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<string>;
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<string, Set<string>>();
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<T>(
map: Record<string, T>,
axis: Axis,
at: number,
delta: 1 | -1,
): Record<string, T> {
const out: Record<string, T> = {};
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<number, number>, at: number, delta: 1 | -1): Record<number, number> {
const out: Record<number, number> = {};
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) ---
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<Record<keyof CellFormat, unknown>>,
) {
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<string, unknown>)[field];
} else {
(cur as Record<string, unknown>)[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<CellFormat>(() => {
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<Record<keyof CellFormat, unknown>>) {
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<TableView>) {
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<string> {
const s = doc.selection;
const keys = new Set<string>();
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<string, ClipboardCell>();
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<Record<keyof CellFormat, unknown>>,
);
}
}
}
}
}
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<string, ClipboardCell>();
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<Record<keyof CellFormat, unknown>>);
}
}
}
}
// ---- 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('=');
}
/** 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<typeof parseFormula> | 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;
}
/** 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<CellCoord>(() => 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<string, string>,
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<string, string>,
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,
// 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<string, (...args: unknown[]) => 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<typeof createStore> | null = null;
export function useDocument() {
if (!instance) instance = createStore();
return instance;
}
export type DocumentStore = ReturnType<typeof createStore>;