Files
numolus/src/store/document.ts
T
wkhandClaude Sonnet 5 8e43cc294c Support $-locked references: relative, mixed and absolute
The engine already anticipated $A1/A$1/$A$1 in its grammar comment, but the
lexer had no rule for '$' at all -- any formula using it failed to parse.

- lexer.ts: '$' is now a valid word character, so "$A$1" tokenizes as one
  IDENT, same as any other bare word (the lexer still doesn't interpret it,
  per its "dumb lexer" design -- the parser/evaluator stay untouched, since
  evaluation is purely positional and $ has no effect on a value).
- refs.ts: parseA1()/toA1() now carry colAbs/rowAbs through the $ markers
  (backward compatible -- existing {col,row} destructuring is unaffected).
  Added cycleReferenceLock() for Excel/Numbers-style F4: relative ->
  absolute -> row-locked -> col-locked -> relative, toggling whichever
  reference sits at the caret (and the correct side of an A1:B2 range).
- document.ts: shiftFormula() now honors the lock on copy (a locked axis
  stays exactly as typed) but not on cut (a moved cell's reference follows
  it regardless of $, since cut relocates the cell itself rather than
  filling a relative offset).
- TableGrid.vue's inline cell editor and FormulaBar.vue both wire up F4 via
  the shared cycleReferenceLock() helper.
- Tests: lexer/parser coverage for $ tokenizing/parsing, a new refs.spec.ts
  for parseA1/toA1 round-tripping and the F4 cycle (incl. range side-
  picking), and document.spec.ts cases for locked-axis copy vs. cut.
- README.md / src/engine/README.md: documented the $ semantics, F4, and
  corrected a couple of now-stale claims (test count, "$ is ignored, there's
  no copy/fill", "active cell is always the last-clicked one").

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

1211 lines
41 KiB
TypeScript

/**
* 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,
type CellValue,
type TableModel,
type Token,
} from '../engine';
import {
formatValue,
isNumeric,
isNumberLocale,
DEFAULT_LOCALE,
type NumberLocale,
} from './format';
import type {
CellCoord,
CellFormat,
ChartObject,
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 allTables(doc).map((t) => ({
id: t.id,
name: t.name,
cols: t.cols,
rows: t.rows,
inputs: new Map(Object.entries(t.cells)),
}));
}
/** 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;
}
/**
* 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));
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);
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
function syncWorkbook() {
wb = new Workbook(toTableModels(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 },
);
// ---- 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 tableNames = () => new Set(allTables(doc).map((t) => t.name.toLowerCase()));
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()));
// ---- 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([...tableNames(), ...objectNames()])),
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') {
const others = new Set(
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();
}
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. */
const clipboardRect = computed(() => {
const cb = clipboard.value;
return cb ? { tableId: cb.tableId, ...cb.rect, cut: cb.cut } : 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,
};
}
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 "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)}`;
});
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 ---------------------------
function replaceDocument(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 === ';' ? ';' : ',';
syncWorkbook();
}
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,
// sheets
sheetById,
activeSheet,
setActiveSheet,
addSheet,
renameSheet,
deleteSheet,
moveSheet,
// objects
objectById,
sheetIdOfObject,
tablesOf,
activeObject,
activeTable,
addTable,
addChart,
addText,
setTextContent,
setTextFormat,
resizeText,
renameObject,
deleteObject,
moveObject,
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,
activeCellInput,
selectionAggregate,
// clipboard
copySelection,
cutSelection,
pasteClipboard,
cancelClipboard,
hasClipboard,
clipboardRect,
// 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>;