Files
numolus/src/store/document.ts
T
wkhandClaude Sonnet 5 01ea18106c Real control flow for "Mit Schleife" functions: DO/END, IF/ELSE/END, RETURN anywhere
Replaces the previous FOR-only, single-statement-body, static-unrolling
design with genuine control flow: FOR var IN source DO <block> END loop
bodies with any number of statements (including nested loops/IFs),
IF cond THEN <block> [ELSE <block>] END branching (nestable in any
block), and RETURN as an ordinary statement usable anywhere -- it
aborts execution immediately with its value, including breaking out of
a loop early.

This required a real, if small, architecture change: an IF branch or an
early RETURN depends on actual cell values, which the previous "unroll
everything into one static formula at parse time" approach can't
resolve (loop *counts* are knowable up front from a range's size;
branch *outcomes* aren't). A kind: 'block' call is now inlined only as
far as resolving nested function calls (expandBlock, parse-time, same
inside-out principle as before) and wrapped in a new BlockCallNode;
its own control flow is interpreted at evaluation time by a small
tree-walking interpreter added to evaluator.ts (execBlock/execStatement,
a thrown ReturnSignal for early exit), reusing userFunctions.ts's
substitute() (now exported) to resolve parameter/local-variable names
against the current environment. Dependency tracking stays fully
correct and conservative: collectRefs() walks into a BlockCallNode's
block (new collectRefsInBlock), so a cell referenced only in a branch
that doesn't happen to run is still a tracked precedent -- same
principle as an ordinary IF() in any spreadsheet.

Also added: comparison operators (= <> > < >= <=, result 1/0) as a new,
loosest-binding precedence level in the *general* expression grammar
(not just inside a block's IF) -- needed for IF conditions to be useful
at all. A definition-time static check (blockAlwaysReturns) rejects a
function at save time when it's not obviously guaranteed to reach
RETURN on every path (an IF without an ELSE, or one where only one
branch returns); the interpreter's own runtime "ended without RETURN"
-> #ERR remains the actual enforcement for cases the static check can't
see (e.g. a loop over an empty range).

Nested loops (FOR inside FOR) and nested IFs work as an emergent
property of the recursive statement grammar -- verified with dedicated
tests, not just assumed.

257 tests passing (30 net new/rewritten for this change), vue-tsc/
eslint/build clean. Verified live in the running app: a FIRSTOVER(range;
threshold) function using a FOR loop with a nested IF and an early
RETURN, confirmed it returns the first match, confirmed changing a
cell *past* the match point doesn't affect the result while changing
the match point itself does, and confirmed a function without a
guaranteed RETURN is rejected inline at save time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:41:55 +02:00

1781 lines
66 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, watch } from 'vue';
import {
Workbook,
toA1,
isError,
tokenize,
looksLikeCell,
parseA1,
parseFormula,
parseFunctionBlock,
hasFunction,
collectCalledFunctionNames,
collectCalledFunctionNamesInBlock,
declaredBlockNames,
findInvalidForSource,
blockAlwaysReturns,
type CellValue,
type TableModel,
type Token,
type FunctionDef,
} from '../engine';
import {
formatValue,
isNumeric,
isNumberLocale,
DEFAULT_LOCALE,
type NumberLocale,
} from './format';
import type {
CellCoord,
CellFormat,
ChartObject,
CustomFunction,
DocumentState,
Sheet,
SelectionState,
SheetObject,
TableObject,
TableView,
TextObject,
} from './types';
import { defaultTableView, DEFAULT_ROW_H } from './types';
import { createSeedDocument } from '../data/seed';
import { validateDocument } from '../io/json';
const STORAGE_KEY = 'numbers-study:doc:v2';
const CASCADE = 28; // px offset for successive inserted objects
// ---- (de)serialisation helpers ------------------------------------------
function allTables(doc: DocumentState): TableObject[] {
const out: TableObject[] = [];
for (const s of doc.sheets) {
for (const o of s.objects) if (o.kind === 'table') out.push(o);
}
return out;
}
function toTableModels(doc: DocumentState): TableModel[] {
return doc.sheets.flatMap((s) =>
s.objects
.filter((o): o is TableObject => o.kind === 'table')
.map((t) => ({
id: t.id,
name: t.name,
sheetName: s.name,
cols: t.cols,
rows: t.rows,
inputs: new Map(Object.entries(t.cells)),
})),
);
}
function toFunctionDefs(doc: DocumentState): FunctionDef[] {
return doc.customFunctions.map((f) => ({
id: f.id,
name: f.name,
params: f.params,
body: f.body,
kind: f.kind,
}));
}
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
type Rect = { c0: number; c1: number; r0: number; r1: number };
/** One cell of a copy/cut buffer, relative to the buffer's top-left corner. */
interface ClipboardCell {
raw: string;
format?: CellFormat;
}
interface ClipboardData {
tableId: string;
rect: Rect;
width: number;
height: number;
/** "dc,dr" (offset from the buffer's top-left) -> cell. Empty/unformatted cells are absent. */
cells: Map<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];
if (tok.type !== 'IDENT' || !looksLikeCell(tok.value)) continue;
const next = toks[i + 1];
if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell
const prev = toks[i - 1];
const qualified = !!prev && (prev.type === 'BANG' || prev.type === 'DBLCOLON');
if (restrict && qualified) continue; // cut: a reference into another table never moved
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
if (
restrict &&
!qualified &&
!(col >= restrict.c0 && col <= restrict.c1 && row >= restrict.r0 && row <= restrict.r1)
) {
continue;
}
const nc = !restrict && colAbs ? col : Math.max(0, col + dCol);
const nr = !restrict && rowAbs ? row : Math.max(0, row + dRow);
out += body.slice(last, tok.pos) + toA1(nc, nr, colAbs, rowAbs);
last = tok.pos + tok.value.length;
}
out += body.slice(last);
return '=' + out;
}
function loadInitial(): DocumentState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
// validateDocument migrates v1, validates + *normalises* v2 (fills in
// `view` and other defaults for documents saved by an older build).
return validateDocument(JSON.parse(raw));
}
} catch {
/* missing / corrupt / stale-schema -> clean fallback */
}
return createSeedDocument();
}
// ---- store singleton --------------------------------------------------
function createStore() {
const doc = reactive<DocumentState>(loadInitial());
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
const recalcTick = ref(0);
const bump = () => (recalcTick.value += 1);
/** View-only state: not part of the document, not persisted. */
const ui = reactive({ settingsOpen: false });
/** Copy/cut buffer -- not part of the persisted document. */
const clipboard = ref<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));
bump();
}
// ---- persistence (debounced) --------------------------------------
let persistTimer: ReturnType<typeof setTimeout> | undefined;
watch(
() => doc,
() => {
clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(doc));
} catch {
/* quota / privacy mode -- ignore */
}
}, 250);
},
{ deep: true },
);
// ---- undo / redo (debounced content snapshots) -----------------------
/**
* Snapshot-based, like the JSON export/import round-trip already is
* (`snapshot()`/`replaceDocument()`) rather than a patch/command log --
* simple and provably correct for a document this size.
*
* Selection/active-object are deliberately excluded from what counts as a
* "change": moving the cursor around must never itself become an undo
* step. A burst of rapid mutations (column-resize drag fires on every
* mousemove; so would per-keystroke typing, if the editors pushed straight
* into the store instead of committing once) is coalesced into a single
* step by waiting for HISTORY_COALESCE_MS of quiet before recording one --
* every mutating action in this store already commits once per discrete
* user action (a keystroke only touches a component-local draft; only
* `setCellInput` et al. touch `doc`), so in practice this mainly matters
* for the resize-drag case. Kept short (well under normal typing/click
* pace) so two genuinely separate quick actions still don't merge -- a
* drag's mousemove events are only ~16-30ms apart, comfortably under it.
*/
const HISTORY_COALESCE_MS = 150;
const MAX_HISTORY = 100;
const undoStack: DocumentState[] = [];
const redoStack: DocumentState[] = [];
const contentFingerprint = (d: DocumentState) =>
JSON.stringify({
sheets: d.sheets,
locale: d.locale,
csvDelimiter: d.csvDelimiter,
customFunctions: d.customFunctions,
});
let historySnapshot = snapshot();
let historyFingerprint = contentFingerprint(historySnapshot);
let historyTimer: ReturnType<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 while undo()/redo() are themselves applying a snapshot -- the watch
// below must not treat that as "a new edit" (which would immediately wipe
// the very redoStack/undoStack entry undo()/redo() just pushed).
let applyingHistory = false;
function commitHistoryStep() {
clearTimeout(historyTimer);
const fp = contentFingerprint(doc);
historyPending.value = false;
if (fp === historyFingerprint) return; // settled back to a no-op, or nothing pending
undoStack.push(historySnapshot);
if (undoStack.length > MAX_HISTORY) undoStack.shift();
historySnapshot = snapshot();
historyFingerprint = fp;
historyTick.value++;
}
watch(
() => doc,
() => {
if (applyingHistory) return; // undo()/redo() reconciles historyPending/Fingerprint itself
const dirty = contentFingerprint(doc) !== historyFingerprint;
// the *first* mutation of a new burst -- a fresh action just started,
// so any redo branch from a previous undo is no longer reachable.
// Done here (synchronously, on the transition) rather than debounced
// in commitHistoryStep(), so it matches real undo/redo semantics
// everywhere else: a new edit kills "redo" immediately, not 500ms later.
if (dirty && !historyPending.value) {
redoStack.length = 0;
historyTick.value++;
}
historyPending.value = dirty;
if (!dirty) return; // pure selection/UI change, or settled back to a no-op
clearTimeout(historyTimer);
historyTimer = setTimeout(commitHistoryStep, HISTORY_COALESCE_MS);
},
// sync: historyPending/redoStack must be accurate the instant a mutation
// happens (so a bound "Undo"/"Redo" button is never briefly wrong) --
// safe to fire many times during one multi-cell action (paste, fill, a
// resize drag) because this only *reschedules* the debounced commit,
// never calls it directly; that stays coalesced into one step either way.
{ deep: true, flush: 'sync' },
);
/** Wipes undo/redo history and re-baselines it on the current doc -- used
* when a *different* document is loaded wholesale (replaceDocument()),
* never by undo()/redo() themselves. */
function resetHistory() {
clearTimeout(historyTimer);
undoStack.length = 0;
redoStack.length = 0;
historySnapshot = snapshot();
historyFingerprint = contentFingerprint(historySnapshot);
historyPending.value = false;
historyTick.value++;
}
const canUndo = computed(() => (historyTick.value, undoStack.length > 0 || historyPending.value));
const canRedo = computed(() => (historyTick.value, redoStack.length > 0));
function undo() {
commitHistoryStep(); // flush a pending (not yet coalesced) change first
const prev = undoStack.pop();
if (!prev) return;
redoStack.push(snapshot());
if (redoStack.length > MAX_HISTORY) redoStack.shift();
applyingHistory = true;
applyDocumentState(prev);
applyingHistory = false;
// reconcile manually, matching what applyDocumentState() just did --
// the watch above skipped this while applyingHistory was true
historySnapshot = snapshot();
historyFingerprint = contentFingerprint(doc);
historyPending.value = false;
historyTick.value++;
}
function redo() {
clearTimeout(historyTimer);
const next = redoStack.pop();
if (!next) return;
undoStack.push(snapshot());
if (undoStack.length > MAX_HISTORY) undoStack.shift();
applyingHistory = true;
applyDocumentState(next);
applyingHistory = false;
historySnapshot = snapshot();
historyFingerprint = contentFingerprint(doc);
historyPending.value = false;
historyTick.value++;
}
// ---- id / name helpers ------------------------------------------
let seq = 0;
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}`;
function uniqueName(base: string, taken: Set<string>): string {
const b = base.trim() || 'Element';
if (!taken.has(b.toLowerCase())) return b;
let i = 2;
while (taken.has(`${b} ${i}`.toLowerCase())) i++;
return `${b} ${i}`;
}
const objectNames = () =>
new Set(doc.sheets.flatMap((s) => s.objects).map((o) => o.name.toLowerCase()));
const sheetNames = () => new Set(doc.sheets.map((s) => s.name.toLowerCase()));
/** Table names are unique *per sheet* only -- two different sheets may
* each hold a table called "Umsatz" (see engine/types.ts's EngineHost
* doc comment for how a formula then resolves it). */
const tableNamesOnSheet = (sheetId: string) =>
new Set(tablesOf(sheetId).map((t) => t.name.toLowerCase()));
const objectNamesOnSheet = (sheetId: string) =>
new Set((sheetById(sheetId)?.objects ?? []).map((o) => o.name.toLowerCase()));
// ---- lookups --------------------------------------------------
const sheetById = (id: string): Sheet | undefined => doc.sheets.find((s) => s.id === id);
const activeSheet = computed(() => sheetById(doc.activeSheetId) ?? doc.sheets[0]);
function objectById(id: string | null): SheetObject | undefined {
if (!id) return undefined;
for (const s of doc.sheets) {
const o = s.objects.find((x) => x.id === id);
if (o) return o;
}
return undefined;
}
function sheetIdOfObject(id: string): string | undefined {
return doc.sheets.find((s) => s.objects.some((o) => o.id === id))?.id;
}
const tableById = (id: string): TableObject | undefined => {
const o = objectById(id);
return o && o.kind === 'table' ? o : undefined;
};
const tablesOf = (sheetId: string): TableObject[] =>
(sheetById(sheetId)?.objects.filter((o): o is TableObject => o.kind === 'table') ?? []);
const activeObject = computed(() => objectById(doc.activeObjectId));
const activeTable = computed<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;
}
function addSheet(name?: string): string {
const id = uid('sheet');
doc.sheets.push({ id, name: uniqueName(name || 'Blatt', sheetNames()), objects: [] });
setActiveSheet(id);
return id;
}
function renameSheet(id: string, name: string) {
const s = sheetById(id);
const clean = name.trim();
if (!s || !clean) return;
const others = new Set(doc.sheets.filter((o) => o.id !== id).map((o) => o.name.toLowerCase()));
s.name = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean;
}
function deleteSheet(id: string) {
if (doc.sheets.length <= 1) return;
const idx = doc.sheets.findIndex((s) => s.id === id);
if (idx < 0) return;
const hadTables = tablesOf(id).length > 0;
doc.sheets.splice(idx, 1);
if (doc.activeSheetId === id) setActiveSheet(doc.sheets[Math.max(0, idx - 1)].id);
if (hadTables) syncWorkbook(); // dependents elsewhere become #REF
}
function moveSheet(from: number, to: number) {
if (from < 0 || to < 0 || from >= doc.sheets.length || to >= doc.sheets.length || from === to)
return;
const [s] = doc.sheets.splice(from, 1);
doc.sheets.splice(to, 0, s);
}
// ---- per-sheet appearance + zoom -----------------------------
function setSheetBackground(sheetId: string, color: string | null) {
const s = sheetById(sheetId);
if (!s) return;
if (color) s.background = color;
else delete s.background;
}
function setSheetForeground(sheetId: string, color: string | null) {
const s = sheetById(sheetId);
if (!s) return;
if (color) s.foreground = color;
else delete s.foreground;
}
function setSheetShowGrid(sheetId: string, show: boolean) {
const s = sheetById(sheetId);
if (s) s.showGrid = show;
}
function setSheetZoom(sheetId: string, z: number) {
const s = sheetById(sheetId);
if (s) s.zoom = clampZoom(z);
}
/** Zoom of the currently active sheet (used by the canvas + zoom bar). */
const activeZoom = computed(() => activeSheet.value.zoom ?? 1);
const setZoom = (z: number) => setSheetZoom(doc.activeSheetId, z);
// ---- per-object background + text colour (table / chart / text) ----
function setObjectBg(objId: string, color: string | null) {
const o = objectById(objId);
if (!o) return;
if (color) o.bg = color;
else delete o.bg;
if (o.kind !== 'table') bump();
}
function setObjectTextColor(objId: string, color: string | null) {
const o = objectById(objId);
if (!o) return;
if (o.kind === 'text') {
if (color) o.color = color;
else delete o.color;
bump();
} else {
if (color) o.fg = color;
else delete o.fg;
if (o.kind === 'chart') bump();
}
}
function setCsvDelimiter(d: ',' | ';') {
doc.csvDelimiter = d === ';' ? ';' : ',';
}
// ---- canvas objects ----------------------------------------
function nextCascade(sheet: Sheet): { x: number; y: number } {
const n = sheet.objects.length;
return { x: 40 + (n % 6) * CASCADE, y: 40 + (n % 6) * CASCADE };
}
function addTable(sheetId: string, pos?: { x: number; y: number }): string {
const sheet = sheetById(sheetId);
if (!sheet) return '';
const id = uid('tbl');
const p = pos ?? nextCascade(sheet);
const table: TableObject = {
kind: 'table',
id,
name: uniqueName('Tabelle', new Set([...tableNamesOnSheet(sheetId), ...objectNamesOnSheet(sheetId)])),
x: Math.max(0, Math.round(p.x)),
y: Math.max(0, Math.round(p.y)),
rows: 12,
cols: 6,
colWidths: {},
cells: {},
view: defaultTableView(),
};
sheet.objects.push(table);
doc.activeObjectId = id;
doc.activeSheetId = sheetId;
doc.selection = { tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
syncWorkbook();
return id;
}
function addChart(
sheetId: string,
pos?: { x: number; y: number },
sourceTableId?: string | null,
): string {
const sheet = sheetById(sheetId);
if (!sheet) return '';
const id = uid('cht');
const p = pos ?? nextCascade(sheet);
const src = sourceTableId ?? tablesOf(sheetId)[0]?.id ?? allTables(doc)[0]?.id ?? null;
const chart: ChartObject = {
kind: 'chart',
id,
name: uniqueName('Diagramm', objectNames()),
x: Math.max(0, Math.round(p.x)),
y: Math.max(0, Math.round(p.y)),
width: 460,
height: 300,
view: 'bar',
sourceTableId: src,
};
sheet.objects.push(chart);
doc.activeObjectId = id;
doc.activeSheetId = sheetId;
bump();
return id;
}
function addText(sheetId: string, pos?: { x: number; y: number }): string {
const sheet = sheetById(sheetId);
if (!sheet) return '';
const id = uid('txt');
const p = pos ?? nextCascade(sheet);
const text: TextObject = {
kind: 'text',
id,
name: uniqueName('Text', objectNames()),
x: Math.max(0, Math.round(p.x)),
y: Math.max(0, Math.round(p.y)),
width: 260,
height: 96,
text: 'Text …',
fontSize: 15,
align: 'left',
};
sheet.objects.push(text);
doc.activeObjectId = id;
doc.activeSheetId = sheetId;
bump();
return id;
}
function setTextContent(objId: string, text: string) {
const o = objectById(objId);
if (o?.kind === 'text') o.text = text;
}
function setTextFormat(objId: string, patch: Partial<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;
}
function resizeText(objId: string, width: number, height: number) {
const o = objectById(objId);
if (o?.kind !== 'text') return;
o.width = Math.max(80, Math.round(width));
o.height = Math.max(32, Math.round(height));
}
function renameObject(objId: string, name: string) {
const o = objectById(objId);
const clean = name.trim();
if (!o || !clean) return;
if (o.kind === 'table') {
// uniqueness is per sheet only -- see engine/types.ts's EngineHost
const sheetId = sheetIdOfObject(objId);
const others = new Set(
(sheetId ? tablesOf(sheetId) : allTables(doc))
.filter((t) => t.id !== objId)
.map((t) => t.name.toLowerCase()),
);
o.name = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean;
syncWorkbook(); // formulas referencing the old/new name must re-resolve
} else {
o.name = clean;
}
}
function deleteObject(objId: string) {
const sheetId = sheetIdOfObject(objId);
const sheet = sheetId ? sheetById(sheetId) : undefined;
if (!sheet) return;
const o = sheet.objects.find((x) => x.id === objId);
const isTable = o?.kind === 'table';
sheet.objects = sheet.objects.filter((x) => x.id !== objId);
if (doc.activeObjectId === objId) doc.activeObjectId = null;
if (doc.selection?.tableId === objId) doc.selection = null;
// charts bound to a deleted table keep a dangling id -> they render empty
if (isTable) syncWorkbook();
else bump();
}
// ---- custom functions (document-level formula library) ----------------
// Created/edited/removed exclusively through the "𝑓 Funktionen" UI. A call
// is inlined into the caller's AST at parse time (engine/userFunctions.ts)
// -- renaming/deleting one behaves exactly like renaming/deleting a table:
// existing formula text is left as-is and becomes #NAME? / #REF on the next
// syncWorkbook(), there is no find-and-replace across stored formulas.
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const customFunctionNames = () => new Set(doc.customFunctions.map((f) => f.name.toLowerCase()));
/** Every (upper-cased) function name a stored custom function's body calls,
* tolerant of a body that -- in theory -- no longer parses (shouldn't
* happen; a save always validates first, but this is only used for a
* best-effort cycle check, so fail open rather than block unrelated saves). */
function bodyCallSet(f: Pick<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']);
/**
* 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);
}
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();
return id;
}
function updateCustomFunction(
id: string,
name: string,
params: string[],
body: string,
kind: CustomFunction['kind'] = 'expression',
): void | { error: string } {
const f = doc.customFunctions.find((x) => x.id === id);
if (!f) return { error: 'Funktion nicht gefunden.' };
const error = validateCustomFunction(name, params, body, kind, id);
if (error) return { error };
f.name = name.trim();
f.params = params.map((p) => p.trim());
f.body = body.startsWith('=') ? body.slice(1) : body;
f.kind = kind;
syncWorkbook();
}
function deleteCustomFunction(id: string) {
doc.customFunctions = doc.customFunctions.filter((f) => f.id !== id);
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
}
function moveObject(objId: string, x: number, y: number) {
const o = objectById(objId);
if (!o) return;
o.x = Math.max(0, Math.round(x));
o.y = Math.max(0, Math.round(y));
}
function resizeChart(objId: string, width: number, height: number) {
const o = objectById(objId);
if (!o || o.kind !== 'chart') return;
o.width = Math.max(200, Math.round(width));
o.height = Math.max(140, Math.round(height));
}
function bringToFront(objId: string) {
const sheetId = sheetIdOfObject(objId);
const sheet = sheetId ? sheetById(sheetId) : undefined;
if (!sheet) return;
const idx = sheet.objects.findIndex((o) => o.id === objId);
if (idx < 0 || idx === sheet.objects.length - 1) return;
const [o] = sheet.objects.splice(idx, 1);
sheet.objects.push(o);
}
function setChartView(objId: string, view: 'bar' | 'line') {
const o = objectById(objId);
if (o?.kind === 'chart') o.view = view;
}
function setChartSource(objId: string, tableId: string) {
const o = objectById(objId);
if (o?.kind === 'chart') o.sourceTableId = tableId;
}
function selectObject(objId: string | null) {
doc.activeObjectId = objId;
if (!objId) return;
const sheetId = sheetIdOfObject(objId);
if (sheetId) doc.activeSheetId = sheetId;
const o = objectById(objId);
if (o?.kind === 'table' && doc.selection?.tableId !== objId) {
doc.selection = { tableId: objId, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
}
}
// ---- cell reads (reactive via recalcTick) -----------------
function cellInput(tableId: string, col: number, row: number): string {
return tableById(tableId)?.cells[`${col},${row}`] ?? '';
}
function cellValue(tableId: string, col: number, row: number): CellValue {
void recalcTick.value;
return wb.cellValue(tableId, col, row);
}
function cellDisplay(tableId: string, col: number, row: number): string {
return formatValue(
cellValue(tableId, col, row),
effectiveLocale(tableId),
tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto',
);
}
// ---- number-format locale (object -> sheet -> document cascade) ---
/** Effective locale for a canvas object (table or chart). */
function effectiveLocale(objId: string | null): NumberLocale {
const o = objId ? objectById(objId) : undefined;
if (o && o.kind !== 'text' && o.locale && isNumberLocale(o.locale)) return o.locale;
const sheetId = objId ? sheetIdOfObject(objId) : doc.activeSheetId;
const sheetLoc = sheetId ? sheetById(sheetId)?.locale : undefined;
if (sheetLoc && isNumberLocale(sheetLoc)) return sheetLoc;
return isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE;
}
const activeLocale = computed(() => effectiveLocale(activeTable.value?.id ?? null));
function setDocumentLocale(l: string) {
if (isNumberLocale(l)) doc.locale = l;
}
function setSheetLocale(sheetId: string, l: string | null) {
const s = sheetById(sheetId);
if (!s) return;
if (l && isNumberLocale(l)) s.locale = l;
else delete s.locale;
}
function setObjectLocale(objId: string, l: string | null) {
const o = objectById(objId);
if (!o || o.kind === 'text') return;
if (l && isNumberLocale(l)) o.locale = l;
else delete o.locale;
}
const cellIsNumeric = (t: string, c: number, r: number) => isNumeric(cellValue(t, c, r));
const cellIsError = (t: string, c: number, r: number) => isError(cellValue(t, c, r));
const cellIsFormula = (t: string, c: number, r: number) => cellInput(t, c, r).startsWith('=');
// ---- cell writes --------------------------------------
function setCellInput(tableId: string, col: number, row: number, raw: string) {
const table = tableById(tableId);
if (!table) return;
const key = `${col},${row}`;
const trimmed = raw.replace(/\s+$/, '');
if (trimmed === '') delete table.cells[key];
else table.cells[key] = trimmed;
if (col >= table.cols) table.cols = col + 1;
if (row >= table.rows) table.rows = row + 1;
wb.setCell(tableId, col, row, trimmed);
bump();
}
function clearRange(tableId: string, a: CellCoord, b: CellCoord) {
const c0 = Math.min(a.col, b.col);
const c1 = Math.max(a.col, b.col);
const r0 = Math.min(a.row, b.row);
const r1 = Math.max(a.row, b.row);
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) setCellInput(tableId, c, r, '');
}
// ---- table grid growth -----------------------------
const addRows = (tableId: string, n = 1) => {
const t = tableById(tableId);
if (t) t.rows += n;
};
const addColumns = (tableId: string, n = 1) => {
const t = tableById(tableId);
if (t) t.cols += n;
};
function removeLastRow(tableId: string) {
const t = tableById(tableId);
if (!t || t.rows <= 1) return;
const r = t.rows - 1;
for (let c = 0; c < t.cols; c++) delete t.cells[`${c},${r}`];
t.rows -= 1;
syncWorkbook();
}
function removeLastColumn(tableId: string) {
const t = tableById(tableId);
if (!t || t.cols <= 1) return;
const c = t.cols - 1;
for (let r = 0; r < t.rows; r++) delete t.cells[`${c},${r}`];
delete t.colWidths[c];
t.cols -= 1;
syncWorkbook();
}
const MIN_COL_W = 24;
const MIN_ROW_H = 14;
function setColWidth(tableId: string, col: number, width: number) {
const t = tableById(tableId);
if (t) t.colWidths[col] = Math.max(MIN_COL_W, Math.round(width));
}
/** Numbers-style proportional resize: scale every column width by `factor`. */
function scaleTableColumns(tableId: string, factor: number, base: number) {
const t = tableById(tableId);
if (!t) return;
for (let c = 0; c < t.cols; c++) {
const cur = t.colWidths[c] ?? base;
t.colWidths[c] = Math.max(MIN_COL_W, Math.round(cur * factor));
}
}
/** Height of one row (per-row override, else the table default). */
function rowHeightOf(tableId: string, row: number): number {
const t = tableById(tableId);
return t?.rowHeights?.[row] ?? t?.rowHeight ?? DEFAULT_ROW_H;
}
function setRowHeight(tableId: string, row: number, height: number) {
const t = tableById(tableId);
if (!t) return;
if (!t.rowHeights) t.rowHeights = {};
t.rowHeights[row] = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
}
/** Default row height for the whole table. */
function setTableRowHeight(tableId: string, height: number) {
const t = tableById(tableId);
if (t) t.rowHeight = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
}
function pruneEmptyMaps(t: TableObject) {
if (t.formats && !Object.keys(t.formats).length) delete t.formats;
if (t.rowHeights && !Object.keys(t.rowHeights).length) delete t.rowHeights;
}
/** Grow or shrink a table to an exact row / column count. */
function setTableRows(tableId: string, target: number) {
const t = tableById(tableId);
if (!t) return;
const n = Math.max(1, Math.floor(target));
if (n === t.rows) return;
if (n < t.rows) {
for (const k of Object.keys(t.cells)) if (Number(k.split(',')[1]) >= n) delete t.cells[k];
if (t.formats) for (const k of Object.keys(t.formats)) if (Number(k.split(',')[1]) >= n) delete t.formats[k];
if (t.rowHeights) for (const k of Object.keys(t.rowHeights)) if (Number(k) >= n) delete t.rowHeights[Number(k)];
}
t.rows = n;
pruneEmptyMaps(t);
clampTableView(t);
clampSelection(tableId);
syncWorkbook();
}
function setTableCols(tableId: string, target: number) {
const t = tableById(tableId);
if (!t) return;
const n = Math.max(1, Math.floor(target));
if (n === t.cols) return;
if (n < t.cols) {
for (const k of Object.keys(t.cells)) if (Number(k.split(',')[0]) >= n) delete t.cells[k];
if (t.formats) for (const k of Object.keys(t.formats)) if (Number(k.split(',')[0]) >= n) delete t.formats[k];
for (const k of Object.keys(t.colWidths)) if (Number(k) >= n) delete t.colWidths[Number(k)];
}
t.cols = n;
pruneEmptyMaps(t);
clampTableView(t);
clampSelection(tableId);
syncWorkbook();
}
// ---- cell formatting (alignment, number format, colours, bold/italic) ---
const fmtKey = (c: number, r: number) => `${c},${r}`;
function cellFormat(tableId: string, col: number, row: number): CellFormat {
return tableById(tableId)?.formats?.[fmtKey(col, row)] ?? {};
}
/** Merge `patch` into every cell of a range; a `null`/`undefined` field clears it. */
function setRangeFormat(
tableId: string,
a: CellCoord,
b: CellCoord,
patch: Partial<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();
}
function clearRangeFormat(tableId: string, a: CellCoord, b: CellCoord) {
const t = tableById(tableId);
if (!t?.formats) return;
const c0 = Math.min(a.col, b.col);
const c1 = Math.max(a.col, b.col);
const r0 = Math.min(a.row, b.row);
const r1 = Math.max(a.row, b.row);
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) delete t.formats[fmtKey(c, r)];
if (!Object.keys(t.formats).length) delete t.formats;
bump();
}
/** Format shared by every cell of the current (possibly multi-) selection. */
const selectionFormat = computed<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);
}
// ---- 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;
}
}
/** Replace the whole selection with one rectangle (or grow the primary one). */
function selectCell(tableId: string, col: number, row: number, extend = false) {
const cur = doc.selection;
const anchor = extend && cur && cur.tableId === tableId ? cur.anchor : { col, row };
setSelection({ tableId, anchor, focus: { col, row } });
}
/** Ctrl/Cmd-click: add the cell as a new rectangle, or toggle it off. */
function addToSelection(tableId: string, col: number, row: number) {
const cur = doc.selection;
if (!cur || cur.tableId !== tableId) {
selectCell(tableId, col, row);
return;
}
const ranges = [...(cur.ranges ?? [])];
const idx = ranges.findIndex((r) => isOneByOne(r.anchor, r.focus, col, row));
if (idx >= 0) {
// toggle an extra 1x1 range off
ranges.splice(idx, 1);
doc.selection = { ...cur, ranges: ranges.length ? ranges : undefined };
return;
}
if (isOneByOne(cur.anchor, cur.focus, col, row) && ranges.length) {
// toggle the primary off -> promote the most recent extra range
const last = ranges.pop()!;
doc.selection = {
tableId,
anchor: last.anchor,
focus: last.focus,
ranges: ranges.length ? ranges : undefined,
};
return;
}
// keep the current primary as an extra range, make the clicked cell primary
ranges.push({ anchor: cur.anchor, focus: cur.focus });
setSelection({ tableId, anchor: { col, row }, focus: { col, row }, ranges });
}
/** Ctrl/Cmd-drag: grow the just-added (primary) rectangle. */
function extendActiveRange(tableId: string, col: number, row: number) {
const cur = doc.selection;
if (!cur || cur.tableId !== tableId) return;
doc.selection = { ...cur, focus: { col, row } };
}
/** Ctrl/Cmd-click on a column/row header: add that whole strip. */
function addRangeToSelection(tableId: string, a: CellCoord, b: CellCoord) {
const cur = doc.selection;
if (!cur || cur.tableId !== tableId) {
setSelection({ tableId, anchor: a, focus: b });
return;
}
const ranges = [...(cur.ranges ?? []), { anchor: cur.anchor, focus: cur.focus }];
setSelection({ tableId, anchor: a, focus: b, ranges });
}
// ---- clipboard (copy / cut / paste) --------------------------
/** True while a copy/cut buffer is held (enables "Einfügen" in menus). */
const hasClipboard = computed(() => clipboard.value !== null);
/** Source rectangle of the current buffer -- drives the dashed source marker
* and its anchor-cell indicator (which stays put once the live selection
* has moved on to pick a paste destination). */
const clipboardRect = computed(() => {
const cb = clipboard.value;
return cb ? { tableId: cb.tableId, ...cb.rect, cut: cb.cut, anchor: cb.anchor } : null;
});
function copyOrCut(cut: boolean) {
const s = doc.selection;
const t = s ? tableById(s.tableId) : undefined;
if (!s || !t) return;
const r = rect(s.anchor, s.focus);
const cells = new Map<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('=');
}
/** `'` needs quoting whenever a name isn't a bare identifier (spaces,
* punctuation, ...) -- shared by the table- and sheet-qualifier cases. */
function quoteQualifier(name: string): string {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`;
}
function qualifyRef(homeTableId: string, targetTableId: string, body: string): string {
if (targetTableId === homeTableId) return body;
const target = tableById(targetTableId);
const tableQ = quoteQualifier(target?.name ?? targetTableId);
// a plain "Tabelle!..." is enough as long as it resolves unambiguously --
// either the name is document-unique, or the target happens to be on the
// same sheet as the formula being edited (resolveTableName()'s "prefer
// the home sheet" fallback picks it correctly either way); only a name
// that collides *and* lives on a different sheet needs the explicit
// "Blatt!Tabelle::..." 3-part form to disambiguate.
const homeSheetId = sheetIdOfObject(homeTableId);
const targetSheetId = sheetIdOfObject(targetTableId);
const nameCollides = target
? allTables(doc).filter((t) => t.name.toLowerCase() === target.name.toLowerCase()).length > 1
: false;
if (!nameCollides || targetSheetId === homeSheetId) {
return `${tableQ}!${body}`;
}
const sheetName = (targetSheetId && sheetById(targetSheetId)?.name) || '';
return `${quoteQualifier(sheetName)}!${tableQ}::${body}`;
}
function applyPointRef(targetTableId: string, body: string): boolean {
const ed = formulaEditor.value;
if (!ed) return false;
const st = ed.getState();
if (!st.text.startsWith('=')) return false;
const text = qualifyRef(ed.tableId, targetTableId, body);
const span = pointSpan ?? { start: st.start, end: st.end };
ed.replace(span.start, span.end, text);
pointSpan = { start: span.start, end: span.start + text.length };
return true;
}
/** A plain click on (tableId, col, row) while editing a formula: insert or
* (immediately after another click/drag, before any typing) replace a
* reference at the caret. Returns false when there's no formula editor
* active, so the caller can fall back to its normal click handling. */
function pointClick(tableId: string, col: number, row: number): boolean {
return applyPointRef(tableId, toA1(col, row));
}
/** A drag from (c0,r0) to (c1,r1) on tableId while editing a formula:
* insert/replace a *range* reference, meant to be called live as the drag
* updates (each call replaces the same span, growing/shrinking the range
* as the user drags), not just once at the end. */
function pointDrag(tableId: string, c0: number, r0: number, c1: number, r1: number): boolean {
const a = toA1(Math.min(c0, c1), Math.min(r0, r1));
const b = toA1(Math.max(c0, c1), Math.max(r0, r1));
return applyPointRef(tableId, a === b ? a : `${a}:${b}`);
}
// The "active cell" is always the anchor -- the first cell of the current
// selection action (click, shift-click, drag start, ...) -- so it stays put
// while a multi-cell selection is being made, matching Excel/Numbers.
const activeCell = computed<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 ?? [];
syncWorkbook();
}
/** Loading a different document outright (JSON import "replace", "reset to
* seed") -- unlike undo/redo, this starts a fresh undo history rather
* than leaving the previous document's edits undoable into the new one. */
function replaceDocument(next: DocumentState) {
applyDocumentState(next);
resetHistory();
}
function resetToSeed() {
replaceDocument(createSeedDocument());
}
/** Append fully-formed sheets (JSON-append / XLSX import). */
function appendSheets(sheets: Sheet[]) {
for (const s of sheets) {
let id = s.id;
while (doc.sheets.some((o) => o.id === id)) id = uid('sheet');
const sn = new Set(doc.sheets.map((x) => x.name.toLowerCase()));
doc.sheets.push({ ...s, id, name: uniqueName(s.name, sn) });
doc.activeSheetId = id;
}
syncWorkbook();
}
/** Add a new table object built from a cell map (CSV "as new table"). */
function addTableFromCells(
sheetId: string,
name: string,
cells: Record<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();
}
function snapshot(): DocumentState {
return JSON.parse(JSON.stringify(doc)) as DocumentState;
}
return {
doc,
recalcTick,
// undo / redo
undo,
redo,
canUndo,
canRedo,
// sheets
sheetById,
activeSheet,
setActiveSheet,
addSheet,
renameSheet,
deleteSheet,
moveSheet,
// objects
objectById,
sheetIdOfObject,
tablesOf,
activeObject,
activeTable,
addTable,
addChart,
addText,
setTextContent,
setTextFormat,
resizeText,
renameObject,
deleteObject,
moveObject,
// custom functions
customFunctionNames,
addCustomFunction,
updateCustomFunction,
deleteCustomFunction,
resizeChart,
bringToFront,
setChartView,
setChartSource,
selectObject,
setObjectBg,
setObjectTextColor,
// table grid
tableById,
cellInput,
cellValue,
cellDisplay,
cellIsNumeric,
cellIsError,
cellIsFormula,
setCellInput,
clearRange,
addRows,
addColumns,
removeLastRow,
removeLastColumn,
setColWidth,
scaleTableColumns,
setRowHeight,
setTableRowHeight,
rowHeightOf,
setTableRows,
setTableCols,
setTableView,
// cell formatting
cellFormat,
setRangeFormat,
clearRangeFormat,
selectionFormat,
applySelectionFormat,
clearSelectionFormat,
clearSelectionCells,
// number-format locale
effectiveLocale,
activeLocale,
setDocumentLocale,
setSheetLocale,
setObjectLocale,
// per-sheet appearance + zoom + csv default
setSheetBackground,
setSheetForeground,
setSheetShowGrid,
setSheetZoom,
activeZoom,
setZoom,
setCsvDelimiter,
// view-only ui
ui,
// selection
setSelection,
selectCell,
addToSelection,
extendActiveRange,
addRangeToSelection,
selectionRects,
activeCell,
activeCellName,
selectionRangeName,
activeCellInput,
selectionAggregate,
// clipboard
copySelection,
cutSelection,
pasteClipboard,
cancelClipboard,
hasClipboard,
clipboardRect,
fillSelection,
// formula "point mode"
registerFormulaEditor,
noteFormulaTyped,
formulaPointModeActive,
pointClick,
pointDrag,
// document
replaceDocument,
resetToSeed,
appendSheets,
addTableFromCells,
replaceTableCells,
snapshot,
engine: () => wb,
};
}
let instance: ReturnType<typeof createStore> | null = null;
export function useDocument() {
if (!instance) instance = createStore();
return instance;
}
export type DocumentStore = ReturnType<typeof createStore>;