Replaces the previous FOR-only, single-statement-body, static-unrolling design with genuine control flow: FOR var IN source DO <block> END loop bodies with any number of statements (including nested loops/IFs), IF cond THEN <block> [ELSE <block>] END branching (nestable in any block), and RETURN as an ordinary statement usable anywhere -- it aborts execution immediately with its value, including breaking out of a loop early. This required a real, if small, architecture change: an IF branch or an early RETURN depends on actual cell values, which the previous "unroll everything into one static formula at parse time" approach can't resolve (loop *counts* are knowable up front from a range's size; branch *outcomes* aren't). A kind: 'block' call is now inlined only as far as resolving nested function calls (expandBlock, parse-time, same inside-out principle as before) and wrapped in a new BlockCallNode; its own control flow is interpreted at evaluation time by a small tree-walking interpreter added to evaluator.ts (execBlock/execStatement, a thrown ReturnSignal for early exit), reusing userFunctions.ts's substitute() (now exported) to resolve parameter/local-variable names against the current environment. Dependency tracking stays fully correct and conservative: collectRefs() walks into a BlockCallNode's block (new collectRefsInBlock), so a cell referenced only in a branch that doesn't happen to run is still a tracked precedent -- same principle as an ordinary IF() in any spreadsheet. Also added: comparison operators (= <> > < >= <=, result 1/0) as a new, loosest-binding precedence level in the *general* expression grammar (not just inside a block's IF) -- needed for IF conditions to be useful at all. A definition-time static check (blockAlwaysReturns) rejects a function at save time when it's not obviously guaranteed to reach RETURN on every path (an IF without an ELSE, or one where only one branch returns); the interpreter's own runtime "ended without RETURN" -> #ERR remains the actual enforcement for cases the static check can't see (e.g. a loop over an empty range). Nested loops (FOR inside FOR) and nested IFs work as an emergent property of the recursive statement grammar -- verified with dedicated tests, not just assumed. 257 tests passing (30 net new/rewritten for this change), vue-tsc/ eslint/build clean. Verified live in the running app: a FIRSTOVER(range; threshold) function using a FOR loop with a nested IF and an early RETURN, confirmed it returns the first match, confirmed changing a cell *past* the match point doesn't affect the result while changing the match point itself does, and confirmed a function without a guaranteed RETURN is rejected inline at save time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1781 lines
66 KiB
TypeScript
1781 lines
66 KiB
TypeScript
/**
|
||
* Reactive document store (Numbers-style canvas model).
|
||
*
|
||
* This is the "own reactive() module" the brief allows instead of Pinia. It
|
||
* owns the persisted {@link DocumentState} v2 and a single {@link Workbook}
|
||
* engine instance, and keeps them in sync:
|
||
*
|
||
* - cell edits go straight into the engine (`wb.setCell`) for an incremental
|
||
* recalculation;
|
||
* - structural edits (add/rename/delete a table, grid growth, ...) rebuild the
|
||
* engine from the document;
|
||
* - `recalcTick` is bumped after every recalculation. UI code reads
|
||
* `store.cellValue(...)`, which touches `recalcTick`, so every formula cell
|
||
* re-renders exactly like a `computed` would.
|
||
*
|
||
* The engine is table-centric: a table object's `name` is a document-unique
|
||
* qualifier, so `Umsatz!B2` resolves the same from any sheet.
|
||
*/
|
||
|
||
import { computed, reactive, ref, watch } from 'vue';
|
||
import {
|
||
Workbook,
|
||
toA1,
|
||
isError,
|
||
tokenize,
|
||
looksLikeCell,
|
||
parseA1,
|
||
parseFormula,
|
||
parseFunctionBlock,
|
||
hasFunction,
|
||
collectCalledFunctionNames,
|
||
collectCalledFunctionNamesInBlock,
|
||
declaredBlockNames,
|
||
findInvalidForSource,
|
||
blockAlwaysReturns,
|
||
type CellValue,
|
||
type TableModel,
|
||
type Token,
|
||
type FunctionDef,
|
||
} from '../engine';
|
||
import {
|
||
formatValue,
|
||
isNumeric,
|
||
isNumberLocale,
|
||
DEFAULT_LOCALE,
|
||
type NumberLocale,
|
||
} from './format';
|
||
import type {
|
||
CellCoord,
|
||
CellFormat,
|
||
ChartObject,
|
||
CustomFunction,
|
||
DocumentState,
|
||
Sheet,
|
||
SelectionState,
|
||
SheetObject,
|
||
TableObject,
|
||
TableView,
|
||
TextObject,
|
||
} from './types';
|
||
import { defaultTableView, DEFAULT_ROW_H } from './types';
|
||
import { createSeedDocument } from '../data/seed';
|
||
import { validateDocument } from '../io/json';
|
||
|
||
const STORAGE_KEY = 'numbers-study:doc:v2';
|
||
|
||
const CASCADE = 28; // px offset for successive inserted objects
|
||
|
||
// ---- (de)serialisation helpers ------------------------------------------
|
||
|
||
function allTables(doc: DocumentState): TableObject[] {
|
||
const out: TableObject[] = [];
|
||
for (const s of doc.sheets) {
|
||
for (const o of s.objects) if (o.kind === 'table') out.push(o);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function toTableModels(doc: DocumentState): TableModel[] {
|
||
return doc.sheets.flatMap((s) =>
|
||
s.objects
|
||
.filter((o): o is TableObject => o.kind === 'table')
|
||
.map((t) => ({
|
||
id: t.id,
|
||
name: t.name,
|
||
sheetName: s.name,
|
||
cols: t.cols,
|
||
rows: t.rows,
|
||
inputs: new Map(Object.entries(t.cells)),
|
||
})),
|
||
);
|
||
}
|
||
|
||
function toFunctionDefs(doc: DocumentState): FunctionDef[] {
|
||
return doc.customFunctions.map((f) => ({
|
||
id: f.id,
|
||
name: f.name,
|
||
params: f.params,
|
||
body: f.body,
|
||
kind: f.kind,
|
||
}));
|
||
}
|
||
|
||
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
|
||
type Rect = { c0: number; c1: number; r0: number; r1: number };
|
||
|
||
/** One cell of a copy/cut buffer, relative to the buffer's top-left corner. */
|
||
interface ClipboardCell {
|
||
raw: string;
|
||
format?: CellFormat;
|
||
}
|
||
interface ClipboardData {
|
||
tableId: string;
|
||
rect: Rect;
|
||
width: number;
|
||
height: number;
|
||
/** "dc,dr" (offset from the buffer's top-left) -> cell. Empty/unformatted cells are absent. */
|
||
cells: Map<string, ClipboardCell>;
|
||
/** cut (Strg+X) vs. copy (Strg+C) -- a cut buffer is consumed by its first paste. */
|
||
cut: boolean;
|
||
/** the copy/cut selection's own anchor -- the *first* cell picked, which is
|
||
* not necessarily `rect`'s top-left corner (a drag can start anywhere).
|
||
* Kept so the source marker can still point it out after the current
|
||
* selection has moved elsewhere -- see `clipboardRect`. */
|
||
anchor: CellCoord;
|
||
}
|
||
|
||
/**
|
||
* Whatever's currently editing a formula (the in-cell editor, or the formula
|
||
* bar) registers one of these so a click on the grid -- possibly in a
|
||
* *different* table entirely -- can insert a reference into it. `tableId` is
|
||
* the table the edited cell itself lives in (the "home" table for deciding
|
||
* whether an inserted reference needs a `Name!` qualifier).
|
||
*/
|
||
interface FormulaEditorHandle {
|
||
tableId: string;
|
||
getState(): { text: string; start: number; end: number };
|
||
replace(start: number, end: number, value: string): void;
|
||
}
|
||
|
||
/**
|
||
* Rewrite every cell reference in a formula by (dCol, dRow) -- the same
|
||
* relative-reference adjustment Excel/Numbers apply on copy.
|
||
*
|
||
* A `$`-locked axis (`$A1`, `A$1`, `$A$1`) is exempt from a *copy*'s shift --
|
||
* that is the whole point of locking a reference -- but not from a *cut*'s:
|
||
* a cut moves the referenced cell itself, so any reference to it, `$` or not,
|
||
* must follow to keep pointing at the same (now relocated) cell.
|
||
*
|
||
* `restrict`, when given (a cut/move), narrows the shift to *only* references
|
||
* that point inside that rectangle -- i.e. cells that moved along with the
|
||
* formula. A cut otherwise leaves references untouched, since the cells they
|
||
* point at did not move (and a qualified `Table!A1` reference is never
|
||
* "inside" a local rectangle, so it is always left alone during a cut).
|
||
*/
|
||
function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect): string {
|
||
if (!raw.startsWith('=') || (dCol === 0 && dRow === 0)) return raw;
|
||
const body = raw.slice(1);
|
||
let toks: Token[];
|
||
try {
|
||
toks = tokenize(body);
|
||
} catch {
|
||
return raw; // unparsable -- leave as-is, the evaluator will surface #ERR
|
||
}
|
||
let out = '';
|
||
let last = 0;
|
||
for (let i = 0; i < toks.length; i++) {
|
||
const tok = toks[i];
|
||
if (tok.type !== 'IDENT' || !looksLikeCell(tok.value)) continue;
|
||
const next = toks[i + 1];
|
||
if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell
|
||
const prev = toks[i - 1];
|
||
const qualified = !!prev && (prev.type === 'BANG' || prev.type === 'DBLCOLON');
|
||
if (restrict && qualified) continue; // cut: a reference into another table never moved
|
||
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
|
||
if (
|
||
restrict &&
|
||
!qualified &&
|
||
!(col >= restrict.c0 && col <= restrict.c1 && row >= restrict.r0 && row <= restrict.r1)
|
||
) {
|
||
continue;
|
||
}
|
||
const nc = !restrict && colAbs ? col : Math.max(0, col + dCol);
|
||
const nr = !restrict && rowAbs ? row : Math.max(0, row + dRow);
|
||
out += body.slice(last, tok.pos) + toA1(nc, nr, colAbs, rowAbs);
|
||
last = tok.pos + tok.value.length;
|
||
}
|
||
out += body.slice(last);
|
||
return '=' + out;
|
||
}
|
||
|
||
function loadInitial(): DocumentState {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (raw) {
|
||
// validateDocument migrates v1, validates + *normalises* v2 (fills in
|
||
// `view` and other defaults for documents saved by an older build).
|
||
return validateDocument(JSON.parse(raw));
|
||
}
|
||
} catch {
|
||
/* missing / corrupt / stale-schema -> clean fallback */
|
||
}
|
||
return createSeedDocument();
|
||
}
|
||
|
||
// ---- store singleton --------------------------------------------------
|
||
|
||
function createStore() {
|
||
const doc = reactive<DocumentState>(loadInitial());
|
||
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
|
||
const recalcTick = ref(0);
|
||
const bump = () => (recalcTick.value += 1);
|
||
|
||
/** View-only state: not part of the document, not persisted. */
|
||
const ui = reactive({ settingsOpen: false });
|
||
/** Copy/cut buffer -- not part of the persisted document. */
|
||
const clipboard = ref<ClipboardData | null>(null);
|
||
/** Whichever formula editor is currently open (in-cell editor or formula
|
||
* bar), if any -- drives "point mode": a grid click inserts a reference
|
||
* instead of changing the selection. Not part of the document either. */
|
||
const formulaEditor = ref<FormulaEditorHandle | null>(null);
|
||
/** The most recently inserted-by-click reference's span in the editor's
|
||
* text, so the *next* click replaces it instead of appending another --
|
||
* until the user types something (a plain assignment, not a ref(), so it
|
||
* never itself triggers reactivity/undo history). */
|
||
let pointSpan: { start: number; end: number } | null = null;
|
||
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
|
||
|
||
function syncWorkbook() {
|
||
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
|
||
bump();
|
||
}
|
||
|
||
// ---- persistence (debounced) --------------------------------------
|
||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||
watch(
|
||
() => doc,
|
||
() => {
|
||
clearTimeout(persistTimer);
|
||
persistTimer = setTimeout(() => {
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(doc));
|
||
} catch {
|
||
/* quota / privacy mode -- ignore */
|
||
}
|
||
}, 250);
|
||
},
|
||
{ deep: true },
|
||
);
|
||
|
||
// ---- undo / redo (debounced content snapshots) -----------------------
|
||
/**
|
||
* Snapshot-based, like the JSON export/import round-trip already is
|
||
* (`snapshot()`/`replaceDocument()`) rather than a patch/command log --
|
||
* simple and provably correct for a document this size.
|
||
*
|
||
* Selection/active-object are deliberately excluded from what counts as a
|
||
* "change": moving the cursor around must never itself become an undo
|
||
* step. A burst of rapid mutations (column-resize drag fires on every
|
||
* mousemove; so would per-keystroke typing, if the editors pushed straight
|
||
* into the store instead of committing once) is coalesced into a single
|
||
* step by waiting for HISTORY_COALESCE_MS of quiet before recording one --
|
||
* every mutating action in this store already commits once per discrete
|
||
* user action (a keystroke only touches a component-local draft; only
|
||
* `setCellInput` et al. touch `doc`), so in practice this mainly matters
|
||
* for the resize-drag case. Kept short (well under normal typing/click
|
||
* pace) so two genuinely separate quick actions still don't merge -- a
|
||
* drag's mousemove events are only ~16-30ms apart, comfortably under it.
|
||
*/
|
||
const HISTORY_COALESCE_MS = 150;
|
||
const MAX_HISTORY = 100;
|
||
const undoStack: DocumentState[] = [];
|
||
const redoStack: DocumentState[] = [];
|
||
const contentFingerprint = (d: DocumentState) =>
|
||
JSON.stringify({
|
||
sheets: d.sheets,
|
||
locale: d.locale,
|
||
csvDelimiter: d.csvDelimiter,
|
||
customFunctions: d.customFunctions,
|
||
});
|
||
let historySnapshot = snapshot();
|
||
let historyFingerprint = contentFingerprint(historySnapshot);
|
||
let historyTimer: ReturnType<typeof setTimeout> | undefined;
|
||
// undoStack/redoStack are plain arrays (snapshots are large; wrapping them
|
||
// in reactive() would make Vue deep-proxy every one for no benefit) --
|
||
// this tick is what canUndo/canRedo actually track. historyPending mirrors
|
||
// "does the doc currently differ from the last committed baseline" so
|
||
// canUndo goes true the instant a change happens, not once the debounce
|
||
// settles (undo() itself already doesn't wait either way -- it flushes any
|
||
// pending change synchronously -- this is purely so a bound UI button
|
||
// doesn't lag behind what Ctrl+Z would actually do right now).
|
||
const historyTick = ref(0);
|
||
const historyPending = ref(false);
|
||
// true while undo()/redo() are themselves applying a snapshot -- the watch
|
||
// below must not treat that as "a new edit" (which would immediately wipe
|
||
// the very redoStack/undoStack entry undo()/redo() just pushed).
|
||
let applyingHistory = false;
|
||
|
||
function commitHistoryStep() {
|
||
clearTimeout(historyTimer);
|
||
const fp = contentFingerprint(doc);
|
||
historyPending.value = false;
|
||
if (fp === historyFingerprint) return; // settled back to a no-op, or nothing pending
|
||
undoStack.push(historySnapshot);
|
||
if (undoStack.length > MAX_HISTORY) undoStack.shift();
|
||
historySnapshot = snapshot();
|
||
historyFingerprint = fp;
|
||
historyTick.value++;
|
||
}
|
||
watch(
|
||
() => doc,
|
||
() => {
|
||
if (applyingHistory) return; // undo()/redo() reconciles historyPending/Fingerprint itself
|
||
const dirty = contentFingerprint(doc) !== historyFingerprint;
|
||
// the *first* mutation of a new burst -- a fresh action just started,
|
||
// so any redo branch from a previous undo is no longer reachable.
|
||
// Done here (synchronously, on the transition) rather than debounced
|
||
// in commitHistoryStep(), so it matches real undo/redo semantics
|
||
// everywhere else: a new edit kills "redo" immediately, not 500ms later.
|
||
if (dirty && !historyPending.value) {
|
||
redoStack.length = 0;
|
||
historyTick.value++;
|
||
}
|
||
historyPending.value = dirty;
|
||
if (!dirty) return; // pure selection/UI change, or settled back to a no-op
|
||
clearTimeout(historyTimer);
|
||
historyTimer = setTimeout(commitHistoryStep, HISTORY_COALESCE_MS);
|
||
},
|
||
// sync: historyPending/redoStack must be accurate the instant a mutation
|
||
// happens (so a bound "Undo"/"Redo" button is never briefly wrong) --
|
||
// safe to fire many times during one multi-cell action (paste, fill, a
|
||
// resize drag) because this only *reschedules* the debounced commit,
|
||
// never calls it directly; that stays coalesced into one step either way.
|
||
{ deep: true, flush: 'sync' },
|
||
);
|
||
|
||
/** Wipes undo/redo history and re-baselines it on the current doc -- used
|
||
* when a *different* document is loaded wholesale (replaceDocument()),
|
||
* never by undo()/redo() themselves. */
|
||
function resetHistory() {
|
||
clearTimeout(historyTimer);
|
||
undoStack.length = 0;
|
||
redoStack.length = 0;
|
||
historySnapshot = snapshot();
|
||
historyFingerprint = contentFingerprint(historySnapshot);
|
||
historyPending.value = false;
|
||
historyTick.value++;
|
||
}
|
||
|
||
const canUndo = computed(() => (historyTick.value, undoStack.length > 0 || historyPending.value));
|
||
const canRedo = computed(() => (historyTick.value, redoStack.length > 0));
|
||
|
||
function undo() {
|
||
commitHistoryStep(); // flush a pending (not yet coalesced) change first
|
||
const prev = undoStack.pop();
|
||
if (!prev) return;
|
||
redoStack.push(snapshot());
|
||
if (redoStack.length > MAX_HISTORY) redoStack.shift();
|
||
applyingHistory = true;
|
||
applyDocumentState(prev);
|
||
applyingHistory = false;
|
||
// reconcile manually, matching what applyDocumentState() just did --
|
||
// the watch above skipped this while applyingHistory was true
|
||
historySnapshot = snapshot();
|
||
historyFingerprint = contentFingerprint(doc);
|
||
historyPending.value = false;
|
||
historyTick.value++;
|
||
}
|
||
function redo() {
|
||
clearTimeout(historyTimer);
|
||
const next = redoStack.pop();
|
||
if (!next) return;
|
||
undoStack.push(snapshot());
|
||
if (undoStack.length > MAX_HISTORY) undoStack.shift();
|
||
applyingHistory = true;
|
||
applyDocumentState(next);
|
||
applyingHistory = false;
|
||
historySnapshot = snapshot();
|
||
historyFingerprint = contentFingerprint(doc);
|
||
historyPending.value = false;
|
||
historyTick.value++;
|
||
}
|
||
|
||
// ---- id / name helpers ------------------------------------------
|
||
let seq = 0;
|
||
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}`;
|
||
|
||
function uniqueName(base: string, taken: Set<string>): string {
|
||
const b = base.trim() || 'Element';
|
||
if (!taken.has(b.toLowerCase())) return b;
|
||
let i = 2;
|
||
while (taken.has(`${b} ${i}`.toLowerCase())) i++;
|
||
return `${b} ${i}`;
|
||
}
|
||
const objectNames = () =>
|
||
new Set(doc.sheets.flatMap((s) => s.objects).map((o) => o.name.toLowerCase()));
|
||
const sheetNames = () => new Set(doc.sheets.map((s) => s.name.toLowerCase()));
|
||
/** Table names are unique *per sheet* only -- two different sheets may
|
||
* each hold a table called "Umsatz" (see engine/types.ts's EngineHost
|
||
* doc comment for how a formula then resolves it). */
|
||
const tableNamesOnSheet = (sheetId: string) =>
|
||
new Set(tablesOf(sheetId).map((t) => t.name.toLowerCase()));
|
||
const objectNamesOnSheet = (sheetId: string) =>
|
||
new Set((sheetById(sheetId)?.objects ?? []).map((o) => o.name.toLowerCase()));
|
||
|
||
// ---- lookups --------------------------------------------------
|
||
const sheetById = (id: string): Sheet | undefined => doc.sheets.find((s) => s.id === id);
|
||
const activeSheet = computed(() => sheetById(doc.activeSheetId) ?? doc.sheets[0]);
|
||
|
||
function objectById(id: string | null): SheetObject | undefined {
|
||
if (!id) return undefined;
|
||
for (const s of doc.sheets) {
|
||
const o = s.objects.find((x) => x.id === id);
|
||
if (o) return o;
|
||
}
|
||
return undefined;
|
||
}
|
||
function sheetIdOfObject(id: string): string | undefined {
|
||
return doc.sheets.find((s) => s.objects.some((o) => o.id === id))?.id;
|
||
}
|
||
const tableById = (id: string): TableObject | undefined => {
|
||
const o = objectById(id);
|
||
return o && o.kind === 'table' ? o : undefined;
|
||
};
|
||
const tablesOf = (sheetId: string): TableObject[] =>
|
||
(sheetById(sheetId)?.objects.filter((o): o is TableObject => o.kind === 'table') ?? []);
|
||
|
||
const activeObject = computed(() => objectById(doc.activeObjectId));
|
||
const activeTable = computed<TableObject | undefined>(() => {
|
||
if (doc.selection) return tableById(doc.selection.tableId);
|
||
return tablesOf(doc.activeSheetId)[0];
|
||
});
|
||
|
||
// ---- sheet structure -----------------------------------------
|
||
function setActiveSheet(id: string) {
|
||
if (!sheetById(id) || id === doc.activeSheetId) return;
|
||
doc.activeSheetId = id;
|
||
doc.activeObjectId = null;
|
||
const first = tablesOf(id)[0];
|
||
doc.selection = first
|
||
? { tableId: first.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } }
|
||
: null;
|
||
}
|
||
|
||
function addSheet(name?: string): string {
|
||
const id = uid('sheet');
|
||
doc.sheets.push({ id, name: uniqueName(name || 'Blatt', sheetNames()), objects: [] });
|
||
setActiveSheet(id);
|
||
return id;
|
||
}
|
||
function renameSheet(id: string, name: string) {
|
||
const s = sheetById(id);
|
||
const clean = name.trim();
|
||
if (!s || !clean) return;
|
||
const others = new Set(doc.sheets.filter((o) => o.id !== id).map((o) => o.name.toLowerCase()));
|
||
s.name = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean;
|
||
}
|
||
function deleteSheet(id: string) {
|
||
if (doc.sheets.length <= 1) return;
|
||
const idx = doc.sheets.findIndex((s) => s.id === id);
|
||
if (idx < 0) return;
|
||
const hadTables = tablesOf(id).length > 0;
|
||
doc.sheets.splice(idx, 1);
|
||
if (doc.activeSheetId === id) setActiveSheet(doc.sheets[Math.max(0, idx - 1)].id);
|
||
if (hadTables) syncWorkbook(); // dependents elsewhere become #REF
|
||
}
|
||
function moveSheet(from: number, to: number) {
|
||
if (from < 0 || to < 0 || from >= doc.sheets.length || to >= doc.sheets.length || from === to)
|
||
return;
|
||
const [s] = doc.sheets.splice(from, 1);
|
||
doc.sheets.splice(to, 0, s);
|
||
}
|
||
|
||
// ---- per-sheet appearance + zoom -----------------------------
|
||
function setSheetBackground(sheetId: string, color: string | null) {
|
||
const s = sheetById(sheetId);
|
||
if (!s) return;
|
||
if (color) s.background = color;
|
||
else delete s.background;
|
||
}
|
||
function setSheetForeground(sheetId: string, color: string | null) {
|
||
const s = sheetById(sheetId);
|
||
if (!s) return;
|
||
if (color) s.foreground = color;
|
||
else delete s.foreground;
|
||
}
|
||
function setSheetShowGrid(sheetId: string, show: boolean) {
|
||
const s = sheetById(sheetId);
|
||
if (s) s.showGrid = show;
|
||
}
|
||
function setSheetZoom(sheetId: string, z: number) {
|
||
const s = sheetById(sheetId);
|
||
if (s) s.zoom = clampZoom(z);
|
||
}
|
||
/** Zoom of the currently active sheet (used by the canvas + zoom bar). */
|
||
const activeZoom = computed(() => activeSheet.value.zoom ?? 1);
|
||
const setZoom = (z: number) => setSheetZoom(doc.activeSheetId, z);
|
||
|
||
// ---- per-object background + text colour (table / chart / text) ----
|
||
function setObjectBg(objId: string, color: string | null) {
|
||
const o = objectById(objId);
|
||
if (!o) return;
|
||
if (color) o.bg = color;
|
||
else delete o.bg;
|
||
if (o.kind !== 'table') bump();
|
||
}
|
||
function setObjectTextColor(objId: string, color: string | null) {
|
||
const o = objectById(objId);
|
||
if (!o) return;
|
||
if (o.kind === 'text') {
|
||
if (color) o.color = color;
|
||
else delete o.color;
|
||
bump();
|
||
} else {
|
||
if (color) o.fg = color;
|
||
else delete o.fg;
|
||
if (o.kind === 'chart') bump();
|
||
}
|
||
}
|
||
|
||
function setCsvDelimiter(d: ',' | ';') {
|
||
doc.csvDelimiter = d === ';' ? ';' : ',';
|
||
}
|
||
|
||
// ---- canvas objects ----------------------------------------
|
||
function nextCascade(sheet: Sheet): { x: number; y: number } {
|
||
const n = sheet.objects.length;
|
||
return { x: 40 + (n % 6) * CASCADE, y: 40 + (n % 6) * CASCADE };
|
||
}
|
||
|
||
function addTable(sheetId: string, pos?: { x: number; y: number }): string {
|
||
const sheet = sheetById(sheetId);
|
||
if (!sheet) return '';
|
||
const id = uid('tbl');
|
||
const p = pos ?? nextCascade(sheet);
|
||
const table: TableObject = {
|
||
kind: 'table',
|
||
id,
|
||
name: uniqueName('Tabelle', new Set([...tableNamesOnSheet(sheetId), ...objectNamesOnSheet(sheetId)])),
|
||
x: Math.max(0, Math.round(p.x)),
|
||
y: Math.max(0, Math.round(p.y)),
|
||
rows: 12,
|
||
cols: 6,
|
||
colWidths: {},
|
||
cells: {},
|
||
view: defaultTableView(),
|
||
};
|
||
sheet.objects.push(table);
|
||
doc.activeObjectId = id;
|
||
doc.activeSheetId = sheetId;
|
||
doc.selection = { tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
|
||
syncWorkbook();
|
||
return id;
|
||
}
|
||
|
||
function addChart(
|
||
sheetId: string,
|
||
pos?: { x: number; y: number },
|
||
sourceTableId?: string | null,
|
||
): string {
|
||
const sheet = sheetById(sheetId);
|
||
if (!sheet) return '';
|
||
const id = uid('cht');
|
||
const p = pos ?? nextCascade(sheet);
|
||
const src = sourceTableId ?? tablesOf(sheetId)[0]?.id ?? allTables(doc)[0]?.id ?? null;
|
||
const chart: ChartObject = {
|
||
kind: 'chart',
|
||
id,
|
||
name: uniqueName('Diagramm', objectNames()),
|
||
x: Math.max(0, Math.round(p.x)),
|
||
y: Math.max(0, Math.round(p.y)),
|
||
width: 460,
|
||
height: 300,
|
||
view: 'bar',
|
||
sourceTableId: src,
|
||
};
|
||
sheet.objects.push(chart);
|
||
doc.activeObjectId = id;
|
||
doc.activeSheetId = sheetId;
|
||
bump();
|
||
return id;
|
||
}
|
||
|
||
function addText(sheetId: string, pos?: { x: number; y: number }): string {
|
||
const sheet = sheetById(sheetId);
|
||
if (!sheet) return '';
|
||
const id = uid('txt');
|
||
const p = pos ?? nextCascade(sheet);
|
||
const text: TextObject = {
|
||
kind: 'text',
|
||
id,
|
||
name: uniqueName('Text', objectNames()),
|
||
x: Math.max(0, Math.round(p.x)),
|
||
y: Math.max(0, Math.round(p.y)),
|
||
width: 260,
|
||
height: 96,
|
||
text: 'Text …',
|
||
fontSize: 15,
|
||
align: 'left',
|
||
};
|
||
sheet.objects.push(text);
|
||
doc.activeObjectId = id;
|
||
doc.activeSheetId = sheetId;
|
||
bump();
|
||
return id;
|
||
}
|
||
function setTextContent(objId: string, text: string) {
|
||
const o = objectById(objId);
|
||
if (o?.kind === 'text') o.text = text;
|
||
}
|
||
function setTextFormat(objId: string, patch: Partial<Omit<TextObject, 'kind' | 'id'>>) {
|
||
const o = objectById(objId);
|
||
if (o?.kind !== 'text') return;
|
||
if (patch.fontSize !== undefined) o.fontSize = Math.min(96, Math.max(9, Math.round(patch.fontSize)));
|
||
if (patch.align !== undefined) o.align = patch.align;
|
||
if (patch.bold !== undefined) o.bold = patch.bold || undefined;
|
||
if (patch.italic !== undefined) o.italic = patch.italic || undefined;
|
||
if ('color' in patch) o.color = patch.color || undefined;
|
||
if ('bg' in patch) o.bg = patch.bg || undefined;
|
||
}
|
||
function resizeText(objId: string, width: number, height: number) {
|
||
const o = objectById(objId);
|
||
if (o?.kind !== 'text') return;
|
||
o.width = Math.max(80, Math.round(width));
|
||
o.height = Math.max(32, Math.round(height));
|
||
}
|
||
|
||
function renameObject(objId: string, name: string) {
|
||
const o = objectById(objId);
|
||
const clean = name.trim();
|
||
if (!o || !clean) return;
|
||
if (o.kind === 'table') {
|
||
// uniqueness is per sheet only -- see engine/types.ts's EngineHost
|
||
const sheetId = sheetIdOfObject(objId);
|
||
const others = new Set(
|
||
(sheetId ? tablesOf(sheetId) : allTables(doc))
|
||
.filter((t) => t.id !== objId)
|
||
.map((t) => t.name.toLowerCase()),
|
||
);
|
||
o.name = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean;
|
||
syncWorkbook(); // formulas referencing the old/new name must re-resolve
|
||
} else {
|
||
o.name = clean;
|
||
}
|
||
}
|
||
|
||
function deleteObject(objId: string) {
|
||
const sheetId = sheetIdOfObject(objId);
|
||
const sheet = sheetId ? sheetById(sheetId) : undefined;
|
||
if (!sheet) return;
|
||
const o = sheet.objects.find((x) => x.id === objId);
|
||
const isTable = o?.kind === 'table';
|
||
sheet.objects = sheet.objects.filter((x) => x.id !== objId);
|
||
if (doc.activeObjectId === objId) doc.activeObjectId = null;
|
||
if (doc.selection?.tableId === objId) doc.selection = null;
|
||
// charts bound to a deleted table keep a dangling id -> they render empty
|
||
if (isTable) syncWorkbook();
|
||
else bump();
|
||
}
|
||
|
||
// ---- custom functions (document-level formula library) ----------------
|
||
// Created/edited/removed exclusively through the "𝑓 Funktionen" UI. A call
|
||
// is inlined into the caller's AST at parse time (engine/userFunctions.ts)
|
||
// -- renaming/deleting one behaves exactly like renaming/deleting a table:
|
||
// existing formula text is left as-is and becomes #NAME? / #REF on the next
|
||
// syncWorkbook(), there is no find-and-replace across stored formulas.
|
||
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||
|
||
const customFunctionNames = () => new Set(doc.customFunctions.map((f) => f.name.toLowerCase()));
|
||
|
||
/** Every (upper-cased) function name a stored custom function's body calls,
|
||
* tolerant of a body that -- in theory -- no longer parses (shouldn't
|
||
* happen; a save always validates first, but this is only used for a
|
||
* best-effort cycle check, so fail open rather than block unrelated saves). */
|
||
function bodyCallSet(f: Pick<CustomFunction, 'params' | 'body' | 'kind'>): Set<string> {
|
||
try {
|
||
return f.kind === 'block'
|
||
? collectCalledFunctionNamesInBlock(parseFunctionBlock(f.body, f.params))
|
||
: collectCalledFunctionNames(parseFormula(f.body, f.params));
|
||
} catch {
|
||
return new Set();
|
||
}
|
||
}
|
||
|
||
/** Does the call graph across every custom function (built-ins are never
|
||
* nodes -- they can't call back into user functions) contain a cycle? */
|
||
function callGraphHasCycle(graph: Map<string, Set<string>>): boolean {
|
||
const state = new Map<string, 1 | 2>(); // 1 = on the current DFS path, 2 = fully explored
|
||
function dfs(node: string): boolean {
|
||
const st = state.get(node);
|
||
if (st === 1) return true; // back-edge -> cycle
|
||
if (st === 2) return false;
|
||
state.set(node, 1);
|
||
for (const callee of graph.get(node) ?? []) {
|
||
if (graph.has(callee) && dfs(callee)) return true;
|
||
}
|
||
state.set(node, 2);
|
||
return false;
|
||
}
|
||
for (const node of graph.keys()) {
|
||
if (dfs(node)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** FOR/IN/DO/RETURN double as block-body keywords (see parser.ts's
|
||
* parseFunctionBlock) -- never usable as a parameter/local name, in
|
||
* either kind of function (so a name stays valid if the function is
|
||
* later switched to "Mit Schleife"). */
|
||
const RESERVED_NAMES = new Set(['FOR', 'IN', 'DO', 'RETURN']);
|
||
|
||
/**
|
||
* Full validation for add/update: name & parameter shape, no collision with
|
||
* a built-in or another custom function, the body parses (as an
|
||
* expression, or -- for `kind: 'block'` -- as a FOR/DO/RETURN statement
|
||
* sequence, with every local/loop-variable name validated the same way as
|
||
* a parameter and every FOR source structurally a parameter/cell/range),
|
||
* and -- simulating the *whole* call graph with this function's (possibly
|
||
* new) definition in place -- no cycle. Returns a user-facing (German)
|
||
* error string, or null when the definition is safe to save. `ignoreId`
|
||
* excludes the function being edited from the collision/graph checks
|
||
* against its own old state.
|
||
*/
|
||
function validateCustomFunction(
|
||
name: string,
|
||
params: string[],
|
||
body: string,
|
||
kind: CustomFunction['kind'],
|
||
ignoreId: string | null,
|
||
): string | null {
|
||
const cleanName = name.trim();
|
||
if (!IDENT_RE.test(cleanName)) {
|
||
return `"${name}" ist kein gültiger Funktionsname.`;
|
||
}
|
||
if (looksLikeCell(cleanName)) {
|
||
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Funktionsname nicht erlaubt.`;
|
||
}
|
||
if (hasFunction(cleanName)) {
|
||
return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`;
|
||
}
|
||
const collision = doc.customFunctions.find(
|
||
(f) => f.id !== ignoreId && f.name.toLowerCase() === cleanName.toLowerCase(),
|
||
);
|
||
if (collision) return `Es gibt bereits eine Funktion namens "${collision.name}".`;
|
||
|
||
const cleanParams = params.map((p) => p.trim());
|
||
for (const p of cleanParams) {
|
||
if (!IDENT_RE.test(p)) return `"${p}" ist kein gültiger Parametername.`;
|
||
if (looksLikeCell(p)) return `Parameter "${p}" sieht wie ein Zellbezug aus.`;
|
||
if (RESERVED_NAMES.has(p.toUpperCase())) {
|
||
return `"${p}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Parametername verwendet werden.`;
|
||
}
|
||
}
|
||
if (new Set(cleanParams.map((p) => p.toLowerCase())).size !== cleanParams.length) {
|
||
return 'Parameternamen müssen eindeutig sein.';
|
||
}
|
||
|
||
let ownCalls: Set<string>;
|
||
if (kind === 'block') {
|
||
let block;
|
||
try {
|
||
block = parseFunctionBlock(body, cleanParams);
|
||
} catch (e) {
|
||
return `Code ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||
}
|
||
// declaredBlockNames() lists every assignment target/loop variable, but
|
||
// the SAME name legitimately reappears across statements (that's the
|
||
// whole point of an accumulator: `ergebnis = 1`, then later `FOR x IN
|
||
// bereich DO ergebnis = ergebnis * x` re-assigns "ergebnis", it doesn't
|
||
// declare a second one) -- so shape/reserved-word checks run on every
|
||
// occurrence (harmless to repeat), but the "no collision" check only
|
||
// looks at each *distinct* local name against the parameters, never
|
||
// against other locals (re-using a local's own name is exactly the
|
||
// point; two locals can even share a name across two separate loops).
|
||
const localNames = declaredBlockNames(block);
|
||
for (const n of localNames) {
|
||
if (!IDENT_RE.test(n)) return `"${n}" ist kein gültiger Name.`;
|
||
if (looksLikeCell(n)) return `"${n}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`;
|
||
if (RESERVED_NAMES.has(n.toUpperCase())) {
|
||
return `"${n}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`;
|
||
}
|
||
}
|
||
const paramSet = new Set(cleanParams.map((p) => p.toLowerCase()));
|
||
for (const n of new Set(localNames.map((x) => x.toLowerCase()))) {
|
||
if (paramSet.has(n)) return `"${n}" ist bereits ein Parametername.`;
|
||
}
|
||
const badFor = findInvalidForSource(block);
|
||
if (badFor) {
|
||
return `FOR ${badFor.varName} IN ...: die Quelle muss ein Parameter, eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`;
|
||
}
|
||
// conservative static check (see blockAlwaysReturns's own doc comment)
|
||
// -- a real "no RETURN reached" (e.g. an empty FOR range) still
|
||
// degrades to #ERR at the affected cell regardless, this just catches
|
||
// the common case immediately, before ever saving.
|
||
if (!blockAlwaysReturns(block)) {
|
||
return 'Nicht jeder Codepfad endet mit RETURN.';
|
||
}
|
||
ownCalls = collectCalledFunctionNamesInBlock(block);
|
||
} else {
|
||
let ast;
|
||
try {
|
||
ast = parseFormula(body, cleanParams);
|
||
} catch (e) {
|
||
return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||
}
|
||
ownCalls = collectCalledFunctionNames(ast);
|
||
}
|
||
|
||
const graph = new Map<string, Set<string>>();
|
||
for (const f of doc.customFunctions) {
|
||
if (f.id === ignoreId) continue;
|
||
graph.set(f.name.toUpperCase(), bodyCallSet(f));
|
||
}
|
||
graph.set(cleanName.toUpperCase(), ownCalls);
|
||
if (callGraphHasCycle(graph)) {
|
||
return `"${cleanName}" führt (direkt oder über andere Funktionen) zu einem Zyklus.`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function addCustomFunction(
|
||
name: string,
|
||
params: string[],
|
||
body: string,
|
||
kind: CustomFunction['kind'] = 'expression',
|
||
): string | { error: string } {
|
||
const error = validateCustomFunction(name, params, body, kind, null);
|
||
if (error) return { error };
|
||
const id = uid('fn');
|
||
const custom: CustomFunction = {
|
||
id,
|
||
name: name.trim(),
|
||
params: params.map((p) => p.trim()),
|
||
body: body.startsWith('=') ? body.slice(1) : body,
|
||
kind,
|
||
};
|
||
doc.customFunctions.push(custom);
|
||
syncWorkbook();
|
||
return id;
|
||
}
|
||
|
||
function updateCustomFunction(
|
||
id: string,
|
||
name: string,
|
||
params: string[],
|
||
body: string,
|
||
kind: CustomFunction['kind'] = 'expression',
|
||
): void | { error: string } {
|
||
const f = doc.customFunctions.find((x) => x.id === id);
|
||
if (!f) return { error: 'Funktion nicht gefunden.' };
|
||
const error = validateCustomFunction(name, params, body, kind, id);
|
||
if (error) return { error };
|
||
f.name = name.trim();
|
||
f.params = params.map((p) => p.trim());
|
||
f.body = body.startsWith('=') ? body.slice(1) : body;
|
||
f.kind = kind;
|
||
syncWorkbook();
|
||
}
|
||
|
||
function deleteCustomFunction(id: string) {
|
||
doc.customFunctions = doc.customFunctions.filter((f) => f.id !== id);
|
||
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
|
||
}
|
||
|
||
function moveObject(objId: string, x: number, y: number) {
|
||
const o = objectById(objId);
|
||
if (!o) return;
|
||
o.x = Math.max(0, Math.round(x));
|
||
o.y = Math.max(0, Math.round(y));
|
||
}
|
||
function resizeChart(objId: string, width: number, height: number) {
|
||
const o = objectById(objId);
|
||
if (!o || o.kind !== 'chart') return;
|
||
o.width = Math.max(200, Math.round(width));
|
||
o.height = Math.max(140, Math.round(height));
|
||
}
|
||
function bringToFront(objId: string) {
|
||
const sheetId = sheetIdOfObject(objId);
|
||
const sheet = sheetId ? sheetById(sheetId) : undefined;
|
||
if (!sheet) return;
|
||
const idx = sheet.objects.findIndex((o) => o.id === objId);
|
||
if (idx < 0 || idx === sheet.objects.length - 1) return;
|
||
const [o] = sheet.objects.splice(idx, 1);
|
||
sheet.objects.push(o);
|
||
}
|
||
function setChartView(objId: string, view: 'bar' | 'line') {
|
||
const o = objectById(objId);
|
||
if (o?.kind === 'chart') o.view = view;
|
||
}
|
||
function setChartSource(objId: string, tableId: string) {
|
||
const o = objectById(objId);
|
||
if (o?.kind === 'chart') o.sourceTableId = tableId;
|
||
}
|
||
function selectObject(objId: string | null) {
|
||
doc.activeObjectId = objId;
|
||
if (!objId) return;
|
||
const sheetId = sheetIdOfObject(objId);
|
||
if (sheetId) doc.activeSheetId = sheetId;
|
||
const o = objectById(objId);
|
||
if (o?.kind === 'table' && doc.selection?.tableId !== objId) {
|
||
doc.selection = { tableId: objId, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
|
||
}
|
||
}
|
||
|
||
// ---- cell reads (reactive via recalcTick) -----------------
|
||
function cellInput(tableId: string, col: number, row: number): string {
|
||
return tableById(tableId)?.cells[`${col},${row}`] ?? '';
|
||
}
|
||
function cellValue(tableId: string, col: number, row: number): CellValue {
|
||
void recalcTick.value;
|
||
return wb.cellValue(tableId, col, row);
|
||
}
|
||
function cellDisplay(tableId: string, col: number, row: number): string {
|
||
return formatValue(
|
||
cellValue(tableId, col, row),
|
||
effectiveLocale(tableId),
|
||
tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto',
|
||
);
|
||
}
|
||
|
||
// ---- number-format locale (object -> sheet -> document cascade) ---
|
||
/** Effective locale for a canvas object (table or chart). */
|
||
function effectiveLocale(objId: string | null): NumberLocale {
|
||
const o = objId ? objectById(objId) : undefined;
|
||
if (o && o.kind !== 'text' && o.locale && isNumberLocale(o.locale)) return o.locale;
|
||
const sheetId = objId ? sheetIdOfObject(objId) : doc.activeSheetId;
|
||
const sheetLoc = sheetId ? sheetById(sheetId)?.locale : undefined;
|
||
if (sheetLoc && isNumberLocale(sheetLoc)) return sheetLoc;
|
||
return isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE;
|
||
}
|
||
const activeLocale = computed(() => effectiveLocale(activeTable.value?.id ?? null));
|
||
|
||
function setDocumentLocale(l: string) {
|
||
if (isNumberLocale(l)) doc.locale = l;
|
||
}
|
||
function setSheetLocale(sheetId: string, l: string | null) {
|
||
const s = sheetById(sheetId);
|
||
if (!s) return;
|
||
if (l && isNumberLocale(l)) s.locale = l;
|
||
else delete s.locale;
|
||
}
|
||
function setObjectLocale(objId: string, l: string | null) {
|
||
const o = objectById(objId);
|
||
if (!o || o.kind === 'text') return;
|
||
if (l && isNumberLocale(l)) o.locale = l;
|
||
else delete o.locale;
|
||
}
|
||
const cellIsNumeric = (t: string, c: number, r: number) => isNumeric(cellValue(t, c, r));
|
||
const cellIsError = (t: string, c: number, r: number) => isError(cellValue(t, c, r));
|
||
const cellIsFormula = (t: string, c: number, r: number) => cellInput(t, c, r).startsWith('=');
|
||
|
||
// ---- cell writes --------------------------------------
|
||
function setCellInput(tableId: string, col: number, row: number, raw: string) {
|
||
const table = tableById(tableId);
|
||
if (!table) return;
|
||
const key = `${col},${row}`;
|
||
const trimmed = raw.replace(/\s+$/, '');
|
||
if (trimmed === '') delete table.cells[key];
|
||
else table.cells[key] = trimmed;
|
||
if (col >= table.cols) table.cols = col + 1;
|
||
if (row >= table.rows) table.rows = row + 1;
|
||
wb.setCell(tableId, col, row, trimmed);
|
||
bump();
|
||
}
|
||
function clearRange(tableId: string, a: CellCoord, b: CellCoord) {
|
||
const c0 = Math.min(a.col, b.col);
|
||
const c1 = Math.max(a.col, b.col);
|
||
const r0 = Math.min(a.row, b.row);
|
||
const r1 = Math.max(a.row, b.row);
|
||
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) setCellInput(tableId, c, r, '');
|
||
}
|
||
|
||
// ---- table grid growth -----------------------------
|
||
const addRows = (tableId: string, n = 1) => {
|
||
const t = tableById(tableId);
|
||
if (t) t.rows += n;
|
||
};
|
||
const addColumns = (tableId: string, n = 1) => {
|
||
const t = tableById(tableId);
|
||
if (t) t.cols += n;
|
||
};
|
||
function removeLastRow(tableId: string) {
|
||
const t = tableById(tableId);
|
||
if (!t || t.rows <= 1) return;
|
||
const r = t.rows - 1;
|
||
for (let c = 0; c < t.cols; c++) delete t.cells[`${c},${r}`];
|
||
t.rows -= 1;
|
||
syncWorkbook();
|
||
}
|
||
function removeLastColumn(tableId: string) {
|
||
const t = tableById(tableId);
|
||
if (!t || t.cols <= 1) return;
|
||
const c = t.cols - 1;
|
||
for (let r = 0; r < t.rows; r++) delete t.cells[`${c},${r}`];
|
||
delete t.colWidths[c];
|
||
t.cols -= 1;
|
||
syncWorkbook();
|
||
}
|
||
const MIN_COL_W = 24;
|
||
const MIN_ROW_H = 14;
|
||
|
||
function setColWidth(tableId: string, col: number, width: number) {
|
||
const t = tableById(tableId);
|
||
if (t) t.colWidths[col] = Math.max(MIN_COL_W, Math.round(width));
|
||
}
|
||
/** Numbers-style proportional resize: scale every column width by `factor`. */
|
||
function scaleTableColumns(tableId: string, factor: number, base: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
for (let c = 0; c < t.cols; c++) {
|
||
const cur = t.colWidths[c] ?? base;
|
||
t.colWidths[c] = Math.max(MIN_COL_W, Math.round(cur * factor));
|
||
}
|
||
}
|
||
/** Height of one row (per-row override, else the table default). */
|
||
function rowHeightOf(tableId: string, row: number): number {
|
||
const t = tableById(tableId);
|
||
return t?.rowHeights?.[row] ?? t?.rowHeight ?? DEFAULT_ROW_H;
|
||
}
|
||
function setRowHeight(tableId: string, row: number, height: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
if (!t.rowHeights) t.rowHeights = {};
|
||
t.rowHeights[row] = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
|
||
}
|
||
/** Default row height for the whole table. */
|
||
function setTableRowHeight(tableId: string, height: number) {
|
||
const t = tableById(tableId);
|
||
if (t) t.rowHeight = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
|
||
}
|
||
|
||
function pruneEmptyMaps(t: TableObject) {
|
||
if (t.formats && !Object.keys(t.formats).length) delete t.formats;
|
||
if (t.rowHeights && !Object.keys(t.rowHeights).length) delete t.rowHeights;
|
||
}
|
||
|
||
/** Grow or shrink a table to an exact row / column count. */
|
||
function setTableRows(tableId: string, target: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
const n = Math.max(1, Math.floor(target));
|
||
if (n === t.rows) return;
|
||
if (n < t.rows) {
|
||
for (const k of Object.keys(t.cells)) if (Number(k.split(',')[1]) >= n) delete t.cells[k];
|
||
if (t.formats) for (const k of Object.keys(t.formats)) if (Number(k.split(',')[1]) >= n) delete t.formats[k];
|
||
if (t.rowHeights) for (const k of Object.keys(t.rowHeights)) if (Number(k) >= n) delete t.rowHeights[Number(k)];
|
||
}
|
||
t.rows = n;
|
||
pruneEmptyMaps(t);
|
||
clampTableView(t);
|
||
clampSelection(tableId);
|
||
syncWorkbook();
|
||
}
|
||
function setTableCols(tableId: string, target: number) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
const n = Math.max(1, Math.floor(target));
|
||
if (n === t.cols) return;
|
||
if (n < t.cols) {
|
||
for (const k of Object.keys(t.cells)) if (Number(k.split(',')[0]) >= n) delete t.cells[k];
|
||
if (t.formats) for (const k of Object.keys(t.formats)) if (Number(k.split(',')[0]) >= n) delete t.formats[k];
|
||
for (const k of Object.keys(t.colWidths)) if (Number(k) >= n) delete t.colWidths[Number(k)];
|
||
}
|
||
t.cols = n;
|
||
pruneEmptyMaps(t);
|
||
clampTableView(t);
|
||
clampSelection(tableId);
|
||
syncWorkbook();
|
||
}
|
||
|
||
// ---- cell formatting (alignment, number format, colours, bold/italic) ---
|
||
const fmtKey = (c: number, r: number) => `${c},${r}`;
|
||
function cellFormat(tableId: string, col: number, row: number): CellFormat {
|
||
return tableById(tableId)?.formats?.[fmtKey(col, row)] ?? {};
|
||
}
|
||
/** Merge `patch` into every cell of a range; a `null`/`undefined` field clears it. */
|
||
function setRangeFormat(
|
||
tableId: string,
|
||
a: CellCoord,
|
||
b: CellCoord,
|
||
patch: Partial<Record<keyof CellFormat, unknown>>,
|
||
) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
if (!t.formats) t.formats = {};
|
||
const c0 = Math.min(a.col, b.col);
|
||
const c1 = Math.max(a.col, b.col);
|
||
const r0 = Math.min(a.row, b.row);
|
||
const r1 = Math.max(a.row, b.row);
|
||
for (let r = r0; r <= r1; r++) {
|
||
for (let c = c0; c <= c1; c++) {
|
||
const k = fmtKey(c, r);
|
||
const cur: CellFormat = { ...(t.formats[k] ?? {}) };
|
||
for (const [field, val] of Object.entries(patch)) {
|
||
if (val === null || val === undefined || val === '' || val === false || val === 'auto') {
|
||
delete (cur as Record<string, unknown>)[field];
|
||
} else {
|
||
(cur as Record<string, unknown>)[field] = val;
|
||
}
|
||
}
|
||
if (Object.keys(cur).length) t.formats[k] = cur;
|
||
else delete t.formats[k];
|
||
}
|
||
}
|
||
if (!Object.keys(t.formats).length) delete t.formats;
|
||
bump();
|
||
}
|
||
function clearRangeFormat(tableId: string, a: CellCoord, b: CellCoord) {
|
||
const t = tableById(tableId);
|
||
if (!t?.formats) return;
|
||
const c0 = Math.min(a.col, b.col);
|
||
const c1 = Math.max(a.col, b.col);
|
||
const r0 = Math.min(a.row, b.row);
|
||
const r1 = Math.max(a.row, b.row);
|
||
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) delete t.formats[fmtKey(c, r)];
|
||
if (!Object.keys(t.formats).length) delete t.formats;
|
||
bump();
|
||
}
|
||
/** Format shared by every cell of the current (possibly multi-) selection. */
|
||
const selectionFormat = computed<CellFormat>(() => {
|
||
void recalcTick.value;
|
||
const s = doc.selection;
|
||
const t = s ? tableById(s.tableId) : undefined;
|
||
if (!s || !t) return {};
|
||
let acc: CellFormat | null = null;
|
||
for (const key of selectedCellKeys()) {
|
||
const f = t.formats?.[key] ?? {};
|
||
if (acc === null) acc = { ...f };
|
||
else {
|
||
for (const k of Object.keys(acc) as (keyof CellFormat)[]) {
|
||
if (acc[k] !== f[k]) delete acc[k];
|
||
}
|
||
}
|
||
}
|
||
return acc ?? {};
|
||
});
|
||
|
||
/** Apply / clear formatting across *all* rectangles of the current selection. */
|
||
function applySelectionFormat(patch: Partial<Record<keyof CellFormat, unknown>>) {
|
||
const s = doc.selection;
|
||
if (!s) return;
|
||
for (const k of selectionRects(s)) {
|
||
setRangeFormat(s.tableId, { col: k.c0, row: k.r0 }, { col: k.c1, row: k.r1 }, patch);
|
||
}
|
||
}
|
||
function clearSelectionFormat() {
|
||
const s = doc.selection;
|
||
if (!s) return;
|
||
for (const k of selectionRects(s)) {
|
||
clearRangeFormat(s.tableId, { col: k.c0, row: k.r0 }, { col: k.c1, row: k.r1 });
|
||
}
|
||
}
|
||
/** Delete/Backspace over a multi-selection. */
|
||
function clearSelectionCells() {
|
||
const s = doc.selection;
|
||
if (!s) return;
|
||
for (const key of selectedCellKeys()) {
|
||
const [c, r] = key.split(',').map(Number);
|
||
setCellInput(s.tableId, c, r, '');
|
||
}
|
||
}
|
||
|
||
function clampTableView(t: TableObject) {
|
||
t.view.frozenRows = Math.min(Math.max(0, t.view.frozenRows), Math.max(0, t.rows - 1));
|
||
t.view.frozenCols = Math.min(Math.max(0, t.view.frozenCols), Math.max(0, t.cols - 1));
|
||
}
|
||
function clampSelection(tableId: string) {
|
||
const s = doc.selection;
|
||
const t = tableById(tableId);
|
||
if (!s || !t || s.tableId !== tableId) return;
|
||
const cl = (c: CellCoord) => ({
|
||
col: Math.min(Math.max(0, c.col), t.cols - 1),
|
||
row: Math.min(Math.max(0, c.row), t.rows - 1),
|
||
});
|
||
doc.selection = {
|
||
tableId,
|
||
anchor: cl(s.anchor),
|
||
focus: cl(s.focus),
|
||
ranges: s.ranges?.map((r) => ({ anchor: cl(r.anchor), focus: cl(r.focus) })),
|
||
};
|
||
}
|
||
|
||
/** Patch a table object's view (sizing mode, viewport size, frozen rows/cols). */
|
||
function setTableView(tableId: string, patch: Partial<TableView>) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
t.view = { ...t.view, ...patch };
|
||
if (patch.width !== undefined) t.view.width = Math.max(200, Math.round(t.view.width));
|
||
if (patch.height !== undefined) t.view.height = Math.max(120, Math.round(t.view.height));
|
||
clampTableView(t);
|
||
}
|
||
|
||
// ---- selection --------------------------------------
|
||
function rect(a: CellCoord, b: CellCoord): Rect {
|
||
return {
|
||
c0: Math.min(a.col, b.col),
|
||
c1: Math.max(a.col, b.col),
|
||
r0: Math.min(a.row, b.row),
|
||
r1: Math.max(a.row, b.row),
|
||
};
|
||
}
|
||
/** Every rectangle of `sel` (primary first), normalised. */
|
||
function selectionRects(sel: SelectionState): Rect[] {
|
||
const out = [rect(sel.anchor, sel.focus)];
|
||
for (const r of sel.ranges ?? []) out.push(rect(r.anchor, r.focus));
|
||
return out;
|
||
}
|
||
/** Deduplicated set of "c,r" keys covered by the current selection. */
|
||
function selectedCellKeys(): Set<string> {
|
||
const s = doc.selection;
|
||
const keys = new Set<string>();
|
||
if (!s) return keys;
|
||
for (const k of selectionRects(s)) {
|
||
for (let r = k.r0; r <= k.r1; r++) for (let c = k.c0; c <= k.c1; c++) keys.add(`${c},${r}`);
|
||
}
|
||
return keys;
|
||
}
|
||
const isOneByOne = (a: CellCoord, b: CellCoord, c: number, r: number) =>
|
||
a.col === c && a.row === r && b.col === c && b.row === r;
|
||
|
||
function setSelection(sel: SelectionState | null) {
|
||
doc.selection = sel;
|
||
if (sel) {
|
||
doc.activeObjectId = sel.tableId;
|
||
const sheetId = sheetIdOfObject(sel.tableId);
|
||
if (sheetId) doc.activeSheetId = sheetId;
|
||
}
|
||
}
|
||
/** Replace the whole selection with one rectangle (or grow the primary one). */
|
||
function selectCell(tableId: string, col: number, row: number, extend = false) {
|
||
const cur = doc.selection;
|
||
const anchor = extend && cur && cur.tableId === tableId ? cur.anchor : { col, row };
|
||
setSelection({ tableId, anchor, focus: { col, row } });
|
||
}
|
||
|
||
/** Ctrl/Cmd-click: add the cell as a new rectangle, or toggle it off. */
|
||
function addToSelection(tableId: string, col: number, row: number) {
|
||
const cur = doc.selection;
|
||
if (!cur || cur.tableId !== tableId) {
|
||
selectCell(tableId, col, row);
|
||
return;
|
||
}
|
||
const ranges = [...(cur.ranges ?? [])];
|
||
const idx = ranges.findIndex((r) => isOneByOne(r.anchor, r.focus, col, row));
|
||
if (idx >= 0) {
|
||
// toggle an extra 1x1 range off
|
||
ranges.splice(idx, 1);
|
||
doc.selection = { ...cur, ranges: ranges.length ? ranges : undefined };
|
||
return;
|
||
}
|
||
if (isOneByOne(cur.anchor, cur.focus, col, row) && ranges.length) {
|
||
// toggle the primary off -> promote the most recent extra range
|
||
const last = ranges.pop()!;
|
||
doc.selection = {
|
||
tableId,
|
||
anchor: last.anchor,
|
||
focus: last.focus,
|
||
ranges: ranges.length ? ranges : undefined,
|
||
};
|
||
return;
|
||
}
|
||
// keep the current primary as an extra range, make the clicked cell primary
|
||
ranges.push({ anchor: cur.anchor, focus: cur.focus });
|
||
setSelection({ tableId, anchor: { col, row }, focus: { col, row }, ranges });
|
||
}
|
||
/** Ctrl/Cmd-drag: grow the just-added (primary) rectangle. */
|
||
function extendActiveRange(tableId: string, col: number, row: number) {
|
||
const cur = doc.selection;
|
||
if (!cur || cur.tableId !== tableId) return;
|
||
doc.selection = { ...cur, focus: { col, row } };
|
||
}
|
||
/** Ctrl/Cmd-click on a column/row header: add that whole strip. */
|
||
function addRangeToSelection(tableId: string, a: CellCoord, b: CellCoord) {
|
||
const cur = doc.selection;
|
||
if (!cur || cur.tableId !== tableId) {
|
||
setSelection({ tableId, anchor: a, focus: b });
|
||
return;
|
||
}
|
||
const ranges = [...(cur.ranges ?? []), { anchor: cur.anchor, focus: cur.focus }];
|
||
setSelection({ tableId, anchor: a, focus: b, ranges });
|
||
}
|
||
|
||
// ---- clipboard (copy / cut / paste) --------------------------
|
||
/** True while a copy/cut buffer is held (enables "Einfügen" in menus). */
|
||
const hasClipboard = computed(() => clipboard.value !== null);
|
||
/** Source rectangle of the current buffer -- drives the dashed source marker
|
||
* and its anchor-cell indicator (which stays put once the live selection
|
||
* has moved on to pick a paste destination). */
|
||
const clipboardRect = computed(() => {
|
||
const cb = clipboard.value;
|
||
return cb ? { tableId: cb.tableId, ...cb.rect, cut: cb.cut, anchor: cb.anchor } : null;
|
||
});
|
||
|
||
function copyOrCut(cut: boolean) {
|
||
const s = doc.selection;
|
||
const t = s ? tableById(s.tableId) : undefined;
|
||
if (!s || !t) return;
|
||
const r = rect(s.anchor, s.focus);
|
||
const cells = new Map<string, ClipboardCell>();
|
||
for (let row = r.r0; row <= r.r1; row++) {
|
||
for (let col = r.c0; col <= r.c1; col++) {
|
||
const raw = cellInput(s.tableId, col, row);
|
||
const format = t.formats?.[fmtKey(col, row)];
|
||
if (raw || format) {
|
||
cells.set(`${col - r.c0},${row - r.r0}`, { raw, format: format ? { ...format } : undefined });
|
||
}
|
||
}
|
||
}
|
||
clipboard.value = {
|
||
tableId: s.tableId,
|
||
rect: r,
|
||
width: r.c1 - r.c0 + 1,
|
||
height: r.r1 - r.r0 + 1,
|
||
cells,
|
||
cut,
|
||
anchor: { ...s.anchor },
|
||
};
|
||
}
|
||
const copySelection = () => copyOrCut(false);
|
||
const cutSelection = () => copyOrCut(true);
|
||
/** Drop the buffer (Escape) without pasting. */
|
||
function cancelClipboard() {
|
||
clipboard.value = null;
|
||
}
|
||
|
||
/**
|
||
* Paste the buffer at the current selection. A selection that is an exact,
|
||
* larger multiple of the buffer tiles it (Numbers/Excel "fill by paste");
|
||
* a smaller or single-cell selection anchors the buffer there and overflows
|
||
* past it, same as pasting a whole block onto one cell.
|
||
*/
|
||
function pasteClipboard() {
|
||
const cb = clipboard.value;
|
||
const s = doc.selection;
|
||
if (!cb || !s || !tableById(s.tableId)) return;
|
||
|
||
const dest = rect(s.anchor, s.focus);
|
||
const destW = dest.c1 - dest.c0 + 1;
|
||
const destH = dest.r1 - dest.r0 + 1;
|
||
const tileX = destW > cb.width && destW % cb.width === 0 ? destW / cb.width : 1;
|
||
const tileY = destH > cb.height && destH % cb.height === 0 ? destH / cb.height : 1;
|
||
|
||
for (let ty = 0; ty < tileY; ty++) {
|
||
for (let tx = 0; tx < tileX; tx++) {
|
||
const baseC = dest.c0 + tx * cb.width;
|
||
const baseR = dest.r0 + ty * cb.height;
|
||
const dCol = baseC - cb.rect.c0;
|
||
const dRow = baseR - cb.rect.r0;
|
||
for (let dr = 0; dr < cb.height; dr++) {
|
||
for (let dc = 0; dc < cb.width; dc++) {
|
||
const c = baseC + dc;
|
||
const r = baseR + dr;
|
||
const cell = cb.cells.get(`${dc},${dr}`);
|
||
const raw = cell?.raw ?? '';
|
||
setCellInput(
|
||
s.tableId,
|
||
c,
|
||
r,
|
||
raw ? shiftFormula(raw, dCol, dRow, cb.cut ? cb.rect : undefined) : raw,
|
||
);
|
||
clearRangeFormat(s.tableId, { col: c, row: r }, { col: c, row: r });
|
||
if (cell?.format) {
|
||
setRangeFormat(
|
||
s.tableId,
|
||
{ col: c, row: r },
|
||
{ col: c, row: r },
|
||
cell.format as Partial<Record<keyof CellFormat, unknown>>,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (cb.cut) {
|
||
// clear the moved-from cells, except any the paste itself just wrote into
|
||
const destC1 = dest.c0 + tileX * cb.width - 1;
|
||
const destR1 = dest.r0 + tileY * cb.height - 1;
|
||
for (let row = cb.rect.r0; row <= cb.rect.r1; row++) {
|
||
for (let col = cb.rect.c0; col <= cb.rect.c1; col++) {
|
||
const overlap =
|
||
cb.tableId === s.tableId &&
|
||
col >= dest.c0 && col <= destC1 && row >= dest.r0 && row <= destR1;
|
||
if (!overlap) {
|
||
setCellInput(cb.tableId, col, row, '');
|
||
clearRangeFormat(cb.tableId, { col, row }, { col, row });
|
||
}
|
||
}
|
||
}
|
||
clipboard.value = null; // a cut buffer is consumed by its first paste
|
||
}
|
||
|
||
setSelection({
|
||
tableId: s.tableId,
|
||
anchor: { col: dest.c0, row: dest.r0 },
|
||
focus: { col: dest.c0 + tileX * cb.width - 1, row: dest.r0 + tileY * cb.height - 1 },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The Excel/Numbers "fill handle": repeat `source`'s cells (values,
|
||
* formulas -- reference-adjusted the same way a copy is -- and formatting)
|
||
* across `target`, a rectangle extending purely up, down, left or right
|
||
* from `source` (never both axes at once -- the caller picks the single
|
||
* dominant drag direction). Repeats cyclically, so a `target` that isn't an
|
||
* exact multiple of `source`'s size still fills its last partial cycle.
|
||
*/
|
||
function fillSelection(tableId: string, source: Rect, target: Rect) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
const srcW = source.c1 - source.c0 + 1;
|
||
const srcH = source.r1 - source.r0 + 1;
|
||
const cells = new Map<string, ClipboardCell>();
|
||
for (let row = source.r0; row <= source.r1; row++) {
|
||
for (let col = source.c0; col <= source.c1; col++) {
|
||
const raw = cellInput(tableId, col, row);
|
||
const format = t.formats?.[fmtKey(col, row)];
|
||
if (raw || format) {
|
||
cells.set(`${col - source.c0},${row - source.r0}`, { raw, format: format ? { ...format } : undefined });
|
||
}
|
||
}
|
||
}
|
||
for (let row = target.r0; row <= target.r1; row++) {
|
||
for (let col = target.c0; col <= target.c1; col++) {
|
||
// which cell of the source block this target cell cycles back to
|
||
const dc = (((col - source.c0) % srcW) + srcW) % srcW;
|
||
const dr = (((row - source.r0) % srcH) + srcH) % srcH;
|
||
const cell = cells.get(`${dc},${dr}`);
|
||
const raw = cell?.raw ?? '';
|
||
const deltaC = col - (source.c0 + dc);
|
||
const deltaR = row - (source.r0 + dr);
|
||
setCellInput(tableId, col, row, raw ? shiftFormula(raw, deltaC, deltaR) : raw);
|
||
clearRangeFormat(tableId, { col, row }, { col, row });
|
||
if (cell?.format) {
|
||
setRangeFormat(tableId, { col, row }, { col, row }, cell.format as Partial<Record<keyof CellFormat, unknown>>);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- formula "point mode" (click/drag a cell to insert its reference) ---
|
||
/** Register (or, with `null`, unregister) the editor a grid click should
|
||
* insert references into. Also drops any in-flight "replace on next
|
||
* click" state from whichever editor was registered before. */
|
||
function registerFormulaEditor(handle: FormulaEditorHandle | null) {
|
||
formulaEditor.value = handle;
|
||
pointSpan = null;
|
||
}
|
||
/** The user typed (as opposed to a click inserting a reference for them):
|
||
* the next click should insert a fresh reference, not replace the last
|
||
* one -- typing anything ends "point mode"'s replace-on-click window,
|
||
* same as Excel (not just `;`, though that's the most common case, since
|
||
* it's the argument separator you'd type right after picking one cell
|
||
* and before picking the next). */
|
||
function noteFormulaTyped() {
|
||
pointSpan = null;
|
||
}
|
||
/** True while the registered editor's buffer is a formula -- point mode
|
||
* (click-to-insert-reference) only applies then. */
|
||
function formulaPointModeActive(): boolean {
|
||
return !!formulaEditor.value && formulaEditor.value.getState().text.startsWith('=');
|
||
}
|
||
/** `'` needs quoting whenever a name isn't a bare identifier (spaces,
|
||
* punctuation, ...) -- shared by the table- and sheet-qualifier cases. */
|
||
function quoteQualifier(name: string): string {
|
||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`;
|
||
}
|
||
function qualifyRef(homeTableId: string, targetTableId: string, body: string): string {
|
||
if (targetTableId === homeTableId) return body;
|
||
const target = tableById(targetTableId);
|
||
const tableQ = quoteQualifier(target?.name ?? targetTableId);
|
||
// a plain "Tabelle!..." is enough as long as it resolves unambiguously --
|
||
// either the name is document-unique, or the target happens to be on the
|
||
// same sheet as the formula being edited (resolveTableName()'s "prefer
|
||
// the home sheet" fallback picks it correctly either way); only a name
|
||
// that collides *and* lives on a different sheet needs the explicit
|
||
// "Blatt!Tabelle::..." 3-part form to disambiguate.
|
||
const homeSheetId = sheetIdOfObject(homeTableId);
|
||
const targetSheetId = sheetIdOfObject(targetTableId);
|
||
const nameCollides = target
|
||
? allTables(doc).filter((t) => t.name.toLowerCase() === target.name.toLowerCase()).length > 1
|
||
: false;
|
||
if (!nameCollides || targetSheetId === homeSheetId) {
|
||
return `${tableQ}!${body}`;
|
||
}
|
||
const sheetName = (targetSheetId && sheetById(targetSheetId)?.name) || '';
|
||
return `${quoteQualifier(sheetName)}!${tableQ}::${body}`;
|
||
}
|
||
function applyPointRef(targetTableId: string, body: string): boolean {
|
||
const ed = formulaEditor.value;
|
||
if (!ed) return false;
|
||
const st = ed.getState();
|
||
if (!st.text.startsWith('=')) return false;
|
||
const text = qualifyRef(ed.tableId, targetTableId, body);
|
||
const span = pointSpan ?? { start: st.start, end: st.end };
|
||
ed.replace(span.start, span.end, text);
|
||
pointSpan = { start: span.start, end: span.start + text.length };
|
||
return true;
|
||
}
|
||
/** A plain click on (tableId, col, row) while editing a formula: insert or
|
||
* (immediately after another click/drag, before any typing) replace a
|
||
* reference at the caret. Returns false when there's no formula editor
|
||
* active, so the caller can fall back to its normal click handling. */
|
||
function pointClick(tableId: string, col: number, row: number): boolean {
|
||
return applyPointRef(tableId, toA1(col, row));
|
||
}
|
||
/** A drag from (c0,r0) to (c1,r1) on tableId while editing a formula:
|
||
* insert/replace a *range* reference, meant to be called live as the drag
|
||
* updates (each call replaces the same span, growing/shrinking the range
|
||
* as the user drags), not just once at the end. */
|
||
function pointDrag(tableId: string, c0: number, r0: number, c1: number, r1: number): boolean {
|
||
const a = toA1(Math.min(c0, c1), Math.min(r0, r1));
|
||
const b = toA1(Math.max(c0, c1), Math.max(r0, r1));
|
||
return applyPointRef(tableId, a === b ? a : `${a}:${b}`);
|
||
}
|
||
|
||
// The "active cell" is always the anchor -- the first cell of the current
|
||
// selection action (click, shift-click, drag start, ...) -- so it stays put
|
||
// while a multi-cell selection is being made, matching Excel/Numbers.
|
||
const activeCell = computed<CellCoord>(() => doc.selection?.anchor ?? { col: 0, row: 0 });
|
||
|
||
const activeCellName = computed(() => {
|
||
const sel = doc.selection;
|
||
const t = sel ? tableById(sel.tableId) : activeTable.value;
|
||
const cell = sel?.anchor ?? { col: 0, row: 0 };
|
||
return `${t?.name ?? '—'}::${toA1(cell.col, cell.row)}`;
|
||
});
|
||
|
||
/** Like {@link activeCellName}, but for a multi-cell selection it shows the
|
||
* full range (`Umsatz::B2:E6`) instead of just the anchor -- used wherever
|
||
* the UI shows "where am I" (formula bar, status bar), as opposed to
|
||
* `activeCellName`/`activeCell`, which stay anchor-only because that's
|
||
* what a formula/edit actually targets (see the "selection aggregate &
|
||
* cell name are live" test). A discontiguous multi-selection additionally
|
||
* gets a `+N` suffix for the further rectangles beyond the primary one. */
|
||
const selectionRangeName = computed(() => {
|
||
const sel = doc.selection;
|
||
const t = sel ? tableById(sel.tableId) : activeTable.value;
|
||
const tableName = t?.name ?? '—';
|
||
if (!sel) return `${tableName}::${toA1(0, 0)}`;
|
||
const rects = selectionRects(sel);
|
||
const [p] = rects;
|
||
const addr =
|
||
p.c0 === p.c1 && p.r0 === p.r1
|
||
? toA1(p.c0, p.r0)
|
||
: `${toA1(p.c0, p.r0)}:${toA1(p.c1, p.r1)}`;
|
||
const extra = rects.length > 1 ? ` +${rects.length - 1}` : '';
|
||
return `${tableName}::${addr}${extra}`;
|
||
});
|
||
|
||
const activeCellInput = computed(() => {
|
||
const sel = doc.selection;
|
||
return sel ? cellInput(sel.tableId, sel.anchor.col, sel.anchor.row) : '';
|
||
});
|
||
|
||
const selectionAggregate = computed(() => {
|
||
void recalcTick.value;
|
||
const sel = doc.selection;
|
||
if (!sel) return { sum: 0, avg: 0, count: 0, cells: 0 };
|
||
let sum = 0;
|
||
let count = 0;
|
||
let cells = 0;
|
||
for (const key of selectedCellKeys()) {
|
||
cells++;
|
||
const [c, r] = key.split(',').map(Number);
|
||
const v = wb.cellValue(sel.tableId, c, r);
|
||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||
sum += v;
|
||
count++;
|
||
}
|
||
}
|
||
return { sum, avg: count ? sum / count : 0, count, cells };
|
||
});
|
||
|
||
// ---- document-level ops ---------------------------
|
||
/** The actual content swap, shared by replaceDocument() and undo()/redo() --
|
||
* the latter must *not* also reset history (that's the whole stack it's
|
||
* currently unwinding), so they call this directly instead. */
|
||
function applyDocumentState(next: DocumentState) {
|
||
doc.version = 2;
|
||
doc.sheets = next.sheets;
|
||
doc.activeSheetId =
|
||
next.sheets.find((s) => s.id === next.activeSheetId)?.id ?? next.sheets[0].id;
|
||
doc.activeObjectId = next.activeObjectId ?? null;
|
||
doc.selection = next.selection ?? null;
|
||
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
|
||
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
||
doc.customFunctions = next.customFunctions ?? [];
|
||
syncWorkbook();
|
||
}
|
||
/** Loading a different document outright (JSON import "replace", "reset to
|
||
* seed") -- unlike undo/redo, this starts a fresh undo history rather
|
||
* than leaving the previous document's edits undoable into the new one. */
|
||
function replaceDocument(next: DocumentState) {
|
||
applyDocumentState(next);
|
||
resetHistory();
|
||
}
|
||
function resetToSeed() {
|
||
replaceDocument(createSeedDocument());
|
||
}
|
||
/** Append fully-formed sheets (JSON-append / XLSX import). */
|
||
function appendSheets(sheets: Sheet[]) {
|
||
for (const s of sheets) {
|
||
let id = s.id;
|
||
while (doc.sheets.some((o) => o.id === id)) id = uid('sheet');
|
||
const sn = new Set(doc.sheets.map((x) => x.name.toLowerCase()));
|
||
doc.sheets.push({ ...s, id, name: uniqueName(s.name, sn) });
|
||
doc.activeSheetId = id;
|
||
}
|
||
syncWorkbook();
|
||
}
|
||
/** Add a new table object built from a cell map (CSV "as new table"). */
|
||
function addTableFromCells(
|
||
sheetId: string,
|
||
name: string,
|
||
cells: Record<string, string>,
|
||
rows: number,
|
||
cols: number,
|
||
): string {
|
||
const id = addTable(sheetId);
|
||
const t = tableById(id)!;
|
||
t.cells = { ...cells };
|
||
t.rows = Math.max(rows, 1);
|
||
t.cols = Math.max(cols, 1);
|
||
renameObject(id, name); // also syncs the workbook
|
||
return id;
|
||
}
|
||
/** Replace an existing table's whole cell map (CSV "replace active table"). */
|
||
function replaceTableCells(
|
||
tableId: string,
|
||
cells: Record<string, string>,
|
||
rows: number,
|
||
cols: number,
|
||
) {
|
||
const t = tableById(tableId);
|
||
if (!t) return;
|
||
t.cells = { ...cells };
|
||
t.rows = Math.max(rows, 1);
|
||
t.cols = Math.max(cols, 1);
|
||
t.colWidths = {};
|
||
syncWorkbook();
|
||
}
|
||
function snapshot(): DocumentState {
|
||
return JSON.parse(JSON.stringify(doc)) as DocumentState;
|
||
}
|
||
|
||
return {
|
||
doc,
|
||
recalcTick,
|
||
// undo / redo
|
||
undo,
|
||
redo,
|
||
canUndo,
|
||
canRedo,
|
||
// sheets
|
||
sheetById,
|
||
activeSheet,
|
||
setActiveSheet,
|
||
addSheet,
|
||
renameSheet,
|
||
deleteSheet,
|
||
moveSheet,
|
||
// objects
|
||
objectById,
|
||
sheetIdOfObject,
|
||
tablesOf,
|
||
activeObject,
|
||
activeTable,
|
||
addTable,
|
||
addChart,
|
||
addText,
|
||
setTextContent,
|
||
setTextFormat,
|
||
resizeText,
|
||
renameObject,
|
||
deleteObject,
|
||
moveObject,
|
||
// custom functions
|
||
customFunctionNames,
|
||
addCustomFunction,
|
||
updateCustomFunction,
|
||
deleteCustomFunction,
|
||
resizeChart,
|
||
bringToFront,
|
||
setChartView,
|
||
setChartSource,
|
||
selectObject,
|
||
setObjectBg,
|
||
setObjectTextColor,
|
||
// table grid
|
||
tableById,
|
||
cellInput,
|
||
cellValue,
|
||
cellDisplay,
|
||
cellIsNumeric,
|
||
cellIsError,
|
||
cellIsFormula,
|
||
setCellInput,
|
||
clearRange,
|
||
addRows,
|
||
addColumns,
|
||
removeLastRow,
|
||
removeLastColumn,
|
||
setColWidth,
|
||
scaleTableColumns,
|
||
setRowHeight,
|
||
setTableRowHeight,
|
||
rowHeightOf,
|
||
setTableRows,
|
||
setTableCols,
|
||
setTableView,
|
||
// cell formatting
|
||
cellFormat,
|
||
setRangeFormat,
|
||
clearRangeFormat,
|
||
selectionFormat,
|
||
applySelectionFormat,
|
||
clearSelectionFormat,
|
||
clearSelectionCells,
|
||
// number-format locale
|
||
effectiveLocale,
|
||
activeLocale,
|
||
setDocumentLocale,
|
||
setSheetLocale,
|
||
setObjectLocale,
|
||
// per-sheet appearance + zoom + csv default
|
||
setSheetBackground,
|
||
setSheetForeground,
|
||
setSheetShowGrid,
|
||
setSheetZoom,
|
||
activeZoom,
|
||
setZoom,
|
||
setCsvDelimiter,
|
||
// view-only ui
|
||
ui,
|
||
// selection
|
||
setSelection,
|
||
selectCell,
|
||
addToSelection,
|
||
extendActiveRange,
|
||
addRangeToSelection,
|
||
selectionRects,
|
||
activeCell,
|
||
activeCellName,
|
||
selectionRangeName,
|
||
activeCellInput,
|
||
selectionAggregate,
|
||
// clipboard
|
||
copySelection,
|
||
cutSelection,
|
||
pasteClipboard,
|
||
cancelClipboard,
|
||
hasClipboard,
|
||
clipboardRect,
|
||
fillSelection,
|
||
// formula "point mode"
|
||
registerFormulaEditor,
|
||
noteFormulaTyped,
|
||
formulaPointModeActive,
|
||
pointClick,
|
||
pointDrag,
|
||
// document
|
||
replaceDocument,
|
||
resetToSeed,
|
||
appendSheets,
|
||
addTableFromCells,
|
||
replaceTableCells,
|
||
snapshot,
|
||
engine: () => wb,
|
||
};
|
||
}
|
||
|
||
let instance: ReturnType<typeof createStore> | null = null;
|
||
|
||
export function useDocument() {
|
||
if (!instance) instance = createStore();
|
||
return instance;
|
||
}
|
||
|
||
export type DocumentStore = ReturnType<typeof createStore>;
|