A first start (nothing in localStorage) now opens a blank document -- one sheet "Blatt 1" with an empty "Tabelle 1" -- instead of the pre-filled sample. The file menu gets "Neu" (blank document) and "Muster · Umsatzanalyse" (the former seed), both behind a confirm and both starting a fresh undo history. The toolbar's "Zurücksetzen" button is gone; its job is now Datei › Muster. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3066 lines
119 KiB
TypeScript
3066 lines
119 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 } from 'vue';
|
||
import {
|
||
Workbook,
|
||
toA1,
|
||
isError,
|
||
tokenize,
|
||
looksLikeCell,
|
||
parseA1,
|
||
parseFormula,
|
||
parseFunctionBlock,
|
||
parseMacroBlock,
|
||
collectRefsInBlock,
|
||
hasFunction,
|
||
collectCalledFunctionNames,
|
||
collectCalledFunctionNamesInBlock,
|
||
declaredBlockNames,
|
||
findInvalidForSource,
|
||
findInvalidActionTarget,
|
||
blockAlwaysReturns,
|
||
buildFunctionIndex,
|
||
expandMacroBlock,
|
||
runMacroActions,
|
||
parseDateTimeLiteral,
|
||
BUILTIN_SIGNATURES,
|
||
type CellRef,
|
||
type CellValue,
|
||
type TableModel,
|
||
type Token,
|
||
type FunctionDef,
|
||
type NamedRangeDef,
|
||
type MacroWriteHost,
|
||
type MacroNetworkHost,
|
||
type EngineHost,
|
||
} from '../engine';
|
||
import {
|
||
formatValue,
|
||
isNumeric,
|
||
isNumberLocale,
|
||
DEFAULT_LOCALE,
|
||
type NumberLocale,
|
||
} from './format';
|
||
import type {
|
||
ButtonObject,
|
||
CellCoord,
|
||
CellFormat,
|
||
CellGraphNode,
|
||
ChartObject,
|
||
CustomFunction,
|
||
DocumentState,
|
||
FnSuggestion,
|
||
Macro,
|
||
NameSuggestion,
|
||
Sheet,
|
||
SelectionState,
|
||
SheetObject,
|
||
TableObject,
|
||
TableView,
|
||
TextObject,
|
||
} from './types';
|
||
import { defaultTableView, DEFAULT_ROW_H, MIN_GRID_SIZE, MAX_GRID_SIZE } from './types';
|
||
import { createBlankDocument, createSeedDocument } from '../data/seed';
|
||
import { validateDocument } from '../io/json';
|
||
|
||
const STORAGE_KEY = 'numolus:doc:v2';
|
||
/** Key used before the rename to numolus -- still read as a fallback. */
|
||
const LEGACY_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,
|
||
}));
|
||
}
|
||
|
||
function toNamedRangeDefs(doc: DocumentState): NamedRangeDef[] {
|
||
return doc.namedRanges.map((r) => ({
|
||
id: r.id,
|
||
name: r.name,
|
||
tableId: r.tableId,
|
||
start: r.start,
|
||
end: r.end,
|
||
}));
|
||
}
|
||
|
||
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
|
||
type Rect = { c0: number; c1: number; r0: number; r1: number };
|
||
|
||
/** One cell of a copy/cut buffer, relative to the buffer's top-left corner. */
|
||
interface ClipboardCell {
|
||
raw: string;
|
||
format?: CellFormat;
|
||
}
|
||
interface ClipboardData {
|
||
tableId: string;
|
||
rect: Rect;
|
||
width: number;
|
||
height: number;
|
||
/** "dc,dr" (offset from the buffer's top-left) -> cell. Empty/unformatted cells are absent. */
|
||
cells: Map<string, ClipboardCell>;
|
||
/** cut (Strg+X) vs. copy (Strg+C) -- a cut buffer is consumed by its first paste. */
|
||
cut: boolean;
|
||
/** the copy/cut selection's own anchor -- the *first* cell picked, which is
|
||
* not necessarily `rect`'s top-left corner (a drag can start anywhere).
|
||
* Kept so the source marker can still point it out after the current
|
||
* selection has moved elsewhere -- see `clipboardRect`. */
|
||
anchor: CellCoord;
|
||
}
|
||
|
||
/**
|
||
* Whatever's currently editing a formula (the in-cell editor, or the formula
|
||
* bar) registers one of these so a click on the grid -- possibly in a
|
||
* *different* table entirely -- can insert a reference into it. `tableId` is
|
||
* the table the edited cell itself lives in (the "home" table for deciding
|
||
* whether an inserted reference needs a `Name!` qualifier).
|
||
*/
|
||
interface FormulaEditorHandle {
|
||
tableId: string;
|
||
getState(): { text: string; start: number; end: number };
|
||
replace(start: number, end: number, value: string): void;
|
||
}
|
||
|
||
/**
|
||
* Rewrite every cell reference in a formula by (dCol, dRow) -- the same
|
||
* relative-reference adjustment Excel/Numbers apply on copy.
|
||
*
|
||
* A `$`-locked axis (`$A1`, `A$1`, `$A$1`) is exempt from a *copy*'s shift --
|
||
* that is the whole point of locking a reference -- but not from a *cut*'s:
|
||
* a cut moves the referenced cell itself, so any reference to it, `$` or not,
|
||
* must follow to keep pointing at the same (now relocated) cell.
|
||
*
|
||
* `restrict`, when given (a cut/move), narrows the shift to *only* references
|
||
* that point inside that rectangle -- i.e. cells that moved along with the
|
||
* formula. A cut otherwise leaves references untouched, since the cells they
|
||
* point at did not move (and a qualified `Table!A1` reference is never
|
||
* "inside" a local rectangle, so it is always left alone during a cut).
|
||
*/
|
||
function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect): string {
|
||
if (!raw.startsWith('=') || (dCol === 0 && dRow === 0)) return raw;
|
||
const body = raw.slice(1);
|
||
let toks: Token[];
|
||
try {
|
||
toks = tokenize(body);
|
||
} catch {
|
||
return raw; // unparsable -- leave as-is, the evaluator will surface #ERR
|
||
}
|
||
let out = '';
|
||
let last = 0;
|
||
for (let i = 0; i < toks.length; i++) {
|
||
const tok = toks[i];
|
||
if (tok.type !== 'IDENT' || !looksLikeCell(tok.value)) continue;
|
||
const next = toks[i + 1];
|
||
if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell
|
||
const prev = toks[i - 1];
|
||
const qualified = !!prev && (prev.type === 'BANG' || prev.type === 'DBLCOLON');
|
||
if (restrict && qualified) continue; // cut: a reference into another table never moved
|
||
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
|
||
if (
|
||
restrict &&
|
||
!qualified &&
|
||
!(col >= restrict.c0 && col <= restrict.c1 && row >= restrict.r0 && row <= restrict.r1)
|
||
) {
|
||
continue;
|
||
}
|
||
const nc = !restrict && colAbs ? col : Math.max(0, col + dCol);
|
||
const nr = !restrict && rowAbs ? row : Math.max(0, row + dRow);
|
||
out += body.slice(last, tok.pos) + toA1(nc, nr, colAbs, rowAbs);
|
||
last = tok.pos + tok.value.length;
|
||
}
|
||
out += body.slice(last);
|
||
return '=' + out;
|
||
}
|
||
|
||
// ---- row/col insert & delete: document-wide reference rewriting ---------
|
||
// A mid-grid row/col insert/delete (unlike edge growth/shrink, or copy/paste's
|
||
// shiftFormula above) can invalidate/relocate references from *any* formula
|
||
// in the *whole document*, not just the edited table's own -- another table's
|
||
// `Tabelle!Zelle` qualifier can point straight at it. `rewriteRefsForStructuralChange`
|
||
// below is the token-level rewriter that does that (same splice technique as
|
||
// shiftFormula: only the affected cell/range tokens' exact text is touched,
|
||
// everything else -- including whitespace -- is byte-identical); it's the
|
||
// engine used by `insertRow`/`deleteRow`/`insertColumn`/`deleteColumn` (in the
|
||
// store proper, below) via `rewriteDocumentRefs`.
|
||
|
||
type Axis = 'row' | 'col';
|
||
|
||
/** Insert-side shift for one coordinate: everything at/after `at` moves by
|
||
* +1. Applied independently to a single cell and to each range endpoint --
|
||
* for a range straddling `at` (start < at <= end), only the end moves, so
|
||
* the range *grows* by one to include the newly inserted row/col (the same
|
||
* behaviour Excel has for "insert a row inside a SUM range"). */
|
||
function insertShift(v: number, at: number): number {
|
||
return v >= at ? v + 1 : v;
|
||
}
|
||
/** Delete-side shift for a range's *start* endpoint: `at` itself stays `at`
|
||
* (the next surviving row/col slides into that slot); only something
|
||
* strictly after `at` moves down by one. */
|
||
function deleteShiftStart(v: number, at: number): number {
|
||
return v > at ? v - 1 : v;
|
||
}
|
||
/** Delete-side shift for a range's *end* endpoint: unlike the start
|
||
* endpoint, `at` itself also moves down by one here -- that row/col is
|
||
* gone, so the range's last surviving row/col is one less than before. */
|
||
function deleteShiftEnd(v: number, at: number): number {
|
||
return v >= at ? v - 1 : v;
|
||
}
|
||
|
||
function axisValue(axis: Axis, c: { col: number; row: number }): number {
|
||
return axis === 'row' ? c.row : c.col;
|
||
}
|
||
function withAxisValue(
|
||
axis: Axis,
|
||
c: { col: number; row: number; colAbs: boolean; rowAbs: boolean },
|
||
v: number,
|
||
): string {
|
||
return axis === 'row' ? toA1(c.col, v, c.colAbs, c.rowAbs) : toA1(v, c.row, c.colAbs, c.rowAbs);
|
||
}
|
||
|
||
/** True iff, with `toks[i]` a known "!" (BANG), the shape ahead is
|
||
* IDENT/QUOTED "::" -- i.e. this "!" really introduces a SHEET name
|
||
* followed by a table qualifier (the 3-part `Blatt!Tabelle::Zelle` form),
|
||
* not a bare (old-style) table qualifier of its own. Exact same lookahead
|
||
* as `parser.ts#Parser.looksLikeSheetThenTable`. */
|
||
function looksLikeSheetThenTable(toks: Token[], i: number): boolean {
|
||
const n1 = toks[i + 1];
|
||
const n2 = toks[i + 2];
|
||
return !!n1 && (n1.type === 'IDENT' || n1.type === 'QUOTED') && !!n2 && n2.type === 'DBLCOLON';
|
||
}
|
||
|
||
/**
|
||
* Rewrite every reference to `targetTableId` inside one formula for a
|
||
* structural row/col insert (`delta` `1`) or delete (`delta` `-1`) at index
|
||
* `at` on `axis`. `homeTableId` is the formula's *own* table (needed to
|
||
* resolve a bare, unqualified reference, and -- via a transiently-set
|
||
* `wb.homeTableId` -- to break a same-table-name-on-two-sheets ambiguity
|
||
* the exact same way `wb.resolveTableName` already does for evaluation;
|
||
* this function literally calls it).
|
||
*
|
||
* A reference landing exactly on a deleted row/col -- or a range that
|
||
* collapses entirely (every row/col it covered was the one deleted) --
|
||
* becomes a literal `#REF` (see engine/parser.ts's `ErrorLiteralNode`),
|
||
* the same thing Excel shows inline for a broken reference. A range that
|
||
* merely *shrinks* (only part of it was the deleted line) stays a valid,
|
||
* smaller range, exactly like Excel.
|
||
*/
|
||
function rewriteRefsForStructuralChange(
|
||
raw: string,
|
||
homeTableId: string,
|
||
targetTableId: string,
|
||
axis: Axis,
|
||
at: number,
|
||
delta: 1 | -1,
|
||
wb: Workbook,
|
||
): string {
|
||
if (!raw.startsWith('=')) return raw;
|
||
const body = raw.slice(1);
|
||
let toks: Token[];
|
||
try {
|
||
toks = tokenize(body);
|
||
} catch {
|
||
return raw; // unparsable -- leave as-is, the evaluator already surfaces #ERR
|
||
}
|
||
|
||
const savedHome = wb.homeTableId;
|
||
wb.homeTableId = homeTableId;
|
||
const resolve = (name: string, sheetName: string | null): string | null =>
|
||
wb.resolveTableName(name, sheetName);
|
||
|
||
interface Repl {
|
||
pos: number;
|
||
end: number;
|
||
text: string;
|
||
}
|
||
const repls: Repl[] = [];
|
||
|
||
let i = 0;
|
||
while (i < toks.length && toks[i].type !== 'EOF') {
|
||
const tok = toks[i];
|
||
let cellIdx = -1;
|
||
let resolvedTableId: string | null = null;
|
||
|
||
if ((tok.type === 'IDENT' || tok.type === 'QUOTED') && toks[i + 1]) {
|
||
if (toks[i + 1].type === 'BANG' && looksLikeSheetThenTable(toks, i + 1)) {
|
||
resolvedTableId = resolve(toks[i + 2].value, tok.value); // sheet=tok, table=toks[i+2]
|
||
cellIdx = i + 4; // consumed: IDENT/QUOTED "!" IDENT/QUOTED "::"
|
||
} else if (toks[i + 1].type === 'BANG' || toks[i + 1].type === 'DBLCOLON') {
|
||
resolvedTableId = resolve(tok.value, null);
|
||
cellIdx = i + 2;
|
||
}
|
||
}
|
||
if (cellIdx === -1 && tok.type === 'IDENT' && looksLikeCell(tok.value)) {
|
||
resolvedTableId = homeTableId;
|
||
cellIdx = i;
|
||
}
|
||
if (cellIdx === -1) {
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
const cellTokA = toks[cellIdx];
|
||
if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) {
|
||
i++; // qualifier not actually followed by a cell -- malformed, leave alone
|
||
continue;
|
||
}
|
||
const isRangeStart = toks[cellIdx + 1]?.type === 'COLON';
|
||
const cellTokB = isRangeStart ? toks[cellIdx + 2] : undefined;
|
||
const validRange = !!cellTokB && cellTokB.type === 'IDENT' && looksLikeCell(cellTokB.value);
|
||
|
||
if (resolvedTableId === targetTableId) {
|
||
const a = parseA1(cellTokA.value);
|
||
if (validRange && cellTokB) {
|
||
const b = parseA1(cellTokB.value);
|
||
const va = axisValue(axis, a);
|
||
const vb = axisValue(axis, b);
|
||
const lo = Math.min(va, vb);
|
||
const hi = Math.max(va, vb);
|
||
if (delta > 0) {
|
||
const newLo = insertShift(lo, at);
|
||
const newHi = insertShift(hi, at);
|
||
if (newLo !== lo) {
|
||
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, va === lo ? newLo : newHi) });
|
||
}
|
||
if (newHi !== hi) {
|
||
repls.push({ pos: cellTokB.pos, end: cellTokB.pos + cellTokB.value.length, text: withAxisValue(axis, b, vb === lo ? newLo : newHi) });
|
||
}
|
||
} else {
|
||
const newLo = deleteShiftStart(lo, at);
|
||
const newHi = deleteShiftEnd(hi, at);
|
||
if (newLo > newHi) {
|
||
repls.push({ pos: cellTokA.pos, end: cellTokB.pos + cellTokB.value.length, text: '#REF' });
|
||
} else {
|
||
if (newLo !== lo) {
|
||
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, va === lo ? newLo : newHi) });
|
||
}
|
||
if (newHi !== hi) {
|
||
repls.push({ pos: cellTokB.pos, end: cellTokB.pos + cellTokB.value.length, text: withAxisValue(axis, b, vb === lo ? newLo : newHi) });
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
const v = axisValue(axis, a);
|
||
if (delta > 0) {
|
||
const nv = insertShift(v, at);
|
||
if (nv !== v) {
|
||
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, nv) });
|
||
}
|
||
} else if (v === at) {
|
||
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: '#REF' });
|
||
} else if (v > at) {
|
||
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, v - 1) });
|
||
}
|
||
}
|
||
}
|
||
|
||
i = validRange ? cellIdx + 3 : cellIdx + 1;
|
||
}
|
||
|
||
wb.homeTableId = savedHome;
|
||
if (repls.length === 0) return raw;
|
||
repls.sort((x, y) => x.pos - y.pos);
|
||
let out = '';
|
||
let cursor = 0;
|
||
for (const r of repls) {
|
||
out += body.slice(cursor, r.pos) + r.text;
|
||
cursor = r.end;
|
||
}
|
||
out += body.slice(cursor);
|
||
return '=' + out;
|
||
}
|
||
|
||
/** `'` needs quoting whenever a name isn't a bare identifier (spaces,
|
||
* punctuation, ...) -- shared by the table- and sheet-qualifier cases.
|
||
* Module-level (not a store method) since it's pure and both
|
||
* `rewriteRefsForRename` below and the store's own `qualifyRef` need it. */
|
||
function quoteQualifier(name: string): string {
|
||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`;
|
||
}
|
||
|
||
/**
|
||
* Rewrite every reference to `targetTableId` inside one formula so its
|
||
* qualifier text uses `newName` instead of whatever name it was written
|
||
* with -- called when a table is renamed (see `renameObject`), so a
|
||
* formula elsewhere that still says `AlterName!B2` keeps pointing at the
|
||
* SAME table instead of becoming `#REF` the next time the workbook
|
||
* rebuilds ("Referenzen in Formeln müssen immer konsistent sein").
|
||
*
|
||
* Same token-splice technique and qualifier-detection shape as
|
||
* `rewriteRefsForStructuralChange` above (only the qualifier token's own
|
||
* text is touched, everything else -- including whitespace and the cell/
|
||
* range part -- stays byte-identical), but simpler: no coordinate
|
||
* shifting, and only the TABLE-name token is ever replaced -- a 3-part
|
||
* `Blatt!Tabelle::Zelle` form keeps its own sheet qualifier untouched,
|
||
* only `Tabelle` becomes `newName`. `homeTableId` is the formula's own
|
||
* table, needed the same way as in the structural-change rewriter, to
|
||
* resolve a same-name-on-two-sheets ambiguity via the real
|
||
* `wb.resolveTableName` rules rather than a naive string match.
|
||
*/
|
||
function rewriteRefsForRename(
|
||
raw: string,
|
||
homeTableId: string,
|
||
targetTableId: string,
|
||
newName: string,
|
||
wb: Workbook,
|
||
): string {
|
||
if (!raw.startsWith('=')) return raw;
|
||
const body = raw.slice(1);
|
||
let toks: Token[];
|
||
try {
|
||
toks = tokenize(body);
|
||
} catch {
|
||
return raw; // unparsable -- leave as-is, the evaluator already surfaces #ERR
|
||
}
|
||
|
||
const savedHome = wb.homeTableId;
|
||
wb.homeTableId = homeTableId;
|
||
const resolve = (name: string, sheetName: string | null): string | null =>
|
||
wb.resolveTableName(name, sheetName);
|
||
|
||
interface Repl {
|
||
pos: number;
|
||
end: number;
|
||
text: string;
|
||
}
|
||
const repls: Repl[] = [];
|
||
|
||
let i = 0;
|
||
while (i < toks.length && toks[i].type !== 'EOF') {
|
||
const tok = toks[i];
|
||
let cellIdx = -1;
|
||
let resolvedTableId: string | null = null;
|
||
let qualTok: Token | null = null;
|
||
|
||
if ((tok.type === 'IDENT' || tok.type === 'QUOTED') && toks[i + 1]) {
|
||
if (toks[i + 1].type === 'BANG' && looksLikeSheetThenTable(toks, i + 1)) {
|
||
qualTok = toks[i + 2]; // the TABLE token of the 3-part form -- toks[i]/tok is the sheet, left alone
|
||
resolvedTableId = resolve(qualTok.value, tok.value);
|
||
cellIdx = i + 4; // consumed: IDENT/QUOTED "!" IDENT/QUOTED "::"
|
||
} else if (toks[i + 1].type === 'BANG' || toks[i + 1].type === 'DBLCOLON') {
|
||
qualTok = tok;
|
||
resolvedTableId = resolve(tok.value, null);
|
||
cellIdx = i + 2;
|
||
}
|
||
}
|
||
if (cellIdx === -1) {
|
||
i++; // a bare, unqualified cell never needs rewriting on rename -- it's already "this table"
|
||
continue;
|
||
}
|
||
|
||
const cellTokA = toks[cellIdx];
|
||
if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) {
|
||
i++; // qualifier not actually followed by a cell -- malformed, leave alone
|
||
continue;
|
||
}
|
||
const isRangeStart = toks[cellIdx + 1]?.type === 'COLON';
|
||
const cellTokB = isRangeStart ? toks[cellIdx + 2] : undefined;
|
||
const validRange = !!cellTokB && cellTokB.type === 'IDENT' && looksLikeCell(cellTokB.value);
|
||
|
||
if (resolvedTableId === targetTableId && qualTok) {
|
||
repls.push({ pos: qualTok.pos, end: qualTok.pos + qualTok.value.length, text: quoteQualifier(newName) });
|
||
}
|
||
|
||
i = validRange ? cellIdx + 3 : cellIdx + 1;
|
||
}
|
||
|
||
wb.homeTableId = savedHome;
|
||
if (repls.length === 0) return raw;
|
||
repls.sort((x, y) => x.pos - y.pos);
|
||
let out = '';
|
||
let cursor = 0;
|
||
for (const r of repls) {
|
||
out += body.slice(cursor, r.pos) + r.text;
|
||
cursor = r.end;
|
||
}
|
||
out += body.slice(cursor);
|
||
return '=' + out;
|
||
}
|
||
|
||
function loadInitial(): DocumentState {
|
||
try {
|
||
const raw =
|
||
localStorage.getItem(STORAGE_KEY) ?? localStorage.getItem(LEGACY_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 createBlankDocument();
|
||
}
|
||
|
||
// ---- store singleton --------------------------------------------------
|
||
|
||
function createStore() {
|
||
const doc = reactive<DocumentState>(loadInitial());
|
||
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
|
||
const recalcTick = ref(0);
|
||
const bump = () => (recalcTick.value += 1);
|
||
|
||
/** View-only state: not part of the document, not persisted. */
|
||
const ui = reactive({
|
||
settingsOpen: false,
|
||
/** The dependency-graph drawer's centered cell, or `null` when closed
|
||
* -- a single document-wide panel (like `settingsOpen`), not owned by
|
||
* any one `TableGrid` instance, so navigating across tables/sheets
|
||
* inside it doesn't depend on which grid happened to open it. */
|
||
depGraph: null as { tableId: string; col: number; row: number } | null,
|
||
});
|
||
/** Copy/cut buffer -- not part of the persisted document. */
|
||
const clipboard = ref<ClipboardData | null>(null);
|
||
/** Whichever formula editor is currently open (in-cell editor or formula
|
||
* bar), if any -- drives "point mode": a grid click inserts a reference
|
||
* instead of changing the selection. Not part of the document either. */
|
||
const formulaEditor = ref<FormulaEditorHandle | null>(null);
|
||
/** The most recently inserted-by-click reference's span in the editor's
|
||
* text, so the *next* click replaces it instead of appending another --
|
||
* until the user types something (a plain assignment, not a ref(), so it
|
||
* never itself triggers reactivity/undo history). */
|
||
let pointSpan: { start: number; end: number } | null = null;
|
||
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
|
||
|
||
function syncWorkbook() {
|
||
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
|
||
bump();
|
||
}
|
||
|
||
// ---- persistence (debounced) --------------------------------------
|
||
// A manually-called signal, not a `watch(() => doc, ..., { deep: true })`
|
||
// -- Vue's deep watch re-traverses the *entire* reactive object graph on
|
||
// every single mutation just to re-establish tracking, an O(total document
|
||
// size) cost per edit regardless of what actually changed (measured: on a
|
||
// 50k-row table, one cell edit went from ~450ms with a deep watch driving
|
||
// this and the history tracking below to <1ms without it). Every
|
||
// content-mutating function in this store calls `markDirty()` below (or,
|
||
// for a mutation that must never itself become an undo step --
|
||
// selection/navigation -- `scheduleSave()` alone) explicitly, the same
|
||
// convention `bump()`/`recalcTick` above already uses for the engine cache.
|
||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||
function scheduleSave() {
|
||
clearTimeout(persistTimer);
|
||
persistTimer = setTimeout(() => {
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(doc));
|
||
} catch {
|
||
/* quota / privacy mode -- ignore */
|
||
}
|
||
}, 250);
|
||
}
|
||
|
||
// ---- 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,
|
||
namedRanges: d.namedRanges,
|
||
macros: d.macros,
|
||
});
|
||
let historySnapshot = snapshot();
|
||
let historyFingerprint = contentFingerprint(historySnapshot);
|
||
let historyTimer: ReturnType<typeof setTimeout> | undefined;
|
||
// undoStack/redoStack are plain arrays (snapshots are large; wrapping them
|
||
// in reactive() would make Vue deep-proxy every one for no benefit) --
|
||
// this tick is what canUndo/canRedo actually track. historyPending mirrors
|
||
// "does the doc currently differ from the last committed baseline" so
|
||
// canUndo goes true the instant a change happens, not once the debounce
|
||
// settles (undo() itself already doesn't wait either way -- it flushes any
|
||
// pending change synchronously -- this is purely so a bound UI button
|
||
// doesn't lag behind what Ctrl+Z would actually do right now).
|
||
const historyTick = ref(0);
|
||
const historyPending = ref(false);
|
||
// true for the duration of one runMacro() call (set/cleared there) -- a
|
||
// macro with a GET/POST can genuinely take longer than HISTORY_COALESCE_MS
|
||
// between two mutations (a real network round-trip), which would otherwise
|
||
// let the debounced commit below fire *during* the run and split one
|
||
// macro into two+ undo steps, silently breaking the documented "one
|
||
// synchronous run = one undo step" guarantee (see README's "Makros"
|
||
// section). While this is true, `markDirty()` below still tracks
|
||
// historyPending/redoStack (so a bound Undo/Redo button stays accurate
|
||
// mid-run) but never *schedules* commitHistoryStep -- runMacro() calls it
|
||
// exactly once itself, in its `finally`, regardless of how long the
|
||
// run's awaits took.
|
||
let macroRunActive = false;
|
||
|
||
function commitHistoryStep() {
|
||
clearTimeout(historyTimer);
|
||
const fp = contentFingerprint(doc);
|
||
historyPending.value = false;
|
||
if (fp === historyFingerprint) return; // settled back to a no-op, or nothing pending
|
||
undoStack.push(historySnapshot);
|
||
if (undoStack.length > MAX_HISTORY) undoStack.shift();
|
||
historySnapshot = snapshot();
|
||
historyFingerprint = fp;
|
||
historyTick.value++;
|
||
}
|
||
/**
|
||
* Called explicitly by every content-mutating store function below (never
|
||
* by applyDocumentState/undo/redo, which reconcile history state
|
||
* themselves) -- marks the doc dirty, kills any reachable redo branch on
|
||
* the *first* mutation of a new burst (matching real undo/redo semantics:
|
||
* a new edit kills "redo" immediately, not once the debounce settles), and
|
||
* (re)schedules the debounced commit.
|
||
*
|
||
* Unlike the deep-watch design this replaces, this always treats the call
|
||
* as a genuine change -- no whole-document fingerprint diff here, since
|
||
* that per-mutation traversal is exactly the O(document size) cost being
|
||
* eliminated. The one safety net that still runs a real diff,
|
||
* `commitHistoryStep`'s own fingerprint check above, still guarantees a
|
||
* burst that settles back to a no-op never lands on the undo stack -- it
|
||
* just now runs once per debounced burst instead of on every mutation.
|
||
* `setCellInput` additionally guards its own no-op case (retyping the same
|
||
* value) up front so the common "F2, Enter" case doesn't even call this.
|
||
*/
|
||
function markDirty() {
|
||
scheduleSave();
|
||
if (!historyPending.value) {
|
||
redoStack.length = 0;
|
||
historyTick.value++;
|
||
}
|
||
historyPending.value = true;
|
||
clearTimeout(historyTimer);
|
||
if (macroRunActive) return; // runMacro() commits exactly once itself, see its own comment above
|
||
historyTimer = setTimeout(commitHistoryStep, HISTORY_COALESCE_MS);
|
||
}
|
||
|
||
/** Wipes undo/redo history and re-baselines it on the current doc -- used
|
||
* when a *different* document is loaded wholesale (replaceDocument()),
|
||
* never by undo()/redo() themselves. */
|
||
function resetHistory() {
|
||
clearTimeout(historyTimer);
|
||
undoStack.length = 0;
|
||
redoStack.length = 0;
|
||
historySnapshot = snapshot();
|
||
historyFingerprint = contentFingerprint(historySnapshot);
|
||
historyPending.value = false;
|
||
historyTick.value++;
|
||
}
|
||
|
||
const 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();
|
||
applyDocumentState(prev); // schedules a save itself; never calls markDirty
|
||
// reconcile manually -- applyDocumentState() doesn't touch history state
|
||
historySnapshot = snapshot();
|
||
historyFingerprint = contentFingerprint(doc);
|
||
historyPending.value = false;
|
||
historyTick.value++;
|
||
}
|
||
function redo() {
|
||
clearTimeout(historyTimer);
|
||
const next = redoStack.pop();
|
||
if (!next) return;
|
||
undoStack.push(snapshot());
|
||
if (undoStack.length > MAX_HISTORY) undoStack.shift();
|
||
applyDocumentState(next); // schedules a save itself; never calls markDirty
|
||
historySnapshot = snapshot();
|
||
historyFingerprint = contentFingerprint(doc);
|
||
historyPending.value = false;
|
||
historyTick.value++;
|
||
}
|
||
|
||
// ---- id / name helpers ------------------------------------------
|
||
let seq = 0;
|
||
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);
|
||
/** Case-insensitive/trimmed lookup by *name* -- unlike `sheetById`, needed
|
||
* for a macro's plain-string sheet references (`ADDSHEET`, `RENAMESHEET`,
|
||
* `DELETESHEET`; see `runMacro` below), which address a sheet the same
|
||
* way a formula's own qualifier addresses a table: by name, not id. */
|
||
const sheetByName = (name: string): Sheet | undefined =>
|
||
doc.sheets.find((s) => s.name.trim().toLowerCase() === name.trim().toLowerCase());
|
||
const activeSheet = computed(() => sheetById(doc.activeSheetId) ?? doc.sheets[0]);
|
||
|
||
function objectById(id: string | null): SheetObject | undefined {
|
||
if (!id) return undefined;
|
||
for (const s of doc.sheets) {
|
||
const o = s.objects.find((x) => x.id === id);
|
||
if (o) return o;
|
||
}
|
||
return undefined;
|
||
}
|
||
function sheetIdOfObject(id: string): string | undefined {
|
||
return doc.sheets.find((s) => s.objects.some((o) => o.id === id))?.id;
|
||
}
|
||
const tableById = (id: string): TableObject | undefined => {
|
||
const o = objectById(id);
|
||
return o && o.kind === 'table' ? o : undefined;
|
||
};
|
||
const tablesOf = (sheetId: string): TableObject[] =>
|
||
(sheetById(sheetId)?.objects.filter((o): o is TableObject => o.kind === 'table') ?? []);
|
||
|
||
const activeObject = computed(() => objectById(doc.activeObjectId));
|
||
const activeTable = computed<TableObject | undefined>(() => {
|
||
if (doc.selection) return tableById(doc.selection.tableId);
|
||
return tablesOf(doc.activeSheetId)[0];
|
||
});
|
||
|
||
// ---- sheet structure -----------------------------------------
|
||
function setActiveSheet(id: string) {
|
||
if (!sheetById(id) || id === doc.activeSheetId) return;
|
||
doc.activeSheetId = id;
|
||
doc.activeObjectId = null;
|
||
const first = tablesOf(id)[0];
|
||
doc.selection = first
|
||
? { tableId: first.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } }
|
||
: null;
|
||
scheduleSave(); // navigation only -- never an undo step
|
||
}
|
||
|
||
function addSheet(name?: string): string {
|
||
const id = uid('sheet');
|
||
doc.sheets.push({ id, name: uniqueName(name || 'Blatt', sheetNames()), objects: [] });
|
||
setActiveSheet(id);
|
||
markDirty();
|
||
return id;
|
||
}
|
||
function renameSheet(id: string, name: string) {
|
||
const s = sheetById(id);
|
||
const clean = name.trim();
|
||
if (!s || !clean) return;
|
||
const others = new Set(doc.sheets.filter((o) => o.id !== id).map((o) => o.name.toLowerCase()));
|
||
s.name = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean;
|
||
markDirty();
|
||
}
|
||
function deleteSheet(id: string) {
|
||
if (doc.sheets.length <= 1) return;
|
||
const idx = doc.sheets.findIndex((s) => s.id === id);
|
||
if (idx < 0) return;
|
||
const hadTables = tablesOf(id).length > 0;
|
||
doc.sheets.splice(idx, 1);
|
||
if (doc.activeSheetId === id) setActiveSheet(doc.sheets[Math.max(0, idx - 1)].id);
|
||
if (hadTables) syncWorkbook(); // dependents elsewhere become #REF
|
||
markDirty();
|
||
}
|
||
function moveSheet(from: number, to: number) {
|
||
if (from < 0 || to < 0 || from >= doc.sheets.length || to >= doc.sheets.length || from === to)
|
||
return;
|
||
const [s] = doc.sheets.splice(from, 1);
|
||
doc.sheets.splice(to, 0, s);
|
||
markDirty();
|
||
}
|
||
|
||
// ---- per-sheet appearance + zoom -----------------------------
|
||
function setSheetBackground(sheetId: string, color: string | null) {
|
||
const s = sheetById(sheetId);
|
||
if (!s) return;
|
||
if (color) s.background = color;
|
||
else delete s.background;
|
||
markDirty();
|
||
}
|
||
function setSheetForeground(sheetId: string, color: string | null) {
|
||
const s = sheetById(sheetId);
|
||
if (!s) return;
|
||
if (color) s.foreground = color;
|
||
else delete s.foreground;
|
||
markDirty();
|
||
}
|
||
function setSheetShowGrid(sheetId: string, show: boolean) {
|
||
const s = sheetById(sheetId);
|
||
if (!s) return;
|
||
s.showGrid = show;
|
||
markDirty();
|
||
}
|
||
function setSheetGridSize(sheetId: string, size: number) {
|
||
const s = sheetById(sheetId);
|
||
if (!s) return;
|
||
s.gridSize = Math.round(Math.min(MAX_GRID_SIZE, Math.max(MIN_GRID_SIZE, size)));
|
||
markDirty();
|
||
}
|
||
function setSheetZoom(sheetId: string, z: number) {
|
||
const s = sheetById(sheetId);
|
||
if (!s) return;
|
||
s.zoom = clampZoom(z);
|
||
markDirty();
|
||
}
|
||
/** Zoom of the currently active sheet (used by the canvas + zoom bar). */
|
||
const activeZoom = computed(() => activeSheet.value.zoom ?? 1);
|
||
const setZoom = (z: number) => setSheetZoom(doc.activeSheetId, z);
|
||
|
||
// ---- per-object background + text colour (table / chart / text) ----
|
||
function setObjectBg(objId: string, color: string | null) {
|
||
const o = objectById(objId);
|
||
if (!o) return;
|
||
if (color) o.bg = color;
|
||
else delete o.bg;
|
||
if (o.kind !== 'table') bump();
|
||
markDirty();
|
||
}
|
||
function setObjectTextColor(objId: string, color: string | null) {
|
||
const o = objectById(objId);
|
||
if (!o) return;
|
||
if (o.kind === 'text') {
|
||
if (color) o.color = color;
|
||
else delete o.color;
|
||
bump();
|
||
} else {
|
||
if (color) o.fg = color;
|
||
else delete o.fg;
|
||
if (o.kind === 'chart') bump();
|
||
}
|
||
markDirty();
|
||
}
|
||
function setTableHideChromeUnfocused(tableId: string, hide: boolean) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
if (hide) t.hideChromeUnfocused = true;
|
||
else delete t.hideChromeUnfocused;
|
||
markDirty();
|
||
}
|
||
|
||
function setCsvDelimiter(d: ',' | ';') {
|
||
doc.csvDelimiter = d === ';' ? ';' : ',';
|
||
markDirty();
|
||
}
|
||
|
||
// ---- canvas objects ----------------------------------------
|
||
function nextCascade(sheet: Sheet): { x: number; y: number } {
|
||
const n = sheet.objects.length;
|
||
return { x: 40 + (n % 6) * CASCADE, y: 40 + (n % 6) * CASCADE };
|
||
}
|
||
|
||
function addTable(sheetId: string, pos?: { x: number; y: number }): string {
|
||
const sheet = sheetById(sheetId);
|
||
if (!sheet) return '';
|
||
const id = uid('tbl');
|
||
const p = pos ?? nextCascade(sheet);
|
||
const table: TableObject = {
|
||
kind: 'table',
|
||
id,
|
||
name: uniqueName('Tabelle', new Set([...tableNamesOnSheet(sheetId), ...objectNamesOnSheet(sheetId)])),
|
||
x: Math.max(0, Math.round(p.x)),
|
||
y: Math.max(0, Math.round(p.y)),
|
||
rows: 12,
|
||
cols: 6,
|
||
colWidths: {},
|
||
cells: {},
|
||
view: defaultTableView(),
|
||
};
|
||
sheet.objects.push(table);
|
||
doc.activeObjectId = id;
|
||
doc.activeSheetId = sheetId;
|
||
doc.selection = { tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
|
||
syncWorkbook();
|
||
markDirty();
|
||
return id;
|
||
}
|
||
|
||
function addChart(
|
||
sheetId: string,
|
||
pos?: { x: number; y: number },
|
||
sourceTableId?: string | null,
|
||
): string {
|
||
const sheet = sheetById(sheetId);
|
||
if (!sheet) return '';
|
||
const id = uid('cht');
|
||
const p = pos ?? nextCascade(sheet);
|
||
const src = sourceTableId ?? tablesOf(sheetId)[0]?.id ?? allTables(doc)[0]?.id ?? null;
|
||
const chart: ChartObject = {
|
||
kind: 'chart',
|
||
id,
|
||
name: uniqueName('Diagramm', objectNames()),
|
||
x: Math.max(0, Math.round(p.x)),
|
||
y: Math.max(0, Math.round(p.y)),
|
||
width: 460,
|
||
height: 300,
|
||
view: 'bar',
|
||
sourceTableId: src,
|
||
};
|
||
sheet.objects.push(chart);
|
||
doc.activeObjectId = id;
|
||
doc.activeSheetId = sheetId;
|
||
bump();
|
||
markDirty();
|
||
return id;
|
||
}
|
||
|
||
function addText(sheetId: string, pos?: { x: number; y: number }): string {
|
||
const sheet = sheetById(sheetId);
|
||
if (!sheet) return '';
|
||
const id = uid('txt');
|
||
const p = pos ?? nextCascade(sheet);
|
||
const text: TextObject = {
|
||
kind: 'text',
|
||
id,
|
||
name: uniqueName('Text', objectNames()),
|
||
x: Math.max(0, Math.round(p.x)),
|
||
y: Math.max(0, Math.round(p.y)),
|
||
width: 260,
|
||
height: 96,
|
||
text: 'Text …',
|
||
fontSize: 15,
|
||
align: 'left',
|
||
};
|
||
sheet.objects.push(text);
|
||
doc.activeObjectId = id;
|
||
doc.activeSheetId = sheetId;
|
||
bump();
|
||
markDirty();
|
||
return id;
|
||
}
|
||
function setTextContent(objId: string, text: string) {
|
||
const o = objectById(objId);
|
||
if (o?.kind !== 'text') return;
|
||
o.text = text;
|
||
markDirty();
|
||
}
|
||
function setTextFormat(objId: string, patch: Partial<Omit<TextObject, 'kind' | 'id'>>) {
|
||
const o = objectById(objId);
|
||
if (o?.kind !== 'text') return;
|
||
if (patch.fontSize !== undefined) o.fontSize = Math.min(96, Math.max(9, Math.round(patch.fontSize)));
|
||
if (patch.align !== undefined) o.align = patch.align;
|
||
if (patch.bold !== undefined) o.bold = patch.bold || undefined;
|
||
if (patch.italic !== undefined) o.italic = patch.italic || undefined;
|
||
if ('color' in patch) o.color = patch.color || undefined;
|
||
if ('bg' in patch) o.bg = patch.bg || undefined;
|
||
markDirty();
|
||
}
|
||
function resizeText(objId: string, width: number, height: number) {
|
||
const o = objectById(objId);
|
||
if (o?.kind !== 'text') return;
|
||
o.width = Math.max(80, Math.round(width));
|
||
o.height = Math.max(32, Math.round(height));
|
||
markDirty();
|
||
}
|
||
|
||
// ---- buttons (canvas objects that run a macro on click) ---------------
|
||
function addButton(sheetId: string, pos?: { x: number; y: number }): string {
|
||
const sheet = sheetById(sheetId);
|
||
if (!sheet) return '';
|
||
const id = uid('btn');
|
||
const p = pos ?? nextCascade(sheet);
|
||
const button: ButtonObject = {
|
||
kind: 'button',
|
||
id,
|
||
name: uniqueName('Button', objectNames()),
|
||
x: Math.max(0, Math.round(p.x)),
|
||
y: Math.max(0, Math.round(p.y)),
|
||
width: 140,
|
||
height: 44,
|
||
label: 'Button',
|
||
macroId: null,
|
||
};
|
||
sheet.objects.push(button);
|
||
doc.activeObjectId = id;
|
||
doc.activeSheetId = sheetId;
|
||
bump();
|
||
markDirty();
|
||
return id;
|
||
}
|
||
function setButtonLabel(objId: string, label: string) {
|
||
const o = objectById(objId);
|
||
if (o?.kind !== 'button') return;
|
||
o.label = label;
|
||
markDirty();
|
||
}
|
||
function setButtonMacroId(objId: string, macroId: string | null) {
|
||
const o = objectById(objId);
|
||
if (o?.kind !== 'button') return;
|
||
o.macroId = macroId;
|
||
markDirty();
|
||
}
|
||
function resizeButton(objId: string, width: number, height: number) {
|
||
const o = objectById(objId);
|
||
if (o?.kind !== 'button') return;
|
||
o.width = Math.max(60, Math.round(width));
|
||
o.height = Math.max(28, Math.round(height));
|
||
markDirty();
|
||
}
|
||
|
||
function renameObject(objId: string, name: string) {
|
||
const o = objectById(objId);
|
||
const clean = name.trim();
|
||
if (!o || !clean) return;
|
||
if (o.kind === 'table') {
|
||
// uniqueness is per sheet only -- see engine/types.ts's EngineHost
|
||
const sheetId = sheetIdOfObject(objId);
|
||
const others = new Set(
|
||
(sheetId ? tablesOf(sheetId) : allTables(doc))
|
||
.filter((t) => t.id !== objId)
|
||
.map((t) => t.name.toLowerCase()),
|
||
);
|
||
const newName = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean;
|
||
// Rewrite every OTHER formula's qualifier BEFORE renaming -- wb still
|
||
// resolves the old name at this point, and rewriteRefsForRename needs
|
||
// that to identify which references actually point at this table.
|
||
// "Referenzen in Formeln müssen immer konsistent sein" (user request):
|
||
// without this, a formula elsewhere still saying the old name would
|
||
// silently become #REF the moment the workbook rebuilds below.
|
||
if (newName !== o.name) renameDocumentRefs(objId, newName);
|
||
o.name = newName;
|
||
syncWorkbook(); // formulas referencing the old/new name must re-resolve
|
||
} else {
|
||
o.name = clean;
|
||
}
|
||
markDirty();
|
||
}
|
||
|
||
function deleteObject(objId: string) {
|
||
const sheetId = sheetIdOfObject(objId);
|
||
const sheet = sheetId ? sheetById(sheetId) : undefined;
|
||
if (!sheet) return;
|
||
const o = sheet.objects.find((x) => x.id === objId);
|
||
const isTable = o?.kind === 'table';
|
||
sheet.objects = sheet.objects.filter((x) => x.id !== objId);
|
||
if (doc.activeObjectId === objId) doc.activeObjectId = null;
|
||
if (doc.selection?.tableId === objId) doc.selection = null;
|
||
// charts bound to a deleted table keep a dangling id -> they render empty
|
||
if (isTable) syncWorkbook();
|
||
else bump();
|
||
markDirty();
|
||
}
|
||
|
||
// ---- custom functions (document-level formula library) ----------------
|
||
// Created/edited/removed exclusively through the "𝑓 Funktionen" UI. A call
|
||
// is inlined into the caller's AST at parse time (engine/userFunctions.ts)
|
||
// -- renaming/deleting one behaves exactly like renaming/deleting a table:
|
||
// existing formula text is left as-is and becomes #NAME? / #REF on the next
|
||
// syncWorkbook(), there is no find-and-replace across stored formulas.
|
||
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||
|
||
const customFunctionNames = () => new Set(doc.customFunctions.map((f) => f.name.toLowerCase()));
|
||
|
||
/** Every (upper-cased) function name a stored custom function's body calls,
|
||
* tolerant of a body that -- in theory -- no longer parses (shouldn't
|
||
* happen; a save always validates first, but this is only used for a
|
||
* best-effort cycle check, so fail open rather than block unrelated saves). */
|
||
function bodyCallSet(f: Pick<CustomFunction, 'params' | 'body' | 'kind'>): Set<string> {
|
||
try {
|
||
return f.kind === 'block'
|
||
? collectCalledFunctionNamesInBlock(parseFunctionBlock(f.body, f.params))
|
||
: collectCalledFunctionNames(parseFormula(f.body, f.params));
|
||
} catch {
|
||
return new Set();
|
||
}
|
||
}
|
||
|
||
/** Does the call graph across every custom function (built-ins are never
|
||
* nodes -- they can't call back into user functions) contain a cycle? */
|
||
function callGraphHasCycle(graph: Map<string, Set<string>>): boolean {
|
||
const state = new Map<string, 1 | 2>(); // 1 = on the current DFS path, 2 = fully explored
|
||
function dfs(node: string): boolean {
|
||
const st = state.get(node);
|
||
if (st === 1) return true; // back-edge -> cycle
|
||
if (st === 2) return false;
|
||
state.set(node, 1);
|
||
for (const callee of graph.get(node) ?? []) {
|
||
if (graph.has(callee) && dfs(callee)) return true;
|
||
}
|
||
state.set(node, 2);
|
||
return false;
|
||
}
|
||
for (const node of graph.keys()) {
|
||
if (dfs(node)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** FOR/IN/DO/RETURN double as block-body keywords (see parser.ts's
|
||
* parseFunctionBlock) -- never usable as a parameter/local name, in
|
||
* either kind of function (so a name stays valid if the function is
|
||
* later switched to "Mit Schleife"). */
|
||
const RESERVED_NAMES = new Set(['FOR', 'IN', 'DO', 'RETURN']);
|
||
|
||
/** The macro write-action vocabulary (see engine/types.ts's
|
||
* `MacroWriteHost` and evaluator.ts's `execAction`) -- reserved against
|
||
* use as a macro-local variable/loop-variable name, same reasoning as
|
||
* `RESERVED_NAMES`: `SETVALUE = 5` would parse under the grammar (an
|
||
* ordinary assignment, no ambiguity) but reads as if it might be calling
|
||
* the action, so it's rejected at save time rather than left as a
|
||
* confusing-but-technically-legal trap. */
|
||
const MACRO_ACTION_NAMES = new Set([
|
||
'SETVALUE',
|
||
'INSERTROW',
|
||
'DELETEROW',
|
||
'INSERTCOL',
|
||
'DELETECOL',
|
||
'ADDTABLE',
|
||
'RENAMETABLE',
|
||
'DELETETABLE',
|
||
'ADDSHEET',
|
||
'RENAMESHEET',
|
||
'DELETESHEET',
|
||
]);
|
||
|
||
/** GET/POST/JSONGET (see evaluator.ts's `execStatementAsync`) -- reserved
|
||
* against use as a macro-local variable name, same reasoning as
|
||
* `MACRO_ACTION_NAMES`. Also used by `validateCustomFunction` to reject
|
||
* these three names as unreachable outside a macro (see there). */
|
||
const MACRO_NETWORK_NAMES = new Set(['GET', 'POST', 'JSONGET']);
|
||
|
||
/**
|
||
* Full validation for add/update: name & parameter shape, no collision with
|
||
* a built-in or another custom function, the body parses (as an
|
||
* expression, or -- for `kind: 'block'` -- as a FOR/DO/RETURN statement
|
||
* sequence, with every local/loop-variable name validated the same way as
|
||
* a parameter and every FOR source structurally a parameter/cell/range),
|
||
* and -- simulating the *whole* call graph with this function's (possibly
|
||
* new) definition in place -- no cycle. Returns a user-facing (German)
|
||
* error string, or null when the definition is safe to save. `ignoreId`
|
||
* excludes the function being edited from the collision/graph checks
|
||
* against its own old state.
|
||
*/
|
||
function validateCustomFunction(
|
||
name: string,
|
||
params: string[],
|
||
body: string,
|
||
kind: CustomFunction['kind'],
|
||
ignoreId: string | null,
|
||
): string | null {
|
||
const cleanName = name.trim();
|
||
if (!IDENT_RE.test(cleanName)) {
|
||
return `"${name}" ist kein gültiger Funktionsname.`;
|
||
}
|
||
if (looksLikeCell(cleanName)) {
|
||
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Funktionsname nicht erlaubt.`;
|
||
}
|
||
if (hasFunction(cleanName)) {
|
||
return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`;
|
||
}
|
||
const collision = doc.customFunctions.find(
|
||
(f) => f.id !== ignoreId && f.name.toLowerCase() === cleanName.toLowerCase(),
|
||
);
|
||
if (collision) return `Es gibt bereits eine Funktion namens "${collision.name}".`;
|
||
|
||
const cleanParams = params.map((p) => p.trim());
|
||
for (const p of cleanParams) {
|
||
if (!IDENT_RE.test(p)) return `"${p}" ist kein gültiger Parametername.`;
|
||
if (looksLikeCell(p)) return `Parameter "${p}" sieht wie ein Zellbezug aus.`;
|
||
if (RESERVED_NAMES.has(p.toUpperCase())) {
|
||
return `"${p}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Parametername verwendet werden.`;
|
||
}
|
||
}
|
||
if (new Set(cleanParams.map((p) => p.toLowerCase())).size !== cleanParams.length) {
|
||
return 'Parameternamen müssen eindeutig sein.';
|
||
}
|
||
|
||
let ownCalls: Set<string>;
|
||
if (kind === 'block') {
|
||
let block;
|
||
try {
|
||
block = parseFunctionBlock(body, cleanParams);
|
||
} catch (e) {
|
||
return `Code ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||
}
|
||
// declaredBlockNames() lists every assignment target/loop variable, but
|
||
// the SAME name legitimately reappears across statements (that's the
|
||
// whole point of an accumulator: `ergebnis = 1`, then later `FOR x IN
|
||
// bereich DO ergebnis = ergebnis * x` re-assigns "ergebnis", it doesn't
|
||
// declare a second one) -- so shape/reserved-word checks run on every
|
||
// occurrence (harmless to repeat), but the "no collision" check only
|
||
// looks at each *distinct* local name against the parameters, never
|
||
// against other locals (re-using a local's own name is exactly the
|
||
// point; two locals can even share a name across two separate loops).
|
||
const localNames = declaredBlockNames(block);
|
||
for (const n of localNames) {
|
||
if (!IDENT_RE.test(n)) return `"${n}" ist kein gültiger Name.`;
|
||
if (looksLikeCell(n)) return `"${n}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`;
|
||
if (RESERVED_NAMES.has(n.toUpperCase())) {
|
||
return `"${n}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`;
|
||
}
|
||
}
|
||
const paramSet = new Set(cleanParams.map((p) => p.toLowerCase()));
|
||
for (const n of new Set(localNames.map((x) => x.toLowerCase()))) {
|
||
if (paramSet.has(n)) return `"${n}" ist bereits ein Parametername.`;
|
||
}
|
||
const badFor = findInvalidForSource(block);
|
||
if (badFor) {
|
||
return `FOR ${badFor.varName} IN ...: die Quelle muss ein Parameter, eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`;
|
||
}
|
||
// conservative static check (see blockAlwaysReturns's own doc comment)
|
||
// -- a real "no RETURN reached" (e.g. an empty FOR range) still
|
||
// degrades to #ERR at the affected cell regardless, this just catches
|
||
// the common case immediately, before ever saving.
|
||
if (!blockAlwaysReturns(block)) {
|
||
return 'Nicht jeder Codepfad endet mit RETURN.';
|
||
}
|
||
ownCalls = collectCalledFunctionNamesInBlock(block);
|
||
} else {
|
||
let ast;
|
||
try {
|
||
ast = parseFormula(body, cleanParams);
|
||
} catch (e) {
|
||
return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||
}
|
||
ownCalls = collectCalledFunctionNames(ast);
|
||
}
|
||
|
||
// GET/POST/JSONGET are never registered as built-ins (see runMacro's
|
||
// MACRO_NETWORK_NAMES/execStatementAsync in evaluator.ts) -- calling
|
||
// one here would already just become #NAME? at evaluation time, same
|
||
// as any other unknown function. This check exists only to catch it
|
||
// immediately with a clear message, mirroring how the 11 write actions
|
||
// aren't separately validated here either (parser-level containment
|
||
// already makes ActionNode unreachable from a function body) -- for
|
||
// GET/POST/JSONGET there's no parser-level gate (they're ordinary
|
||
// FuncNode calls), so a save-time name check is the cheap equivalent.
|
||
for (const bad of MACRO_NETWORK_NAMES) {
|
||
if (ownCalls.has(bad)) {
|
||
return `"${bad}" ist nur in Makros erlaubt, nicht in benutzerdefinierten Funktionen.`;
|
||
}
|
||
}
|
||
|
||
const graph = new Map<string, Set<string>>();
|
||
for (const f of doc.customFunctions) {
|
||
if (f.id === ignoreId) continue;
|
||
graph.set(f.name.toUpperCase(), bodyCallSet(f));
|
||
}
|
||
graph.set(cleanName.toUpperCase(), ownCalls);
|
||
if (callGraphHasCycle(graph)) {
|
||
return `"${cleanName}" führt (direkt oder über andere Funktionen) zu einem Zyklus.`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function addCustomFunction(
|
||
name: string,
|
||
params: string[],
|
||
body: string,
|
||
kind: CustomFunction['kind'] = 'expression',
|
||
): string | { error: string } {
|
||
const error = validateCustomFunction(name, params, body, kind, null);
|
||
if (error) return { error };
|
||
const id = uid('fn');
|
||
const custom: CustomFunction = {
|
||
id,
|
||
name: name.trim(),
|
||
params: params.map((p) => p.trim()),
|
||
body: body.startsWith('=') ? body.slice(1) : body,
|
||
kind,
|
||
};
|
||
doc.customFunctions.push(custom);
|
||
syncWorkbook();
|
||
markDirty();
|
||
return id;
|
||
}
|
||
|
||
function updateCustomFunction(
|
||
id: string,
|
||
name: string,
|
||
params: string[],
|
||
body: string,
|
||
kind: CustomFunction['kind'] = 'expression',
|
||
): void | { error: string } {
|
||
const f = doc.customFunctions.find((x) => x.id === id);
|
||
if (!f) return { error: 'Funktion nicht gefunden.' };
|
||
const error = validateCustomFunction(name, params, body, kind, id);
|
||
if (error) return { error };
|
||
f.name = name.trim();
|
||
f.params = params.map((p) => p.trim());
|
||
f.body = body.startsWith('=') ? body.slice(1) : body;
|
||
f.kind = kind;
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
|
||
function deleteCustomFunction(id: string) {
|
||
doc.customFunctions = doc.customFunctions.filter((f) => f.id !== id);
|
||
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
|
||
markDirty();
|
||
}
|
||
|
||
// ---- named ranges (document-level names for a fixed cell/range) -------
|
||
// Created/edited/removed exclusively through the "🏷 Namen" UI. Unlike a
|
||
// custom function there is no expression of its own -- just fixed
|
||
// coordinates on a specific table -- so there's nothing that could
|
||
// cyclically reference itself, and no "does every path RETURN" check
|
||
// either. `tableId` (not the table's name) is what's stored, so a named
|
||
// range survives its target table being *renamed* (see
|
||
// engine/workbook.ts's `resolveNamedRange`); only an outright *deletion*
|
||
// of that table leaves it dangling -> #NAME? on the next rebuild, same
|
||
// "nothing is auto-rewritten" convention as a plain formula reference to a
|
||
// deleted table.
|
||
function validateNamedRange(
|
||
name: string,
|
||
tableId: string,
|
||
start: CellCoord,
|
||
end: CellCoord,
|
||
ignoreId: string | null,
|
||
): string | null {
|
||
const cleanName = name.trim();
|
||
if (!IDENT_RE.test(cleanName)) {
|
||
return `"${name}" ist kein gültiger Name.`;
|
||
}
|
||
if (looksLikeCell(cleanName)) {
|
||
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`;
|
||
}
|
||
if (RESERVED_NAMES.has(cleanName.toUpperCase())) {
|
||
return `"${cleanName}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`;
|
||
}
|
||
if (hasFunction(cleanName)) {
|
||
return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`;
|
||
}
|
||
const fnCollision = doc.customFunctions.find(
|
||
(f) => f.name.toLowerCase() === cleanName.toLowerCase(),
|
||
);
|
||
if (fnCollision) return `Es gibt bereits eine Funktion namens "${fnCollision.name}".`;
|
||
const rangeCollision = doc.namedRanges.find(
|
||
(r) => r.id !== ignoreId && r.name.toLowerCase() === cleanName.toLowerCase(),
|
||
);
|
||
if (rangeCollision) return `Es gibt bereits einen Namen "${rangeCollision.name}".`;
|
||
if (!tableById(tableId)) return 'Tabelle nicht gefunden.';
|
||
if (start.col < 0 || start.row < 0 || end.col < 0 || end.row < 0) {
|
||
return 'Ungültiger Bereich.';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function addNamedRange(
|
||
name: string,
|
||
tableId: string,
|
||
start: CellCoord,
|
||
end: CellCoord,
|
||
): string | { error: string } {
|
||
const error = validateNamedRange(name, tableId, start, end, null);
|
||
if (error) return { error };
|
||
const id = uid('nr');
|
||
doc.namedRanges.push({ id, name: name.trim(), tableId, start: { ...start }, end: { ...end } });
|
||
syncWorkbook();
|
||
markDirty();
|
||
return id;
|
||
}
|
||
|
||
function updateNamedRange(
|
||
id: string,
|
||
name: string,
|
||
tableId: string,
|
||
start: CellCoord,
|
||
end: CellCoord,
|
||
): void | { error: string } {
|
||
const r = doc.namedRanges.find((x) => x.id === id);
|
||
if (!r) return { error: 'Name nicht gefunden.' };
|
||
const error = validateNamedRange(name, tableId, start, end, id);
|
||
if (error) return { error };
|
||
r.name = name.trim();
|
||
r.tableId = tableId;
|
||
r.start = { ...start };
|
||
r.end = { ...end };
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
|
||
function deleteNamedRange(id: string) {
|
||
doc.namedRanges = doc.namedRanges.filter((r) => r.id !== id);
|
||
syncWorkbook(); // affected formulas become #NAME? on rebuild, same as a deleted table
|
||
markDirty();
|
||
}
|
||
|
||
// ---- macros (document-level, explicitly-triggered write scripts) ------
|
||
// Created/edited/removed exclusively through the "▶ Makros" UI, run via
|
||
// that panel's own "▶ Ausführen" button or a canvas button object's click
|
||
// -- NEVER callable from a cell formula (see store/types.ts's `Macro` doc
|
||
// comment for why this is a separate type from `CustomFunction`, not a
|
||
// third `kind`, so that's impossible by construction). Unlike a custom
|
||
// function's body, every cell/range reference in a macro must be
|
||
// qualified with a table name (`Umsatz::A1`, never bare `A1`) -- a macro
|
||
// has no "home table" the way a formula cell does.
|
||
function validateMacro(name: string, body: string, ignoreId: string | null): string | null {
|
||
const cleanName = name.trim();
|
||
if (!IDENT_RE.test(cleanName)) {
|
||
return `"${name}" ist kein gültiger Makroname.`;
|
||
}
|
||
if (looksLikeCell(cleanName)) {
|
||
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Makroname nicht erlaubt.`;
|
||
}
|
||
const collision = doc.macros.find(
|
||
(m) => m.id !== ignoreId && m.name.toLowerCase() === cleanName.toLowerCase(),
|
||
);
|
||
if (collision) return `Es gibt bereits ein Makro namens "${collision.name}".`;
|
||
|
||
let block;
|
||
try {
|
||
block = parseMacroBlock(body);
|
||
} catch (e) {
|
||
return `Makro ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||
}
|
||
const localNames = declaredBlockNames(block);
|
||
for (const n of localNames) {
|
||
if (!IDENT_RE.test(n)) return `"${n}" ist kein gültiger Name.`;
|
||
if (looksLikeCell(n)) return `"${n}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`;
|
||
if (RESERVED_NAMES.has(n.toUpperCase())) {
|
||
return `"${n}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`;
|
||
}
|
||
if (MACRO_ACTION_NAMES.has(n.toUpperCase())) {
|
||
return `"${n}" ist der Name einer Makro-Aktion und kann nicht als Name verwendet werden.`;
|
||
}
|
||
if (MACRO_NETWORK_NAMES.has(n.toUpperCase())) {
|
||
return `"${n}" ist der Name einer Netzwerk-Funktion und kann nicht als Name verwendet werden.`;
|
||
}
|
||
}
|
||
const badFor = findInvalidForSource(block);
|
||
if (badFor) {
|
||
return `FOR ${badFor.varName} IN ...: die Quelle muss eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`;
|
||
}
|
||
const badAction = findInvalidActionTarget(block);
|
||
if (badAction) {
|
||
return `${badAction.name}: das erste Argument muss ein Zell- oder Bereichsbezug sein, kein berechneter Ausdruck.`;
|
||
}
|
||
for (const ref of collectRefsInBlock(block)) {
|
||
if ((ref.kind === 'cell' || ref.kind === 'range') && ref.qualifier === null) {
|
||
return 'Zellbezüge in Makros müssen die Tabelle nennen, z. B. Umsatz::A1.';
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function addMacro(name: string, body: string): string | { error: string } {
|
||
const error = validateMacro(name, body, null);
|
||
if (error) return { error };
|
||
const id = uid('mac');
|
||
const macro: Macro = { id, name: name.trim(), body: body.startsWith('=') ? body.slice(1) : body };
|
||
doc.macros.push(macro);
|
||
bump();
|
||
markDirty();
|
||
return id;
|
||
}
|
||
|
||
function updateMacro(id: string, name: string, body: string): void | { error: string } {
|
||
const m = doc.macros.find((x) => x.id === id);
|
||
if (!m) return { error: 'Makro nicht gefunden.' };
|
||
const error = validateMacro(name, body, id);
|
||
if (error) return { error };
|
||
m.name = name.trim();
|
||
m.body = body.startsWith('=') ? body.slice(1) : body;
|
||
bump();
|
||
markDirty();
|
||
}
|
||
|
||
function deleteMacro(id: string) {
|
||
doc.macros = doc.macros.filter((m) => m.id !== id);
|
||
bump();
|
||
markDirty();
|
||
}
|
||
|
||
/** Resolve a macro action's plain "Tabelle" / "Blatt!Tabelle" string
|
||
* argument the same way a formula's own qualifier resolves -- via the
|
||
* live `wb.resolveTableName`. Reads the outer (closure-scoped) `wb`
|
||
* directly, so it always sees whatever `syncWorkbook()` most recently
|
||
* assigned -- critical when an earlier action in the SAME macro run was
|
||
* structural (see `runMacro`'s own doc comment). Throws a clear error
|
||
* when the table doesn't exist, instead of the primitives' own silent
|
||
* no-op on an unknown id. */
|
||
function resolveMacroTableRef(ref: string): string {
|
||
const bang = ref.indexOf('!');
|
||
const table = bang === -1 ? ref : ref.slice(bang + 1);
|
||
const sheet = bang === -1 ? null : ref.slice(0, bang);
|
||
const tableId = wb.resolveTableName(table, sheet);
|
||
if (!tableId) throw new Error(`unbekannte Tabelle '${ref}'`);
|
||
return tableId;
|
||
}
|
||
|
||
/**
|
||
* Runs a macro to completion (see store/types.ts's `Macro` doc comment
|
||
* for the "why explicit trigger, not recalc-embedded" design). `async`
|
||
* because a `GET`/`POST` genuinely waits on a real network round-trip --
|
||
* that costs the automatic "one JS turn = one undo step" property a
|
||
* fully synchronous run would otherwise get for free, so this function
|
||
* restores it explicitly instead (`macroRunActive` suppresses the
|
||
* debounced auto-commit for the run's whole duration; the `finally`
|
||
* below commits exactly once, however long the run's `await`s took --
|
||
* see `macroRunActive`'s own comment near the history watcher).
|
||
*
|
||
* All three hosts handed to the interpreter re-read the outer `wb`/`doc`
|
||
* on every call rather than capturing anything once at the top --
|
||
* required correctness, not a style choice: a structural action
|
||
* (INSERTROW, ADDTABLE, ...) calls `syncWorkbook()`, which reassigns
|
||
* `wb` to a brand-new `Workbook` instance; a cached reference from
|
||
* before that point would silently go stale for every read for the
|
||
* rest of the run.
|
||
*/
|
||
async function runMacro(macroId: string): Promise<{ ok: boolean; message: string }> {
|
||
const macro = doc.macros.find((m) => m.id === macroId);
|
||
if (!macro) return { ok: false, message: 'Makro nicht gefunden.' };
|
||
|
||
// addTable/addSheet/... are UI-oriented primitives that also navigate
|
||
// the user to what they just inserted -- exactly right for a toolbar
|
||
// click, but a surprising side effect for a macro run in the
|
||
// background (e.g. a button click on "Analyse" silently switching the
|
||
// view to some other sheet an ADDTABLE deep in the script happened to
|
||
// touch). Found live: running a macro containing ADDTABLE("Blatt 2";
|
||
// ...) yanked the active sheet to "Blatt 2". Snapshotting + restoring
|
||
// the view state (unless the macro deleted it out from under us) keeps
|
||
// the mutation but undoes only its incidental navigation.
|
||
const savedSheetId = doc.activeSheetId;
|
||
const savedObjectId = doc.activeObjectId;
|
||
const savedSelection = doc.selection ? { ...doc.selection } : null;
|
||
const restoreView = () => {
|
||
if (sheetById(savedSheetId)) doc.activeSheetId = savedSheetId;
|
||
doc.activeObjectId = savedObjectId && objectById(savedObjectId) ? savedObjectId : null;
|
||
doc.selection = savedSelection && objectById(savedSelection.tableId) ? savedSelection : null;
|
||
};
|
||
|
||
const macroHost: EngineHost = {
|
||
// Never actually consulted: validateMacro rejects every unqualified
|
||
// cell/range reference at save time, so evalScalar's 'cell' case
|
||
// never needs "this table" for a macro. A getter (not a fixed empty
|
||
// string) purely so this object has the exact same "always reads
|
||
// fresh" shape as every other field here.
|
||
get homeTableId() {
|
||
return '';
|
||
},
|
||
resolveTableName: (name, sheetName) => wb.resolveTableName(name, sheetName ?? null),
|
||
resolveNamedRange: (name) => wb.resolveNamedRange(name),
|
||
tableSize: (id) => wb.tableSize(id),
|
||
getComputed: (tableId, col, row) => wb.getComputed(tableId, col, row),
|
||
};
|
||
|
||
const writeHost: MacroWriteHost = {
|
||
setValue(cells, text) {
|
||
for (const c of cells) setCellInput(c.tableId, c.col, c.row, text);
|
||
},
|
||
insertRow(tableRef, at) {
|
||
insertRow(resolveMacroTableRef(tableRef), at);
|
||
},
|
||
deleteRow(tableRef, at) {
|
||
deleteRow(resolveMacroTableRef(tableRef), at);
|
||
},
|
||
insertCol(tableRef, at) {
|
||
insertColumn(resolveMacroTableRef(tableRef), at);
|
||
},
|
||
deleteCol(tableRef, at) {
|
||
deleteColumn(resolveMacroTableRef(tableRef), at);
|
||
},
|
||
addTable(sheetRef, name) {
|
||
const sheet = sheetByName(sheetRef);
|
||
if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`);
|
||
renameObject(addTable(sheet.id), name);
|
||
},
|
||
renameTable(tableRef, newName) {
|
||
renameObject(resolveMacroTableRef(tableRef), newName);
|
||
},
|
||
deleteTable(tableRef) {
|
||
deleteObject(resolveMacroTableRef(tableRef));
|
||
},
|
||
addSheet(name) {
|
||
addSheet(name);
|
||
},
|
||
renameSheet(sheetRef, newName) {
|
||
const sheet = sheetByName(sheetRef);
|
||
if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`);
|
||
renameSheet(sheet.id, newName);
|
||
},
|
||
deleteSheet(sheetRef) {
|
||
const sheet = sheetByName(sheetRef);
|
||
if (!sheet) throw new Error(`unbekanntes Blatt '${sheetRef}'`);
|
||
// deleteSheet() itself silently no-ops on the last sheet -- surface
|
||
// that as a real failure here instead of a misleading success toast.
|
||
if (doc.sheets.length <= 1) throw new Error('das letzte Blatt kann nicht gelöscht werden');
|
||
deleteSheet(sheet.id);
|
||
},
|
||
};
|
||
|
||
// GET/POST confirmation + fetch. Per-MACRO approval (not per-document
|
||
// and not per-session): an imported document with several small macros
|
||
// quietly contacting the same host should not have approving one of
|
||
// them silently authorize all the others too -- each macro earns its
|
||
// own one-time human decision the first time IT reaches a given host,
|
||
// persisted in `macro.approvedHosts` so it only ever asks once. The
|
||
// host (via `new URL(url).host`, i.e. including a non-default port) is
|
||
// recorded BEFORE the fetch itself, so the approval survives even if
|
||
// this particular request later fails.
|
||
const netHost: MacroNetworkHost = {
|
||
async request(method, url, body) {
|
||
let host: string;
|
||
try {
|
||
host = new URL(url).host;
|
||
} catch {
|
||
throw new Error(`${method}: '${url}' ist keine gültige URL`);
|
||
}
|
||
if (!macro.approvedHosts?.includes(host)) {
|
||
const verb = method === 'POST' ? 'eine POST-Anfrage an' : 'eine GET-Anfrage an';
|
||
if (!confirm(`Makro "${macro.name}" möchte ${verb} "${host}" senden. Erlauben?`)) {
|
||
throw new Error(`${method}: Verbindung zu '${host}' abgelehnt`);
|
||
}
|
||
(macro.approvedHosts ??= []).push(host);
|
||
markDirty();
|
||
}
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(url, {
|
||
method,
|
||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||
body,
|
||
});
|
||
} catch (e) {
|
||
throw new Error(`${method} ${url}: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
if (!res.ok) {
|
||
throw new Error(`${method} ${url}: HTTP ${res.status} ${res.statusText}`);
|
||
}
|
||
try {
|
||
return await res.json();
|
||
} catch {
|
||
throw new Error(`${method} ${url}: Antwort ist kein gültiges JSON`);
|
||
}
|
||
},
|
||
};
|
||
|
||
macroRunActive = true;
|
||
try {
|
||
const block = parseMacroBlock(macro.body);
|
||
const index = buildFunctionIndex(toFunctionDefs(doc));
|
||
const expanded = expandMacroBlock(block, index);
|
||
const result = await runMacroActions(expanded, macroHost, writeHost, netHost);
|
||
return { ok: true, message: typeof result === 'string' ? result : 'Makro ausgeführt.' };
|
||
} catch (e) {
|
||
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
||
} finally {
|
||
restoreView();
|
||
macroRunActive = false;
|
||
commitHistoryStep(); // exactly one step for everything mutated since the run started, any await duration
|
||
}
|
||
}
|
||
|
||
function moveObject(objId: string, x: number, y: number) {
|
||
const o = objectById(objId);
|
||
if (!o) return;
|
||
o.x = Math.max(0, Math.round(x));
|
||
o.y = Math.max(0, Math.round(y));
|
||
markDirty();
|
||
}
|
||
function resizeChart(objId: string, width: number, height: number) {
|
||
const o = objectById(objId);
|
||
if (!o || o.kind !== 'chart') return;
|
||
o.width = Math.max(200, Math.round(width));
|
||
o.height = Math.max(140, Math.round(height));
|
||
markDirty();
|
||
}
|
||
function bringToFront(objId: string) {
|
||
const sheetId = sheetIdOfObject(objId);
|
||
const sheet = sheetId ? sheetById(sheetId) : undefined;
|
||
if (!sheet) return;
|
||
const idx = sheet.objects.findIndex((o) => o.id === objId);
|
||
if (idx < 0 || idx === sheet.objects.length - 1) return;
|
||
const [o] = sheet.objects.splice(idx, 1);
|
||
sheet.objects.push(o);
|
||
markDirty();
|
||
}
|
||
function setChartView(objId: string, view: 'bar' | 'line') {
|
||
const o = objectById(objId);
|
||
if (o?.kind !== 'chart') return;
|
||
o.view = view;
|
||
markDirty();
|
||
}
|
||
function setChartSource(objId: string, tableId: string) {
|
||
const o = objectById(objId);
|
||
if (o?.kind !== 'chart') return;
|
||
o.sourceTableId = tableId;
|
||
markDirty();
|
||
}
|
||
function selectObject(objId: string | null) {
|
||
doc.activeObjectId = objId;
|
||
if (objId) {
|
||
const sheetId = sheetIdOfObject(objId);
|
||
if (sheetId) doc.activeSheetId = sheetId;
|
||
const o = objectById(objId);
|
||
if (o?.kind === 'table' && doc.selection?.tableId !== objId) {
|
||
doc.selection = { tableId: objId, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
|
||
}
|
||
}
|
||
scheduleSave(); // navigation only -- never an undo step
|
||
}
|
||
|
||
// ---- cell reads (reactive via recalcTick) -----------------
|
||
function cellInput(tableId: string, col: number, row: number): string {
|
||
return tableById(tableId)?.cells[`${col},${row}`] ?? '';
|
||
}
|
||
function cellValue(tableId: string, col: number, row: number): CellValue {
|
||
void recalcTick.value;
|
||
return wb.cellValue(tableId, col, row);
|
||
}
|
||
function cellDisplay(tableId: string, col: number, row: number): string {
|
||
return formatValue(
|
||
cellValue(tableId, col, row),
|
||
effectiveLocale(tableId),
|
||
tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto',
|
||
);
|
||
}
|
||
|
||
// ---- number-format locale (object -> sheet -> document cascade) ---
|
||
/** Effective locale for a canvas object (table or chart) -- text/button
|
||
* objects have no `locale` field of their own (nothing they show is
|
||
* locale-formatted), so they fall straight through to the sheet/document
|
||
* cascade. */
|
||
function effectiveLocale(objId: string | null): NumberLocale {
|
||
const o = objId ? objectById(objId) : undefined;
|
||
if (o && (o.kind === 'table' || o.kind === 'chart') && o.locale && isNumberLocale(o.locale)) {
|
||
return o.locale;
|
||
}
|
||
const sheetId = objId ? sheetIdOfObject(objId) : doc.activeSheetId;
|
||
const sheetLoc = sheetId ? sheetById(sheetId)?.locale : undefined;
|
||
if (sheetLoc && isNumberLocale(sheetLoc)) return sheetLoc;
|
||
return isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE;
|
||
}
|
||
const activeLocale = computed(() => effectiveLocale(activeTable.value?.id ?? null));
|
||
|
||
function setDocumentLocale(l: string) {
|
||
if (!isNumberLocale(l)) return;
|
||
doc.locale = l;
|
||
markDirty();
|
||
}
|
||
function setSheetLocale(sheetId: string, l: string | null) {
|
||
const s = sheetById(sheetId);
|
||
if (!s) return;
|
||
if (l && isNumberLocale(l)) s.locale = l;
|
||
else delete s.locale;
|
||
markDirty();
|
||
}
|
||
function setObjectLocale(objId: string, l: string | null) {
|
||
const o = objectById(objId);
|
||
if (!o || (o.kind !== 'table' && o.kind !== 'chart')) return;
|
||
if (l && isNumberLocale(l)) o.locale = l;
|
||
else delete o.locale;
|
||
markDirty();
|
||
}
|
||
const cellIsNumeric = (t: string, c: number, r: number) => isNumeric(cellValue(t, c, r));
|
||
const cellIsError = (t: string, c: number, r: number) => isError(cellValue(t, c, r));
|
||
const cellIsFormula = (t: string, c: number, r: number) => cellInput(t, c, r).startsWith('=');
|
||
|
||
// ---- dependency-graph view (precedents/dependents of a cell) ---------
|
||
const isCellCircular = (v: CellValue): boolean => isError(v) && v.code === '#CIRC';
|
||
function describeCellRef(ref: CellRef): CellGraphNode | null {
|
||
const table = tableById(ref.tableId);
|
||
if (!table) return null; // dangling (table deleted mid-navigation) -- drop it
|
||
const sheetId = sheetIdOfObject(ref.tableId);
|
||
const sheetName = (sheetId && sheetById(sheetId)?.name) || '';
|
||
const value = wb.cellValue(ref.tableId, ref.col, ref.row);
|
||
return {
|
||
tableId: ref.tableId,
|
||
tableName: table.name,
|
||
sheetName,
|
||
col: ref.col,
|
||
row: ref.row,
|
||
label: toA1(ref.col, ref.row),
|
||
raw: cellInput(ref.tableId, ref.col, ref.row),
|
||
value,
|
||
isCircular: isCellCircular(value),
|
||
};
|
||
}
|
||
/** Stable render order -- a `Set`'s iteration order is otherwise
|
||
* insertion-order-dependent and would make the graph view jump around
|
||
* on every re-render for no reason. */
|
||
const graphNodeSort = (a: CellGraphNode, b: CellGraphNode): number =>
|
||
a.tableName.localeCompare(b.tableName) || a.row - b.row || a.col - b.col;
|
||
|
||
/** The centered cell itself, described the same way as a precedent/
|
||
* dependent node -- null if its table no longer exists. */
|
||
function cellGraphSelf(tableId: string, col: number, row: number): CellGraphNode | null {
|
||
void recalcTick.value;
|
||
return describeCellRef({ tableId, col, row });
|
||
}
|
||
/** Direct precedents/dependents of a cell (see engine/graph.ts), enriched
|
||
* for display -- the data source for the interactive dependency-graph
|
||
* view (`DependencyGraphView.vue`). */
|
||
function cellGraphNeighbors(
|
||
tableId: string,
|
||
col: number,
|
||
row: number,
|
||
): { precedents: CellGraphNode[]; dependents: CellGraphNode[] } {
|
||
void recalcTick.value;
|
||
const toNodes = (refs: CellRef[]) =>
|
||
refs.map(describeCellRef).filter((n): n is CellGraphNode => n !== null).sort(graphNodeSort);
|
||
return {
|
||
precedents: toNodes(wb.graphPrecedents(tableId, col, row)),
|
||
dependents: toNodes(wb.graphDependents(tableId, col, row)),
|
||
};
|
||
}
|
||
/** Opens the dependency-graph drawer centered on this cell -- also *really*
|
||
* selects it (via the existing `selectCell`, which already switches the
|
||
* active sheet/object as a side effect), so the sheet tabs, canvas,
|
||
* formula bar and status bar all reflect where you are, not just the
|
||
* drawer itself. Reused as-is for navigating *inside* the already-open
|
||
* drawer (clicking a precedent/dependent node calls this again) -- a
|
||
* jump across sheets/tables inside the graph is then indistinguishable
|
||
* from clicking that cell directly. */
|
||
function openDependencyGraph(tableId: string, col: number, row: number) {
|
||
selectCell(tableId, col, row);
|
||
ui.depGraph = { tableId, col, row };
|
||
}
|
||
function closeDependencyGraph() {
|
||
ui.depGraph = null;
|
||
}
|
||
|
||
// ---- cell writes --------------------------------------
|
||
function setCellInput(tableId: string, col: number, row: number, raw: string) {
|
||
const table = tableById(tableId);
|
||
if (!table) return;
|
||
const key = `${col},${row}`;
|
||
const trimmed = raw.replace(/\s+$/, '');
|
||
// Cheap, O(1) no-op guard: re-committing a cell's already-current value
|
||
// (e.g. F2, Enter with nothing changed; a clear over a range that's
|
||
// already empty) must not mark history dirty or clear the redo stack --
|
||
// this is the one place that gets that check back after the old
|
||
// whole-document deep watch (which got it for free as a byproduct) was
|
||
// replaced by explicit, unconditional markDirty() calls (see that
|
||
// function's own doc comment) -- chosen here specifically because it's
|
||
// by far the highest-frequency mutator.
|
||
if (trimmed === (table.cells[key] ?? '')) return;
|
||
if (trimmed === '') delete table.cells[key];
|
||
else table.cells[key] = trimmed;
|
||
if (col >= table.cols) table.cols = col + 1;
|
||
if (row >= table.rows) table.rows = row + 1;
|
||
wb.setCell(tableId, col, row, trimmed);
|
||
// Typing a recognizable date/time literal auto-applies the matching
|
||
// display format, like Excel/Numbers -- but only while the cell has no
|
||
// explicit numFmt of its own yet, so this never overrides a deliberate
|
||
// choice (e.g. a cell the user already set to "Ohne Formatierung").
|
||
if (trimmed !== '' && !trimmed.startsWith('=')) {
|
||
const dt = parseDateTimeLiteral(trimmed);
|
||
if (dt && (cellFormat(tableId, col, row).numFmt ?? 'auto') === 'auto') {
|
||
setRangeFormat(tableId, { col, row }, { col, row }, { numFmt: dt.kind });
|
||
}
|
||
}
|
||
bump();
|
||
markDirty();
|
||
}
|
||
function clearRange(tableId: string, a: CellCoord, b: CellCoord) {
|
||
const c0 = Math.min(a.col, b.col);
|
||
const c1 = Math.max(a.col, b.col);
|
||
const r0 = Math.min(a.row, b.row);
|
||
const r1 = Math.max(a.row, b.row);
|
||
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) setCellInput(tableId, c, r, '');
|
||
}
|
||
|
||
// ---- table grid growth -----------------------------
|
||
const addRows = (tableId: string, n = 1) => {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
t.rows += n;
|
||
markDirty();
|
||
};
|
||
const addColumns = (tableId: string, n = 1) => {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
t.cols += n;
|
||
markDirty();
|
||
};
|
||
function removeLastRow(tableId: string) {
|
||
const t = tableById(tableId);
|
||
if (!t || t.rows <= 1) return;
|
||
const r = t.rows - 1;
|
||
for (let c = 0; c < t.cols; c++) delete t.cells[`${c},${r}`];
|
||
t.rows -= 1;
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
function removeLastColumn(tableId: string) {
|
||
const t = tableById(tableId);
|
||
if (!t || t.cols <= 1) return;
|
||
const c = t.cols - 1;
|
||
for (let r = 0; r < t.rows; r++) delete t.cells[`${c},${r}`];
|
||
delete t.colWidths[c];
|
||
t.cols -= 1;
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
const MIN_COL_W = 24;
|
||
const MIN_ROW_H = 14;
|
||
|
||
function setColWidth(tableId: string, col: number, width: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
t.colWidths[col] = Math.max(MIN_COL_W, Math.round(width));
|
||
markDirty();
|
||
}
|
||
/** Height of one row (per-row override, else the table default). */
|
||
function rowHeightOf(tableId: string, row: number): number {
|
||
const t = tableById(tableId);
|
||
return t?.rowHeights?.[row] ?? t?.rowHeight ?? DEFAULT_ROW_H;
|
||
}
|
||
function setRowHeight(tableId: string, row: number, height: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
if (!t.rowHeights) t.rowHeights = {};
|
||
t.rowHeights[row] = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
|
||
markDirty();
|
||
}
|
||
/** Default row height for the whole table. */
|
||
function setTableRowHeight(tableId: string, height: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
t.rowHeight = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
|
||
markDirty();
|
||
}
|
||
|
||
function pruneEmptyMaps(t: TableObject) {
|
||
if (t.formats && !Object.keys(t.formats).length) delete t.formats;
|
||
if (t.rowHeights && !Object.keys(t.rowHeights).length) delete t.rowHeights;
|
||
}
|
||
|
||
/** Grow or shrink a table to an exact row / column count. */
|
||
function setTableRows(tableId: string, target: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
const n = Math.max(1, Math.floor(target));
|
||
if (n === t.rows) return;
|
||
if (n < t.rows) {
|
||
for (const k of Object.keys(t.cells)) if (Number(k.split(',')[1]) >= n) delete t.cells[k];
|
||
if (t.formats) for (const k of Object.keys(t.formats)) if (Number(k.split(',')[1]) >= n) delete t.formats[k];
|
||
if (t.rowHeights) for (const k of Object.keys(t.rowHeights)) if (Number(k) >= n) delete t.rowHeights[Number(k)];
|
||
}
|
||
t.rows = n;
|
||
pruneEmptyMaps(t);
|
||
clampTableView(t);
|
||
clampSelection(tableId);
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
function setTableCols(tableId: string, target: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
const n = Math.max(1, Math.floor(target));
|
||
if (n === t.cols) return;
|
||
if (n < t.cols) {
|
||
for (const k of Object.keys(t.cells)) if (Number(k.split(',')[0]) >= n) delete t.cells[k];
|
||
if (t.formats) for (const k of Object.keys(t.formats)) if (Number(k.split(',')[0]) >= n) delete t.formats[k];
|
||
for (const k of Object.keys(t.colWidths)) if (Number(k) >= n) delete t.colWidths[Number(k)];
|
||
}
|
||
t.cols = n;
|
||
pruneEmptyMaps(t);
|
||
clampTableView(t);
|
||
clampSelection(tableId);
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
|
||
// ---- row/col insert & delete (mid-grid, not just edge growth) ---------
|
||
// Unlike setTableRows/setTableCols above (pure edge resize) or
|
||
// removeLastRow/removeLastColumn, these shift cell data/formats/row-height
|
||
// (or column-width) overrides *within* the table around the insertion/
|
||
// deletion point, and rewrite every formula in the *whole document* that
|
||
// references a row/col at/after it on this table -- see
|
||
// `rewriteRefsForStructuralChange` above for the "how".
|
||
|
||
/** Move `doc.selection` (if it's on `tableId`) the same way a row/col
|
||
* insert/delete moves cell data -- so the active cell/marquee stays on
|
||
* the "same" logical cell it was on before, not wherever that grid
|
||
* position now happens to be. `clampSelection` (called by every caller
|
||
* right after) catches anything this pushes out of the shrunk bounds. */
|
||
function shiftSelectionForStructuralChange(tableId: string, axis: Axis, at: number, delta: 1 | -1) {
|
||
const s = doc.selection;
|
||
if (!s || s.tableId !== tableId) return;
|
||
const shift = (c: CellCoord): CellCoord => {
|
||
const v = axis === 'row' ? c.row : c.col;
|
||
const nv = delta > 0 ? insertShift(v, at) : deleteShiftStart(v, at);
|
||
return axis === 'row' ? { ...c, row: nv } : { ...c, col: nv };
|
||
};
|
||
doc.selection = {
|
||
tableId,
|
||
anchor: shift(s.anchor),
|
||
focus: shift(s.focus),
|
||
ranges: s.ranges?.map((r) => ({ anchor: shift(r.anchor), focus: shift(r.focus) })),
|
||
};
|
||
}
|
||
|
||
/** Rewrite every formula in the whole document referencing `targetTableId`
|
||
* after its rows/cols and cell data have already been shifted to their
|
||
* new shape (self-references inside `targetTableId` are included -- it's
|
||
* just another table as far as this loop is concerned). */
|
||
function rewriteDocumentRefs(targetTableId: string, axis: Axis, at: number, delta: 1 | -1) {
|
||
for (const s of doc.sheets) {
|
||
for (const o of s.objects) {
|
||
if (o.kind !== 'table') continue;
|
||
for (const [key, raw] of Object.entries(o.cells)) {
|
||
const next = rewriteRefsForStructuralChange(raw, o.id, targetTableId, axis, at, delta, wb);
|
||
if (next !== raw) o.cells[key] = next;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Same driver shape as `rewriteDocumentRefs` above, for a table rename
|
||
* (see `rewriteRefsForRename`'s own doc comment) -- every OTHER table's
|
||
* cells are walked too, not just the renamed table's own, since a
|
||
* qualified reference to it can live anywhere in the document. Scoped
|
||
* to table cells only, same as the structural-change rewriter (macro
|
||
* bodies address a table by a plain string, not a formula qualifier, and
|
||
* are resolved fresh at each run anyway -- see `resolveMacroTableRef` --
|
||
* so they need no rewriting; a custom function's own body is likewise
|
||
* left alone, matching the existing, established scope of the sibling
|
||
* row/col-insert/delete rewrite this mirrors). Called BEFORE the
|
||
* object's `.name` is actually changed, so `wb` (not yet rebuilt) still
|
||
* resolves the OLD name to `targetTableId`. */
|
||
function renameDocumentRefs(targetTableId: string, newName: string) {
|
||
for (const s of doc.sheets) {
|
||
for (const o of s.objects) {
|
||
if (o.kind !== 'table') continue;
|
||
for (const [key, raw] of Object.entries(o.cells)) {
|
||
const next = rewriteRefsForRename(raw, o.id, targetTableId, newName, wb);
|
||
if (next !== raw) o.cells[key] = next;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Re-key every entry of a "col,row" map, shifting the given axis at/after
|
||
* `at` by `delta` (dropping entries whose axis value === `at` when
|
||
* `delta` is -1 -- that row/col's own cells are gone). Shared by
|
||
* insertRow/deleteRow (axis 'row') and insertColumn/deleteColumn (axis
|
||
* 'col'). */
|
||
function shiftCellKeyedMap<T>(
|
||
map: Record<string, T>,
|
||
axis: Axis,
|
||
at: number,
|
||
delta: 1 | -1,
|
||
): Record<string, T> {
|
||
const out: Record<string, T> = {};
|
||
for (const [k, v] of Object.entries(map)) {
|
||
const [c, r] = k.split(',').map(Number);
|
||
const cur = axis === 'row' ? r : c;
|
||
if (delta < 0 && cur === at) continue; // this row/col's own cell is gone
|
||
const nv = delta > 0 ? insertShift(cur, at) : deleteShiftStart(cur, at);
|
||
const key = axis === 'row' ? `${c},${nv}` : `${nv},${r}`;
|
||
out[key] = v;
|
||
}
|
||
return out;
|
||
}
|
||
/** Same idea as shiftCellKeyedMap, for a single-axis map keyed by plain
|
||
* row/col number (rowHeights, colWidths). */
|
||
function shiftAxisKeyedMap(map: Record<number, number>, at: number, delta: 1 | -1): Record<number, number> {
|
||
const out: Record<number, number> = {};
|
||
for (const [k, v] of Object.entries(map)) {
|
||
const cur = Number(k);
|
||
if (delta < 0 && cur === at) continue;
|
||
out[delta > 0 ? insertShift(cur, at) : deleteShiftStart(cur, at)] = v;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function insertRow(tableId: string, at: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
const clamped = Math.max(0, Math.min(Math.floor(at), t.rows));
|
||
t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, 1);
|
||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, 1);
|
||
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, 1);
|
||
t.rows += 1;
|
||
shiftSelectionForStructuralChange(tableId, 'row', clamped, 1);
|
||
rewriteDocumentRefs(tableId, 'row', clamped, 1);
|
||
clampSelection(tableId);
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
function deleteRow(tableId: string, at: number) {
|
||
const t = tableById(tableId);
|
||
if (!t || t.rows <= 1) return;
|
||
const clamped = Math.max(0, Math.min(Math.floor(at), t.rows - 1));
|
||
t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, -1);
|
||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, -1);
|
||
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, -1);
|
||
t.rows -= 1;
|
||
shiftSelectionForStructuralChange(tableId, 'row', clamped, -1);
|
||
rewriteDocumentRefs(tableId, 'row', clamped, -1);
|
||
pruneEmptyMaps(t);
|
||
clampTableView(t);
|
||
clampSelection(tableId);
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
function insertColumn(tableId: string, at: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
const clamped = Math.max(0, Math.min(Math.floor(at), t.cols));
|
||
t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, 1);
|
||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, 1);
|
||
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, 1);
|
||
t.cols += 1;
|
||
shiftSelectionForStructuralChange(tableId, 'col', clamped, 1);
|
||
rewriteDocumentRefs(tableId, 'col', clamped, 1);
|
||
clampSelection(tableId);
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
function deleteColumn(tableId: string, at: number) {
|
||
const t = tableById(tableId);
|
||
if (!t || t.cols <= 1) return;
|
||
const clamped = Math.max(0, Math.min(Math.floor(at), t.cols - 1));
|
||
t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, -1);
|
||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, -1);
|
||
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, -1);
|
||
t.cols -= 1;
|
||
shiftSelectionForStructuralChange(tableId, 'col', clamped, -1);
|
||
rewriteDocumentRefs(tableId, 'col', clamped, -1);
|
||
pruneEmptyMaps(t);
|
||
clampTableView(t);
|
||
clampSelection(tableId);
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
|
||
// ---- cell formatting (alignment, number format, colours, bold/italic) ---
|
||
const fmtKey = (c: number, r: number) => `${c},${r}`;
|
||
function cellFormat(tableId: string, col: number, row: number): CellFormat {
|
||
return tableById(tableId)?.formats?.[fmtKey(col, row)] ?? {};
|
||
}
|
||
/** Merge `patch` into every cell of a range; a `null`/`undefined` field clears it. */
|
||
function setRangeFormat(
|
||
tableId: string,
|
||
a: CellCoord,
|
||
b: CellCoord,
|
||
patch: Partial<Record<keyof CellFormat, unknown>>,
|
||
) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
if (!t.formats) t.formats = {};
|
||
const c0 = Math.min(a.col, b.col);
|
||
const c1 = Math.max(a.col, b.col);
|
||
const r0 = Math.min(a.row, b.row);
|
||
const r1 = Math.max(a.row, b.row);
|
||
for (let r = r0; r <= r1; r++) {
|
||
for (let c = c0; c <= c1; c++) {
|
||
const k = fmtKey(c, r);
|
||
const cur: CellFormat = { ...(t.formats[k] ?? {}) };
|
||
for (const [field, val] of Object.entries(patch)) {
|
||
if (val === null || val === undefined || val === '' || val === false || val === 'auto') {
|
||
delete (cur as Record<string, unknown>)[field];
|
||
} else {
|
||
(cur as Record<string, unknown>)[field] = val;
|
||
}
|
||
}
|
||
if (Object.keys(cur).length) t.formats[k] = cur;
|
||
else delete t.formats[k];
|
||
}
|
||
}
|
||
if (!Object.keys(t.formats).length) delete t.formats;
|
||
bump();
|
||
markDirty();
|
||
}
|
||
function clearRangeFormat(tableId: string, a: CellCoord, b: CellCoord) {
|
||
const t = tableById(tableId);
|
||
if (!t?.formats) return;
|
||
const c0 = Math.min(a.col, b.col);
|
||
const c1 = Math.max(a.col, b.col);
|
||
const r0 = Math.min(a.row, b.row);
|
||
const r1 = Math.max(a.row, b.row);
|
||
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) delete t.formats[fmtKey(c, r)];
|
||
if (!Object.keys(t.formats).length) delete t.formats;
|
||
bump();
|
||
markDirty();
|
||
}
|
||
/** Format shared by every cell of the current (possibly multi-) selection. */
|
||
const selectionFormat = computed<CellFormat>(() => {
|
||
void recalcTick.value;
|
||
const s = doc.selection;
|
||
const t = s ? tableById(s.tableId) : undefined;
|
||
if (!s || !t) return {};
|
||
let acc: CellFormat | null = null;
|
||
for (const key of selectedCellKeys()) {
|
||
const f = t.formats?.[key] ?? {};
|
||
if (acc === null) acc = { ...f };
|
||
else {
|
||
for (const k of Object.keys(acc) as (keyof CellFormat)[]) {
|
||
if (acc[k] !== f[k]) delete acc[k];
|
||
}
|
||
}
|
||
}
|
||
return acc ?? {};
|
||
});
|
||
|
||
/** Apply / clear formatting across *all* rectangles of the current selection. */
|
||
function applySelectionFormat(patch: Partial<Record<keyof CellFormat, unknown>>) {
|
||
const s = doc.selection;
|
||
if (!s) return;
|
||
for (const k of selectionRects(s)) {
|
||
setRangeFormat(s.tableId, { col: k.c0, row: k.r0 }, { col: k.c1, row: k.r1 }, patch);
|
||
}
|
||
}
|
||
function clearSelectionFormat() {
|
||
const s = doc.selection;
|
||
if (!s) return;
|
||
for (const k of selectionRects(s)) {
|
||
clearRangeFormat(s.tableId, { col: k.c0, row: k.r0 }, { col: k.c1, row: k.r1 });
|
||
}
|
||
}
|
||
/** Delete/Backspace over a multi-selection. */
|
||
function clearSelectionCells() {
|
||
const s = doc.selection;
|
||
if (!s) return;
|
||
for (const key of selectedCellKeys()) {
|
||
const [c, r] = key.split(',').map(Number);
|
||
setCellInput(s.tableId, c, r, '');
|
||
}
|
||
}
|
||
|
||
function clampTableView(t: TableObject) {
|
||
t.view.frozenRows = Math.min(Math.max(0, t.view.frozenRows), Math.max(0, t.rows - 1));
|
||
t.view.frozenCols = Math.min(Math.max(0, t.view.frozenCols), Math.max(0, t.cols - 1));
|
||
}
|
||
function clampSelection(tableId: string) {
|
||
const s = doc.selection;
|
||
const t = tableById(tableId);
|
||
if (!s || !t || s.tableId !== tableId) return;
|
||
const cl = (c: CellCoord) => ({
|
||
col: Math.min(Math.max(0, c.col), t.cols - 1),
|
||
row: Math.min(Math.max(0, c.row), t.rows - 1),
|
||
});
|
||
doc.selection = {
|
||
tableId,
|
||
anchor: cl(s.anchor),
|
||
focus: cl(s.focus),
|
||
ranges: s.ranges?.map((r) => ({ anchor: cl(r.anchor), focus: cl(r.focus) })),
|
||
};
|
||
}
|
||
|
||
/** Patch a table object's view (sizing mode, viewport size, frozen rows/cols). */
|
||
function setTableView(tableId: string, patch: Partial<TableView>) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
t.view = { ...t.view, ...patch };
|
||
if (patch.width !== undefined) t.view.width = Math.max(200, Math.round(t.view.width));
|
||
if (patch.height !== undefined) t.view.height = Math.max(120, Math.round(t.view.height));
|
||
clampTableView(t);
|
||
markDirty();
|
||
}
|
||
|
||
// ---- selection --------------------------------------
|
||
function rect(a: CellCoord, b: CellCoord): Rect {
|
||
return {
|
||
c0: Math.min(a.col, b.col),
|
||
c1: Math.max(a.col, b.col),
|
||
r0: Math.min(a.row, b.row),
|
||
r1: Math.max(a.row, b.row),
|
||
};
|
||
}
|
||
/** Every rectangle of `sel` (primary first), normalised. */
|
||
function selectionRects(sel: SelectionState): Rect[] {
|
||
const out = [rect(sel.anchor, sel.focus)];
|
||
for (const r of sel.ranges ?? []) out.push(rect(r.anchor, r.focus));
|
||
return out;
|
||
}
|
||
/** Deduplicated set of "c,r" keys covered by the current selection. */
|
||
function selectedCellKeys(): Set<string> {
|
||
const s = doc.selection;
|
||
const keys = new Set<string>();
|
||
if (!s) return keys;
|
||
for (const k of selectionRects(s)) {
|
||
for (let r = k.r0; r <= k.r1; r++) for (let c = k.c0; c <= k.c1; c++) keys.add(`${c},${r}`);
|
||
}
|
||
return keys;
|
||
}
|
||
const isOneByOne = (a: CellCoord, b: CellCoord, c: number, r: number) =>
|
||
a.col === c && a.row === r && b.col === c && b.row === r;
|
||
|
||
function setSelection(sel: SelectionState | null) {
|
||
doc.selection = sel;
|
||
if (sel) {
|
||
doc.activeObjectId = sel.tableId;
|
||
const sheetId = sheetIdOfObject(sel.tableId);
|
||
if (sheetId) doc.activeSheetId = sheetId;
|
||
}
|
||
scheduleSave(); // selection only -- never an undo step
|
||
}
|
||
/** Replace the whole selection with one rectangle (or grow the primary one). */
|
||
function selectCell(tableId: string, col: number, row: number, extend = false) {
|
||
const cur = doc.selection;
|
||
const anchor = extend && cur && cur.tableId === tableId ? cur.anchor : { col, row };
|
||
setSelection({ tableId, anchor, focus: { col, row } });
|
||
}
|
||
|
||
/** Ctrl/Cmd-click: add the cell as a new rectangle, or toggle it off. */
|
||
function addToSelection(tableId: string, col: number, row: number) {
|
||
const cur = doc.selection;
|
||
if (!cur || cur.tableId !== tableId) {
|
||
selectCell(tableId, col, row);
|
||
return;
|
||
}
|
||
const ranges = [...(cur.ranges ?? [])];
|
||
const idx = ranges.findIndex((r) => isOneByOne(r.anchor, r.focus, col, row));
|
||
if (idx >= 0) {
|
||
// toggle an extra 1x1 range off
|
||
ranges.splice(idx, 1);
|
||
doc.selection = { ...cur, ranges: ranges.length ? ranges : undefined };
|
||
scheduleSave();
|
||
return;
|
||
}
|
||
if (isOneByOne(cur.anchor, cur.focus, col, row) && ranges.length) {
|
||
// toggle the primary off -> promote the most recent extra range
|
||
const last = ranges.pop()!;
|
||
doc.selection = {
|
||
tableId,
|
||
anchor: last.anchor,
|
||
focus: last.focus,
|
||
ranges: ranges.length ? ranges : undefined,
|
||
};
|
||
scheduleSave();
|
||
return;
|
||
}
|
||
// keep the current primary as an extra range, make the clicked cell primary
|
||
ranges.push({ anchor: cur.anchor, focus: cur.focus });
|
||
setSelection({ tableId, anchor: { col, row }, focus: { col, row }, ranges });
|
||
}
|
||
/** Ctrl/Cmd-drag: grow the just-added (primary) rectangle. */
|
||
function extendActiveRange(tableId: string, col: number, row: number) {
|
||
const cur = doc.selection;
|
||
if (!cur || cur.tableId !== tableId) return;
|
||
doc.selection = { ...cur, focus: { col, row } };
|
||
scheduleSave();
|
||
}
|
||
/** Ctrl/Cmd-click on a column/row header: add that whole strip. */
|
||
function addRangeToSelection(tableId: string, a: CellCoord, b: CellCoord) {
|
||
const cur = doc.selection;
|
||
if (!cur || cur.tableId !== tableId) {
|
||
setSelection({ tableId, anchor: a, focus: b });
|
||
return;
|
||
}
|
||
const ranges = [...(cur.ranges ?? []), { anchor: cur.anchor, focus: cur.focus }];
|
||
setSelection({ tableId, anchor: a, focus: b, ranges });
|
||
}
|
||
|
||
// ---- clipboard (copy / cut / paste) --------------------------
|
||
/** True while a copy/cut buffer is held (enables "Einfügen" in menus). */
|
||
const hasClipboard = computed(() => clipboard.value !== null);
|
||
/** Source rectangle of the current buffer -- drives the dashed source marker
|
||
* and its anchor-cell indicator (which stays put once the live selection
|
||
* has moved on to pick a paste destination). */
|
||
const clipboardRect = computed(() => {
|
||
const cb = clipboard.value;
|
||
return cb ? { tableId: cb.tableId, ...cb.rect, cut: cb.cut, anchor: cb.anchor } : null;
|
||
});
|
||
|
||
function copyOrCut(cut: boolean) {
|
||
const s = doc.selection;
|
||
const t = s ? tableById(s.tableId) : undefined;
|
||
if (!s || !t) return;
|
||
const r = rect(s.anchor, s.focus);
|
||
const cells = new Map<string, ClipboardCell>();
|
||
for (let row = r.r0; row <= r.r1; row++) {
|
||
for (let col = r.c0; col <= r.c1; col++) {
|
||
const raw = cellInput(s.tableId, col, row);
|
||
const format = t.formats?.[fmtKey(col, row)];
|
||
if (raw || format) {
|
||
cells.set(`${col - r.c0},${row - r.r0}`, { raw, format: format ? { ...format } : undefined });
|
||
}
|
||
}
|
||
}
|
||
clipboard.value = {
|
||
tableId: s.tableId,
|
||
rect: r,
|
||
width: r.c1 - r.c0 + 1,
|
||
height: r.r1 - r.r0 + 1,
|
||
cells,
|
||
cut,
|
||
anchor: { ...s.anchor },
|
||
};
|
||
}
|
||
const copySelection = () => copyOrCut(false);
|
||
const cutSelection = () => copyOrCut(true);
|
||
/** Drop the buffer (Escape) without pasting. */
|
||
function cancelClipboard() {
|
||
clipboard.value = null;
|
||
}
|
||
|
||
/**
|
||
* Paste the buffer at the current selection. A selection that is an exact,
|
||
* larger multiple of the buffer tiles it (Numbers/Excel "fill by paste");
|
||
* a smaller or single-cell selection anchors the buffer there and overflows
|
||
* past it, same as pasting a whole block onto one cell.
|
||
*/
|
||
function pasteClipboard() {
|
||
const cb = clipboard.value;
|
||
const s = doc.selection;
|
||
if (!cb || !s || !tableById(s.tableId)) return;
|
||
|
||
const dest = rect(s.anchor, s.focus);
|
||
const destW = dest.c1 - dest.c0 + 1;
|
||
const destH = dest.r1 - dest.r0 + 1;
|
||
const tileX = destW > cb.width && destW % cb.width === 0 ? destW / cb.width : 1;
|
||
const tileY = destH > cb.height && destH % cb.height === 0 ? destH / cb.height : 1;
|
||
|
||
for (let ty = 0; ty < tileY; ty++) {
|
||
for (let tx = 0; tx < tileX; tx++) {
|
||
const baseC = dest.c0 + tx * cb.width;
|
||
const baseR = dest.r0 + ty * cb.height;
|
||
const dCol = baseC - cb.rect.c0;
|
||
const dRow = baseR - cb.rect.r0;
|
||
for (let dr = 0; dr < cb.height; dr++) {
|
||
for (let dc = 0; dc < cb.width; dc++) {
|
||
const c = baseC + dc;
|
||
const r = baseR + dr;
|
||
const cell = cb.cells.get(`${dc},${dr}`);
|
||
const raw = cell?.raw ?? '';
|
||
setCellInput(
|
||
s.tableId,
|
||
c,
|
||
r,
|
||
raw ? shiftFormula(raw, dCol, dRow, cb.cut ? cb.rect : undefined) : raw,
|
||
);
|
||
clearRangeFormat(s.tableId, { col: c, row: r }, { col: c, row: r });
|
||
if (cell?.format) {
|
||
setRangeFormat(
|
||
s.tableId,
|
||
{ col: c, row: r },
|
||
{ col: c, row: r },
|
||
cell.format as Partial<Record<keyof CellFormat, unknown>>,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (cb.cut) {
|
||
// clear the moved-from cells, except any the paste itself just wrote into
|
||
const destC1 = dest.c0 + tileX * cb.width - 1;
|
||
const destR1 = dest.r0 + tileY * cb.height - 1;
|
||
for (let row = cb.rect.r0; row <= cb.rect.r1; row++) {
|
||
for (let col = cb.rect.c0; col <= cb.rect.c1; col++) {
|
||
const overlap =
|
||
cb.tableId === s.tableId &&
|
||
col >= dest.c0 && col <= destC1 && row >= dest.r0 && row <= destR1;
|
||
if (!overlap) {
|
||
setCellInput(cb.tableId, col, row, '');
|
||
clearRangeFormat(cb.tableId, { col, row }, { col, row });
|
||
}
|
||
}
|
||
}
|
||
clipboard.value = null; // a cut buffer is consumed by its first paste
|
||
}
|
||
|
||
setSelection({
|
||
tableId: s.tableId,
|
||
anchor: { col: dest.c0, row: dest.r0 },
|
||
focus: { col: dest.c0 + tileX * cb.width - 1, row: dest.r0 + tileY * cb.height - 1 },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The Excel/Numbers "fill handle": repeat `source`'s cells (values,
|
||
* formulas -- reference-adjusted the same way a copy is -- and formatting)
|
||
* across `target`, a rectangle extending purely up, down, left or right
|
||
* from `source` (never both axes at once -- the caller picks the single
|
||
* dominant drag direction). Repeats cyclically, so a `target` that isn't an
|
||
* exact multiple of `source`'s size still fills its last partial cycle.
|
||
*/
|
||
function fillSelection(tableId: string, source: Rect, target: Rect) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
const srcW = source.c1 - source.c0 + 1;
|
||
const srcH = source.r1 - source.r0 + 1;
|
||
const cells = new Map<string, ClipboardCell>();
|
||
for (let row = source.r0; row <= source.r1; row++) {
|
||
for (let col = source.c0; col <= source.c1; col++) {
|
||
const raw = cellInput(tableId, col, row);
|
||
const format = t.formats?.[fmtKey(col, row)];
|
||
if (raw || format) {
|
||
cells.set(`${col - source.c0},${row - source.r0}`, { raw, format: format ? { ...format } : undefined });
|
||
}
|
||
}
|
||
}
|
||
for (let row = target.r0; row <= target.r1; row++) {
|
||
for (let col = target.c0; col <= target.c1; col++) {
|
||
// which cell of the source block this target cell cycles back to
|
||
const dc = (((col - source.c0) % srcW) + srcW) % srcW;
|
||
const dr = (((row - source.r0) % srcH) + srcH) % srcH;
|
||
const cell = cells.get(`${dc},${dr}`);
|
||
const raw = cell?.raw ?? '';
|
||
const deltaC = col - (source.c0 + dc);
|
||
const deltaR = row - (source.r0 + dr);
|
||
setCellInput(tableId, col, row, raw ? shiftFormula(raw, deltaC, deltaR) : raw);
|
||
clearRangeFormat(tableId, { col, row }, { col, row });
|
||
if (cell?.format) {
|
||
setRangeFormat(tableId, { col, row }, { col, row }, cell.format as Partial<Record<keyof CellFormat, unknown>>);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- formula "point mode" (click/drag a cell to insert its reference) ---
|
||
/** Register (or, with `null`, unregister) the editor a grid click should
|
||
* insert references into. Also drops any in-flight "replace on next
|
||
* click" state from whichever editor was registered before. */
|
||
function registerFormulaEditor(handle: FormulaEditorHandle | null) {
|
||
formulaEditor.value = handle;
|
||
pointSpan = null;
|
||
}
|
||
/** The user typed (as opposed to a click inserting a reference for them):
|
||
* the next click should insert a fresh reference, not replace the last
|
||
* one -- typing anything ends "point mode"'s replace-on-click window,
|
||
* same as Excel (not just `;`, though that's the most common case, since
|
||
* it's the argument separator you'd type right after picking one cell
|
||
* and before picking the next). */
|
||
function noteFormulaTyped() {
|
||
pointSpan = null;
|
||
}
|
||
/** True while the registered editor's buffer is a formula -- point mode
|
||
* (click-to-insert-reference) only applies then. */
|
||
function formulaPointModeActive(): boolean {
|
||
return !!formulaEditor.value && formulaEditor.value.getState().text.startsWith('=');
|
||
}
|
||
/** Function-name autocomplete candidates for a prefix already typed (built-in
|
||
* + this document's custom functions), case-insensitive, name-sorted. Used
|
||
* by src/composables/useFormulaAutocomplete.ts, driven by
|
||
* currentIdentifierAtCaret() finding the word being typed. Empty prefix ->
|
||
* no suggestions (matches Excel/Sheets: autocomplete only kicks in once
|
||
* something's actually been typed, not on a bare "="). */
|
||
function functionSuggestions(prefix: string): FnSuggestion[] {
|
||
const p = prefix.toUpperCase();
|
||
if (!p) return [];
|
||
const builtins: FnSuggestion[] = BUILTIN_SIGNATURES.filter((s) => s.name.startsWith(p)).map(
|
||
(s) => ({ name: s.name, hint: s.hint, kind: 'builtin' }),
|
||
);
|
||
const custom: FnSuggestion[] = doc.customFunctions
|
||
.filter((f) => f.name.toUpperCase().startsWith(p))
|
||
.map((f) => ({ name: f.name, hint: `${f.name}(${f.params.join('; ')})`, kind: 'custom' }));
|
||
return [...builtins, ...custom].sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
/** Name Box autocomplete candidates for a prefix already typed -- named
|
||
* ranges, table names (any sheet), then sheet names, each group
|
||
* case-insensitively prefix-matched and name-sorted, in that priority
|
||
* order (matching `jumpToNameInput`'s own resolution order below). Empty
|
||
* prefix -> no suggestions, same convention as `functionSuggestions`. */
|
||
function nameBoxSuggestions(prefix: string): NameSuggestion[] {
|
||
const p = prefix.trim().toLowerCase();
|
||
if (!p) return [];
|
||
const namedRanges: NameSuggestion[] = doc.namedRanges
|
||
.filter((r) => r.name.toLowerCase().startsWith(p))
|
||
.map((r) => {
|
||
const t = tableById(r.tableId);
|
||
const addr =
|
||
r.start.col === r.end.col && r.start.row === r.end.row
|
||
? toA1(r.start.col, r.start.row)
|
||
: `${toA1(r.start.col, r.start.row)}:${toA1(r.end.col, r.end.row)}`;
|
||
return { name: r.name, hint: `${t?.name ?? '?'}!${addr}`, kind: 'namedrange' as const };
|
||
})
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
const tables: NameSuggestion[] = allTables(doc)
|
||
.filter((t) => t.name.toLowerCase().startsWith(p))
|
||
.map((t) => {
|
||
const sheetId = sheetIdOfObject(t.id);
|
||
const sheetName = (sheetId && sheetById(sheetId)?.name) || '?';
|
||
return { name: t.name, hint: `Tabelle · Blatt „${sheetName}"`, kind: 'table' as const };
|
||
})
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
const sheets: NameSuggestion[] = doc.sheets
|
||
.filter((s) => s.name.toLowerCase().startsWith(p))
|
||
.map((s) => ({ name: s.name, hint: 'Blatt', kind: 'sheet' as const }))
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
return [...namedRanges, ...tables, ...sheets];
|
||
}
|
||
|
||
/**
|
||
* Resolves free-form Name Box input and navigates to it -- same syntax a
|
||
* formula reference would use (`Tabelle::A1`, `Blatt!Tabelle::A1:B5`, a
|
||
* bare `A1`/`A1:B5` meaning "on the currently active table", or a named
|
||
* range's own name), falling back to a bare **table** name (selects the
|
||
* whole table) or a bare **sheet** name (switches to it) -- neither of
|
||
* those two is valid formula syntax on its own, so they're only tried
|
||
* once the reference parse produced nothing usable. Returns whether it
|
||
* found something to jump to; the caller (the Name Box itself) reports a
|
||
* failure to the user.
|
||
*/
|
||
function jumpToNameInput(text: string): boolean {
|
||
const trimmed = text.trim();
|
||
if (!trimmed) return false;
|
||
const home = doc.selection?.tableId ?? activeTable.value?.id ?? null;
|
||
|
||
let node: ReturnType<typeof parseFormula> | null = null;
|
||
try {
|
||
node = parseFormula(trimmed);
|
||
} catch {
|
||
node = null;
|
||
}
|
||
if (node) {
|
||
let qualifier: string | null = null;
|
||
let sheetQualifier: string | undefined;
|
||
let start: { col: number; row: number } | null = null;
|
||
let end: { col: number; row: number } | null = null;
|
||
if (node.kind === 'cell') {
|
||
qualifier = node.qualifier;
|
||
sheetQualifier = node.sheetQualifier;
|
||
start = end = { col: node.col, row: node.row };
|
||
} else if (node.kind === 'range') {
|
||
qualifier = node.qualifier;
|
||
sheetQualifier = node.sheetQualifier;
|
||
start = node.start;
|
||
end = node.end;
|
||
} else if (node.kind === 'namedref') {
|
||
const r = wb.resolveNamedRange(node.name);
|
||
if (r) {
|
||
qualifier = r.qualifier;
|
||
sheetQualifier = r.sheetQualifier;
|
||
start = r.start;
|
||
end = r.end;
|
||
}
|
||
}
|
||
if (start && end) {
|
||
const tableId = qualifier === null ? home : wb.resolveTableName(qualifier, sheetQualifier ?? null);
|
||
const t = tableId ? tableById(tableId) : undefined;
|
||
if (t) {
|
||
const clampC = (c: number) => Math.max(0, Math.min(t.cols - 1, c));
|
||
const clampR = (r: number) => Math.max(0, Math.min(t.rows - 1, r));
|
||
setSelection({
|
||
tableId: t.id,
|
||
anchor: { col: clampC(start.col), row: clampR(start.row) },
|
||
focus: { col: clampC(end.col), row: clampR(end.row) },
|
||
});
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Not a (resolvable) reference -- try a bare table or sheet name.
|
||
const tableId = wb.resolveTableName(trimmed, null);
|
||
const t = tableId ? tableById(tableId) : undefined;
|
||
if (t) {
|
||
setSelection({ tableId: t.id, anchor: { col: 0, row: 0 }, focus: { col: t.cols - 1, row: t.rows - 1 } });
|
||
return true;
|
||
}
|
||
const sheet = sheetByName(trimmed);
|
||
if (sheet) {
|
||
setActiveSheet(sheet.id);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function qualifyRef(homeTableId: string, targetTableId: string, body: string): string {
|
||
if (targetTableId === homeTableId) return body;
|
||
const target = tableById(targetTableId);
|
||
const tableQ = quoteQualifier(target?.name ?? targetTableId);
|
||
// a plain "Tabelle!..." is enough as long as it resolves unambiguously --
|
||
// either the name is document-unique, or the target happens to be on the
|
||
// same sheet as the formula being edited (resolveTableName()'s "prefer
|
||
// the home sheet" fallback picks it correctly either way); only a name
|
||
// that collides *and* lives on a different sheet needs the explicit
|
||
// "Blatt!Tabelle::..." 3-part form to disambiguate.
|
||
const homeSheetId = sheetIdOfObject(homeTableId);
|
||
const targetSheetId = sheetIdOfObject(targetTableId);
|
||
const nameCollides = target
|
||
? allTables(doc).filter((t) => t.name.toLowerCase() === target.name.toLowerCase()).length > 1
|
||
: false;
|
||
if (!nameCollides || targetSheetId === homeSheetId) {
|
||
return `${tableQ}!${body}`;
|
||
}
|
||
const sheetName = (targetSheetId && sheetById(targetSheetId)?.name) || '';
|
||
return `${quoteQualifier(sheetName)}!${tableQ}::${body}`;
|
||
}
|
||
function applyPointRef(targetTableId: string, body: string): boolean {
|
||
const ed = formulaEditor.value;
|
||
if (!ed) return false;
|
||
const st = ed.getState();
|
||
if (!st.text.startsWith('=')) return false;
|
||
const text = qualifyRef(ed.tableId, targetTableId, body);
|
||
const span = pointSpan ?? { start: st.start, end: st.end };
|
||
ed.replace(span.start, span.end, text);
|
||
pointSpan = { start: span.start, end: span.start + text.length };
|
||
return true;
|
||
}
|
||
/** A plain click on (tableId, col, row) while editing a formula: insert or
|
||
* (immediately after another click/drag, before any typing) replace a
|
||
* reference at the caret. Returns false when there's no formula editor
|
||
* active, so the caller can fall back to its normal click handling. */
|
||
function pointClick(tableId: string, col: number, row: number): boolean {
|
||
return applyPointRef(tableId, toA1(col, row));
|
||
}
|
||
/** A drag from (c0,r0) to (c1,r1) on tableId while editing a formula:
|
||
* insert/replace a *range* reference, meant to be called live as the drag
|
||
* updates (each call replaces the same span, growing/shrinking the range
|
||
* as the user drags), not just once at the end. */
|
||
function pointDrag(tableId: string, c0: number, r0: number, c1: number, r1: number): boolean {
|
||
const a = toA1(Math.min(c0, c1), Math.min(r0, r1));
|
||
const b = toA1(Math.max(c0, c1), Math.max(r0, r1));
|
||
return applyPointRef(tableId, a === b ? a : `${a}:${b}`);
|
||
}
|
||
|
||
// The "active cell" is always the anchor -- the first cell of the current
|
||
// selection action (click, shift-click, drag start, ...) -- so it stays put
|
||
// while a multi-cell selection is being made, matching Excel/Numbers.
|
||
const activeCell = computed<CellCoord>(() => doc.selection?.anchor ?? { col: 0, row: 0 });
|
||
|
||
const activeCellName = computed(() => {
|
||
const sel = doc.selection;
|
||
const t = sel ? tableById(sel.tableId) : activeTable.value;
|
||
const cell = sel?.anchor ?? { col: 0, row: 0 };
|
||
return `${t?.name ?? '—'}::${toA1(cell.col, cell.row)}`;
|
||
});
|
||
|
||
/** Like {@link activeCellName}, but for a multi-cell selection it shows the
|
||
* full range (`Umsatz::B2:E6`) instead of just the anchor -- used wherever
|
||
* the UI shows "where am I" (formula bar, status bar), as opposed to
|
||
* `activeCellName`/`activeCell`, which stay anchor-only because that's
|
||
* what a formula/edit actually targets (see the "selection aggregate &
|
||
* cell name are live" test). A discontiguous multi-selection additionally
|
||
* gets a `+N` suffix for the further rectangles beyond the primary one. */
|
||
const selectionRangeName = computed(() => {
|
||
const sel = doc.selection;
|
||
const t = sel ? tableById(sel.tableId) : activeTable.value;
|
||
const tableName = t?.name ?? '—';
|
||
if (!sel) return `${tableName}::${toA1(0, 0)}`;
|
||
const rects = selectionRects(sel);
|
||
const [p] = rects;
|
||
const addr =
|
||
p.c0 === p.c1 && p.r0 === p.r1
|
||
? toA1(p.c0, p.r0)
|
||
: `${toA1(p.c0, p.r0)}:${toA1(p.c1, p.r1)}`;
|
||
const extra = rects.length > 1 ? ` +${rects.length - 1}` : '';
|
||
return `${tableName}::${addr}${extra}`;
|
||
});
|
||
|
||
const activeCellInput = computed(() => {
|
||
const sel = doc.selection;
|
||
return sel ? cellInput(sel.tableId, sel.anchor.col, sel.anchor.row) : '';
|
||
});
|
||
|
||
const selectionAggregate = computed(() => {
|
||
void recalcTick.value;
|
||
const sel = doc.selection;
|
||
if (!sel) return { sum: 0, avg: 0, count: 0, cells: 0 };
|
||
let sum = 0;
|
||
let count = 0;
|
||
let cells = 0;
|
||
for (const key of selectedCellKeys()) {
|
||
cells++;
|
||
const [c, r] = key.split(',').map(Number);
|
||
const v = wb.cellValue(sel.tableId, c, r);
|
||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||
sum += v;
|
||
count++;
|
||
}
|
||
}
|
||
return { sum, avg: count ? sum / count : 0, count, cells };
|
||
});
|
||
|
||
// ---- document-level ops ---------------------------
|
||
/** The actual content swap, shared by replaceDocument() and undo()/redo() --
|
||
* the latter must *not* also reset history (that's the whole stack it's
|
||
* currently unwinding), so they call this directly instead. */
|
||
function applyDocumentState(next: DocumentState) {
|
||
doc.version = 2;
|
||
doc.sheets = next.sheets;
|
||
doc.activeSheetId =
|
||
next.sheets.find((s) => s.id === next.activeSheetId)?.id ?? next.sheets[0].id;
|
||
doc.activeObjectId = next.activeObjectId ?? null;
|
||
doc.selection = next.selection ?? null;
|
||
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
|
||
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
||
doc.customFunctions = next.customFunctions ?? [];
|
||
doc.namedRanges = next.namedRanges ?? [];
|
||
doc.macros = next.macros ?? [];
|
||
syncWorkbook();
|
||
// never markDirty(): shared by undo/redo (which reconcile history state
|
||
// themselves right after calling this) and replaceDocument (which resets
|
||
// it outright via resetHistory()) -- none of the three want this counted
|
||
// as a new edit. Persistence, unlike undo history, has no such carve-out.
|
||
scheduleSave();
|
||
}
|
||
/** Loading a different document outright (JSON import "replace", "reset to
|
||
* seed") -- unlike undo/redo, this starts a fresh undo history rather
|
||
* than leaving the previous document's edits undoable into the new one. */
|
||
function replaceDocument(next: DocumentState) {
|
||
applyDocumentState(next);
|
||
resetHistory();
|
||
}
|
||
/** Datei › Neu: an empty document. */
|
||
function newDocument() {
|
||
replaceDocument(createBlankDocument());
|
||
}
|
||
/** Datei › Muster: the pre-filled sample document. */
|
||
function resetToSeed() {
|
||
replaceDocument(createSeedDocument());
|
||
}
|
||
/** Append fully-formed sheets (JSON-append / XLSX import). */
|
||
function appendSheets(sheets: Sheet[]) {
|
||
for (const s of sheets) {
|
||
let id = s.id;
|
||
while (doc.sheets.some((o) => o.id === id)) id = uid('sheet');
|
||
const sn = new Set(doc.sheets.map((x) => x.name.toLowerCase()));
|
||
doc.sheets.push({ ...s, id, name: uniqueName(s.name, sn) });
|
||
doc.activeSheetId = id;
|
||
}
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
/** Add a new table object built from a cell map (CSV "as new table"). */
|
||
function addTableFromCells(
|
||
sheetId: string,
|
||
name: string,
|
||
cells: Record<string, string>,
|
||
rows: number,
|
||
cols: number,
|
||
): string {
|
||
const id = addTable(sheetId);
|
||
const t = tableById(id)!;
|
||
t.cells = { ...cells };
|
||
t.rows = Math.max(rows, 1);
|
||
t.cols = Math.max(cols, 1);
|
||
renameObject(id, name); // also syncs the workbook
|
||
return id;
|
||
}
|
||
/** Replace an existing table's whole cell map (CSV "replace active table"). */
|
||
function replaceTableCells(
|
||
tableId: string,
|
||
cells: Record<string, string>,
|
||
rows: number,
|
||
cols: number,
|
||
) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
t.cells = { ...cells };
|
||
t.rows = Math.max(rows, 1);
|
||
t.cols = Math.max(cols, 1);
|
||
t.colWidths = {};
|
||
syncWorkbook();
|
||
markDirty();
|
||
}
|
||
function snapshot(): DocumentState {
|
||
return JSON.parse(JSON.stringify(doc)) as DocumentState;
|
||
}
|
||
|
||
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,
|
||
addButton,
|
||
setButtonLabel,
|
||
setButtonMacroId,
|
||
resizeButton,
|
||
renameObject,
|
||
deleteObject,
|
||
moveObject,
|
||
// custom functions
|
||
customFunctionNames,
|
||
addCustomFunction,
|
||
updateCustomFunction,
|
||
deleteCustomFunction,
|
||
addNamedRange,
|
||
updateNamedRange,
|
||
deleteNamedRange,
|
||
// macros
|
||
addMacro,
|
||
updateMacro,
|
||
deleteMacro,
|
||
runMacro,
|
||
resizeChart,
|
||
bringToFront,
|
||
setChartView,
|
||
setChartSource,
|
||
selectObject,
|
||
setObjectBg,
|
||
setObjectTextColor,
|
||
setTableHideChromeUnfocused,
|
||
// table grid
|
||
tableById,
|
||
cellInput,
|
||
cellValue,
|
||
cellDisplay,
|
||
cellIsNumeric,
|
||
cellIsError,
|
||
cellIsFormula,
|
||
cellGraphSelf,
|
||
cellGraphNeighbors,
|
||
openDependencyGraph,
|
||
closeDependencyGraph,
|
||
setCellInput,
|
||
clearRange,
|
||
addRows,
|
||
addColumns,
|
||
removeLastRow,
|
||
removeLastColumn,
|
||
setColWidth,
|
||
setRowHeight,
|
||
setTableRowHeight,
|
||
rowHeightOf,
|
||
setTableRows,
|
||
setTableCols,
|
||
insertRow,
|
||
deleteRow,
|
||
insertColumn,
|
||
deleteColumn,
|
||
setTableView,
|
||
// cell formatting
|
||
cellFormat,
|
||
setRangeFormat,
|
||
clearRangeFormat,
|
||
selectionFormat,
|
||
applySelectionFormat,
|
||
clearSelectionFormat,
|
||
clearSelectionCells,
|
||
// number-format locale
|
||
effectiveLocale,
|
||
activeLocale,
|
||
setDocumentLocale,
|
||
setSheetLocale,
|
||
setObjectLocale,
|
||
// per-sheet appearance + zoom + csv default
|
||
setSheetBackground,
|
||
setSheetForeground,
|
||
setSheetShowGrid,
|
||
setSheetGridSize,
|
||
setSheetZoom,
|
||
activeZoom,
|
||
setZoom,
|
||
setCsvDelimiter,
|
||
// view-only ui
|
||
ui,
|
||
// selection
|
||
setSelection,
|
||
selectCell,
|
||
addToSelection,
|
||
extendActiveRange,
|
||
addRangeToSelection,
|
||
selectionRects,
|
||
activeCell,
|
||
activeCellName,
|
||
selectionRangeName,
|
||
activeCellInput,
|
||
selectionAggregate,
|
||
// clipboard
|
||
copySelection,
|
||
cutSelection,
|
||
pasteClipboard,
|
||
cancelClipboard,
|
||
hasClipboard,
|
||
clipboardRect,
|
||
fillSelection,
|
||
// formula "point mode"
|
||
registerFormulaEditor,
|
||
noteFormulaTyped,
|
||
formulaPointModeActive,
|
||
functionSuggestions,
|
||
nameBoxSuggestions,
|
||
jumpToNameInput,
|
||
pointClick,
|
||
pointDrag,
|
||
// document
|
||
replaceDocument,
|
||
newDocument,
|
||
resetToSeed,
|
||
appendSheets,
|
||
addTableFromCells,
|
||
replaceTableCells,
|
||
snapshot,
|
||
engine: () => wb,
|
||
};
|
||
}
|
||
|
||
let instance: ReturnType<typeof createStore> | null = null;
|
||
|
||
export function useDocument() {
|
||
if (!instance) instance = createStore();
|
||
return instance;
|
||
}
|
||
|
||
export type DocumentStore = ReturnType<typeof createStore>;
|