/** * 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; /** 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; } /** * Rewrite every cell reference in a formula by (dCol, dRow) -- the same * relative-reference adjustment Excel/Numbers apply on copy. * * A `$`-locked axis (`$A1`, `A$1`, `$A$1`) is exempt from a *copy*'s shift -- * that is the whole point of locking a reference -- but not from a *cut*'s: * a cut moves the referenced cell itself, so any reference to it, `$` or not, * must follow to keep pointing at the same (now relocated) cell. * * `restrict`, when given (a cut/move), narrows the shift to *only* references * that point inside that rectangle -- i.e. cells that moved along with the * formula. A cut otherwise leaves references untouched, since the cells they * point at did not move (and a qualified `Table!A1` reference is never * "inside" a local rectangle, so it is always left alone during a cut). */ function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect): string { if (!raw.startsWith('=') || (dCol === 0 && dRow === 0)) return raw; const body = raw.slice(1); let toks: Token[]; try { toks = tokenize(body); } catch { return raw; // unparsable -- leave as-is, the evaluator will surface #ERR } let out = ''; let last = 0; for (let i = 0; i < toks.length; i++) { const tok = toks[i]; if (tok.type !== 'IDENT' || !looksLikeCell(tok.value)) continue; const next = toks[i + 1]; if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell const prev = toks[i - 1]; const qualified = !!prev && (prev.type === 'BANG' || prev.type === 'DBLCOLON'); if (restrict && qualified) continue; // cut: a reference into another table never moved const { col, row, colAbs, rowAbs } = parseA1(tok.value); if ( restrict && !qualified && !(col >= restrict.c0 && col <= restrict.c1 && row >= restrict.r0 && row <= restrict.r1) ) { continue; } const nc = !restrict && colAbs ? col : Math.max(0, col + dCol); const nr = !restrict && rowAbs ? row : Math.max(0, row + dRow); out += body.slice(last, tok.pos) + toA1(nc, nr, colAbs, rowAbs); last = tok.pos + tok.value.length; } out += body.slice(last); return '=' + out; } function loadInitial(): DocumentState { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { // validateDocument migrates v1, validates + *normalises* v2 (fills in // `view` and other defaults for documents saved by an older build). return validateDocument(JSON.parse(raw)); } } catch { /* missing / corrupt / stale-schema -> clean fallback */ } return createSeedDocument(); } // ---- store singleton -------------------------------------------------- function createStore() { const doc = reactive(loadInitial()); let wb = new Workbook(toTableModels(doc)); const recalcTick = ref(0); const bump = () => (recalcTick.value += 1); /** View-only state: not part of the document, not persisted. */ const ui = reactive({ settingsOpen: false }); /** Copy/cut buffer -- not part of the persisted document. */ const clipboard = ref(null); 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 | 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 { 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(() => { 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>) { 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>, ) { const t = tableById(tableId); if (!t) return; if (!t.formats) t.formats = {}; const c0 = Math.min(a.col, b.col); const c1 = Math.max(a.col, b.col); const r0 = Math.min(a.row, b.row); const r1 = Math.max(a.row, b.row); for (let r = r0; r <= r1; r++) { for (let c = c0; c <= c1; c++) { const k = fmtKey(c, r); const cur: CellFormat = { ...(t.formats[k] ?? {}) }; for (const [field, val] of Object.entries(patch)) { if (val === null || val === undefined || val === '' || val === false || val === 'auto') { delete (cur as Record)[field]; } else { (cur as Record)[field] = val; } } if (Object.keys(cur).length) t.formats[k] = cur; else delete t.formats[k]; } } if (!Object.keys(t.formats).length) delete t.formats; bump(); } function clearRangeFormat(tableId: string, a: CellCoord, b: CellCoord) { const t = tableById(tableId); if (!t?.formats) return; const c0 = Math.min(a.col, b.col); const c1 = Math.max(a.col, b.col); const r0 = Math.min(a.row, b.row); const r1 = Math.max(a.row, b.row); for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) delete t.formats[fmtKey(c, r)]; if (!Object.keys(t.formats).length) delete t.formats; bump(); } /** Format shared by every cell of the current (possibly multi-) selection. */ const selectionFormat = computed(() => { void recalcTick.value; const s = doc.selection; const t = s ? tableById(s.tableId) : undefined; if (!s || !t) return {}; let acc: CellFormat | null = null; for (const key of selectedCellKeys()) { const f = t.formats?.[key] ?? {}; if (acc === null) acc = { ...f }; else { for (const k of Object.keys(acc) as (keyof CellFormat)[]) { if (acc[k] !== f[k]) delete acc[k]; } } } return acc ?? {}; }); /** Apply / clear formatting across *all* rectangles of the current selection. */ function applySelectionFormat(patch: Partial>) { const s = doc.selection; if (!s) return; for (const k of selectionRects(s)) { setRangeFormat(s.tableId, { col: k.c0, row: k.r0 }, { col: k.c1, row: k.r1 }, patch); } } function clearSelectionFormat() { const s = doc.selection; if (!s) return; for (const k of selectionRects(s)) { clearRangeFormat(s.tableId, { col: k.c0, row: k.r0 }, { col: k.c1, row: k.r1 }); } } /** Delete/Backspace over a multi-selection. */ function clearSelectionCells() { const s = doc.selection; if (!s) return; for (const key of selectedCellKeys()) { const [c, r] = key.split(',').map(Number); setCellInput(s.tableId, c, r, ''); } } function clampTableView(t: TableObject) { t.view.frozenRows = Math.min(Math.max(0, t.view.frozenRows), Math.max(0, t.rows - 1)); t.view.frozenCols = Math.min(Math.max(0, t.view.frozenCols), Math.max(0, t.cols - 1)); } function clampSelection(tableId: string) { const s = doc.selection; const t = tableById(tableId); if (!s || !t || s.tableId !== tableId) return; const cl = (c: CellCoord) => ({ col: Math.min(Math.max(0, c.col), t.cols - 1), row: Math.min(Math.max(0, c.row), t.rows - 1), }); doc.selection = { tableId, anchor: cl(s.anchor), focus: cl(s.focus), ranges: s.ranges?.map((r) => ({ anchor: cl(r.anchor), focus: cl(r.focus) })), }; } /** Patch a table object's view (sizing mode, viewport size, frozen rows/cols). */ function setTableView(tableId: string, patch: Partial) { const t = tableById(tableId); if (!t) return; t.view = { ...t.view, ...patch }; if (patch.width !== undefined) t.view.width = Math.max(200, Math.round(t.view.width)); if (patch.height !== undefined) t.view.height = Math.max(120, Math.round(t.view.height)); clampTableView(t); } // ---- selection -------------------------------------- function rect(a: CellCoord, b: CellCoord): Rect { return { c0: Math.min(a.col, b.col), c1: Math.max(a.col, b.col), r0: Math.min(a.row, b.row), r1: Math.max(a.row, b.row), }; } /** Every rectangle of `sel` (primary first), normalised. */ function selectionRects(sel: SelectionState): Rect[] { const out = [rect(sel.anchor, sel.focus)]; for (const r of sel.ranges ?? []) out.push(rect(r.anchor, r.focus)); return out; } /** Deduplicated set of "c,r" keys covered by the current selection. */ function selectedCellKeys(): Set { const s = doc.selection; const keys = new Set(); if (!s) return keys; for (const k of selectionRects(s)) { for (let r = k.r0; r <= k.r1; r++) for (let c = k.c0; c <= k.c1; c++) keys.add(`${c},${r}`); } return keys; } const isOneByOne = (a: CellCoord, b: CellCoord, c: number, r: number) => a.col === c && a.row === r && b.col === c && b.row === r; function setSelection(sel: SelectionState | null) { doc.selection = sel; if (sel) { doc.activeObjectId = sel.tableId; const sheetId = sheetIdOfObject(sel.tableId); if (sheetId) doc.activeSheetId = sheetId; } } /** Replace the whole selection with one rectangle (or grow the primary one). */ function selectCell(tableId: string, col: number, row: number, extend = false) { const cur = doc.selection; const anchor = extend && cur && cur.tableId === tableId ? cur.anchor : { col, row }; setSelection({ tableId, anchor, focus: { col, row } }); } /** Ctrl/Cmd-click: add the cell as a new rectangle, or toggle it off. */ function addToSelection(tableId: string, col: number, row: number) { const cur = doc.selection; if (!cur || cur.tableId !== tableId) { selectCell(tableId, col, row); return; } const ranges = [...(cur.ranges ?? [])]; const idx = ranges.findIndex((r) => isOneByOne(r.anchor, r.focus, col, row)); if (idx >= 0) { // toggle an extra 1x1 range off ranges.splice(idx, 1); doc.selection = { ...cur, ranges: ranges.length ? ranges : undefined }; return; } if (isOneByOne(cur.anchor, cur.focus, col, row) && ranges.length) { // toggle the primary off -> promote the most recent extra range const last = ranges.pop()!; doc.selection = { tableId, anchor: last.anchor, focus: last.focus, ranges: ranges.length ? ranges : undefined, }; return; } // keep the current primary as an extra range, make the clicked cell primary ranges.push({ anchor: cur.anchor, focus: cur.focus }); setSelection({ tableId, anchor: { col, row }, focus: { col, row }, ranges }); } /** Ctrl/Cmd-drag: grow the just-added (primary) rectangle. */ function extendActiveRange(tableId: string, col: number, row: number) { const cur = doc.selection; if (!cur || cur.tableId !== tableId) return; doc.selection = { ...cur, focus: { col, row } }; } /** Ctrl/Cmd-click on a column/row header: add that whole strip. */ function addRangeToSelection(tableId: string, a: CellCoord, b: CellCoord) { const cur = doc.selection; if (!cur || cur.tableId !== tableId) { setSelection({ tableId, anchor: a, focus: b }); return; } const ranges = [...(cur.ranges ?? []), { anchor: cur.anchor, focus: cur.focus }]; setSelection({ tableId, anchor: a, focus: b, ranges }); } // ---- clipboard (copy / cut / paste) -------------------------- /** True while a copy/cut buffer is held (enables "Einfügen" in menus). */ const hasClipboard = computed(() => clipboard.value !== null); /** Source rectangle of the current buffer -- drives the dashed source marker * and its anchor-cell indicator (which stays put once the live selection * has moved on to pick a paste destination). */ const clipboardRect = computed(() => { const cb = clipboard.value; return cb ? { tableId: cb.tableId, ...cb.rect, cut: cb.cut, anchor: cb.anchor } : null; }); function copyOrCut(cut: boolean) { const s = doc.selection; const t = s ? tableById(s.tableId) : undefined; if (!s || !t) return; const r = rect(s.anchor, s.focus); const cells = new Map(); for (let row = r.r0; row <= r.r1; row++) { for (let col = r.c0; col <= r.c1; col++) { const raw = cellInput(s.tableId, col, row); const format = t.formats?.[fmtKey(col, row)]; if (raw || format) { cells.set(`${col - r.c0},${row - r.r0}`, { raw, format: format ? { ...format } : undefined }); } } } clipboard.value = { tableId: s.tableId, rect: r, width: r.c1 - r.c0 + 1, height: r.r1 - r.r0 + 1, cells, cut, anchor: { ...s.anchor }, }; } const copySelection = () => copyOrCut(false); const cutSelection = () => copyOrCut(true); /** Drop the buffer (Escape) without pasting. */ function cancelClipboard() { clipboard.value = null; } /** * Paste the buffer at the current selection. A selection that is an exact, * larger multiple of the buffer tiles it (Numbers/Excel "fill by paste"); * a smaller or single-cell selection anchors the buffer there and overflows * past it, same as pasting a whole block onto one cell. */ function pasteClipboard() { const cb = clipboard.value; const s = doc.selection; if (!cb || !s || !tableById(s.tableId)) return; const dest = rect(s.anchor, s.focus); const destW = dest.c1 - dest.c0 + 1; const destH = dest.r1 - dest.r0 + 1; const tileX = destW > cb.width && destW % cb.width === 0 ? destW / cb.width : 1; const tileY = destH > cb.height && destH % cb.height === 0 ? destH / cb.height : 1; for (let ty = 0; ty < tileY; ty++) { for (let tx = 0; tx < tileX; tx++) { const baseC = dest.c0 + tx * cb.width; const baseR = dest.r0 + ty * cb.height; const dCol = baseC - cb.rect.c0; const dRow = baseR - cb.rect.r0; for (let dr = 0; dr < cb.height; dr++) { for (let dc = 0; dc < cb.width; dc++) { const c = baseC + dc; const r = baseR + dr; const cell = cb.cells.get(`${dc},${dr}`); const raw = cell?.raw ?? ''; setCellInput( s.tableId, c, r, raw ? shiftFormula(raw, dCol, dRow, cb.cut ? cb.rect : undefined) : raw, ); clearRangeFormat(s.tableId, { col: c, row: r }, { col: c, row: r }); if (cell?.format) { setRangeFormat( s.tableId, { col: c, row: r }, { col: c, row: r }, cell.format as Partial>, ); } } } } } if (cb.cut) { // clear the moved-from cells, except any the paste itself just wrote into const destC1 = dest.c0 + tileX * cb.width - 1; const destR1 = dest.r0 + tileY * cb.height - 1; for (let row = cb.rect.r0; row <= cb.rect.r1; row++) { for (let col = cb.rect.c0; col <= cb.rect.c1; col++) { const overlap = cb.tableId === s.tableId && col >= dest.c0 && col <= destC1 && row >= dest.r0 && row <= destR1; if (!overlap) { setCellInput(cb.tableId, col, row, ''); clearRangeFormat(cb.tableId, { col, row }, { col, row }); } } } clipboard.value = null; // a cut buffer is consumed by its first paste } setSelection({ tableId: s.tableId, anchor: { col: dest.c0, row: dest.r0 }, focus: { col: dest.c0 + tileX * cb.width - 1, row: dest.r0 + tileY * cb.height - 1 }, }); } /** * The Excel/Numbers "fill handle": repeat `source`'s cells (values, * formulas -- reference-adjusted the same way a copy is -- and formatting) * across `target`, a rectangle extending purely up, down, left or right * from `source` (never both axes at once -- the caller picks the single * dominant drag direction). Repeats cyclically, so a `target` that isn't an * exact multiple of `source`'s size still fills its last partial cycle. */ function fillSelection(tableId: string, source: Rect, target: Rect) { const t = tableById(tableId); if (!t) return; const srcW = source.c1 - source.c0 + 1; const srcH = source.r1 - source.r0 + 1; const cells = new Map(); for (let row = source.r0; row <= source.r1; row++) { for (let col = source.c0; col <= source.c1; col++) { const raw = cellInput(tableId, col, row); const format = t.formats?.[fmtKey(col, row)]; if (raw || format) { cells.set(`${col - source.c0},${row - source.r0}`, { raw, format: format ? { ...format } : undefined }); } } } for (let row = target.r0; row <= target.r1; row++) { for (let col = target.c0; col <= target.c1; col++) { // which cell of the source block this target cell cycles back to const dc = (((col - source.c0) % srcW) + srcW) % srcW; const dr = (((row - source.r0) % srcH) + srcH) % srcH; const cell = cells.get(`${dc},${dr}`); const raw = cell?.raw ?? ''; const deltaC = col - (source.c0 + dc); const deltaR = row - (source.r0 + dr); setCellInput(tableId, col, row, raw ? shiftFormula(raw, deltaC, deltaR) : raw); clearRangeFormat(tableId, { col, row }, { col, row }); if (cell?.format) { setRangeFormat(tableId, { col, row }, { col, row }, cell.format as Partial>); } } } } // The "active cell" is always the anchor -- the first cell of the current // selection action (click, shift-click, drag start, ...) -- so it stays put // while a multi-cell selection is being made, matching Excel/Numbers. const activeCell = computed(() => doc.selection?.anchor ?? { col: 0, row: 0 }); const activeCellName = computed(() => { const sel = doc.selection; const t = sel ? tableById(sel.tableId) : activeTable.value; const cell = sel?.anchor ?? { col: 0, row: 0 }; return `${t?.name ?? '—'}::${toA1(cell.col, cell.row)}`; }); 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, rows: number, cols: number, ): string { const id = addTable(sheetId); const t = tableById(id)!; t.cells = { ...cells }; t.rows = Math.max(rows, 1); t.cols = Math.max(cols, 1); renameObject(id, name); // also syncs the workbook return id; } /** Replace an existing table's whole cell map (CSV "replace active table"). */ function replaceTableCells( tableId: string, cells: Record, rows: number, cols: number, ) { const t = tableById(tableId); if (!t) return; t.cells = { ...cells }; t.rows = Math.max(rows, 1); t.cols = Math.max(cols, 1); t.colWidths = {}; syncWorkbook(); } function snapshot(): DocumentState { return JSON.parse(JSON.stringify(doc)) as DocumentState; } return { doc, recalcTick, // 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, fillSelection, // document replaceDocument, resetToSeed, appendSheets, addTableFromCells, replaceTableCells, snapshot, engine: () => wb, }; } let instance: ReturnType | null = null; export function useDocument() { if (!instance) instance = createStore(); return instance; } export type DocumentStore = ReturnType;