- store: copySelection/cutSelection/pasteClipboard/cancelClipboard hold an in-memory (unpersisted) clipboard buffer of raw cell inputs + formatting. - shiftFormula rewrites every relative cell reference in a copied/cut formula by the move delta (the engine has no $-absolute refs, so all refs qualify). Copy shifts every reference uniformly; cut only follows references that point inside the moved block itself, leaving refs to untouched cells alone and never touching a qualified Table!A1 reference. - paste anchors on a smaller/single-cell selection (and overflows past it), or tiles across a selection that's an exact larger multiple of the buffer. - TableGrid: Strg+C/X/V shortcuts, context-menu entries, a dashed "clip-src" source marker, and Escape drops a pending buffer. - document.spec.ts: coverage for reference shifting, tiled/fill paste, cut's inside/outside reference split, format copy, and Escape cancelling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1206 lines
40 KiB
TypeScript
1206 lines
40 KiB
TypeScript
/**
|
|
* Reactive document store (Numbers-style canvas model).
|
|
*
|
|
* This is the "own reactive() module" the brief allows instead of Pinia. It
|
|
* owns the persisted {@link DocumentState} v2 and a single {@link Workbook}
|
|
* engine instance, and keeps them in sync:
|
|
*
|
|
* - cell edits go straight into the engine (`wb.setCell`) for an incremental
|
|
* recalculation;
|
|
* - structural edits (add/rename/delete a table, grid growth, ...) rebuild the
|
|
* engine from the document;
|
|
* - `recalcTick` is bumped after every recalculation. UI code reads
|
|
* `store.cellValue(...)`, which touches `recalcTick`, so every formula cell
|
|
* re-renders exactly like a `computed` would.
|
|
*
|
|
* The engine is table-centric: a table object's `name` is a document-unique
|
|
* qualifier, so `Umsatz!B2` resolves the same from any sheet.
|
|
*/
|
|
|
|
import { computed, reactive, ref, watch } from 'vue';
|
|
import {
|
|
Workbook,
|
|
toA1,
|
|
isError,
|
|
tokenize,
|
|
looksLikeCell,
|
|
parseA1,
|
|
type CellValue,
|
|
type TableModel,
|
|
type Token,
|
|
} from '../engine';
|
|
import {
|
|
formatValue,
|
|
isNumeric,
|
|
isNumberLocale,
|
|
DEFAULT_LOCALE,
|
|
type NumberLocale,
|
|
} from './format';
|
|
import type {
|
|
CellCoord,
|
|
CellFormat,
|
|
ChartObject,
|
|
DocumentState,
|
|
Sheet,
|
|
SelectionState,
|
|
SheetObject,
|
|
TableObject,
|
|
TableView,
|
|
TextObject,
|
|
} from './types';
|
|
import { defaultTableView, DEFAULT_ROW_H } from './types';
|
|
import { createSeedDocument } from '../data/seed';
|
|
import { validateDocument } from '../io/json';
|
|
|
|
const STORAGE_KEY = 'numbers-study:doc:v2';
|
|
|
|
const CASCADE = 28; // px offset for successive inserted objects
|
|
|
|
// ---- (de)serialisation helpers ------------------------------------------
|
|
|
|
function allTables(doc: DocumentState): TableObject[] {
|
|
const out: TableObject[] = [];
|
|
for (const s of doc.sheets) {
|
|
for (const o of s.objects) if (o.kind === 'table') out.push(o);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function toTableModels(doc: DocumentState): TableModel[] {
|
|
return allTables(doc).map((t) => ({
|
|
id: t.id,
|
|
name: t.name,
|
|
cols: t.cols,
|
|
rows: t.rows,
|
|
inputs: new Map(Object.entries(t.cells)),
|
|
}));
|
|
}
|
|
|
|
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
|
|
type Rect = { c0: number; c1: number; r0: number; r1: number };
|
|
|
|
/** One cell of a copy/cut buffer, relative to the buffer's top-left corner. */
|
|
interface ClipboardCell {
|
|
raw: string;
|
|
format?: CellFormat;
|
|
}
|
|
interface ClipboardData {
|
|
tableId: string;
|
|
rect: Rect;
|
|
width: number;
|
|
height: number;
|
|
/** "dc,dr" (offset from the buffer's top-left) -> cell. Empty/unformatted cells are absent. */
|
|
cells: Map<string, ClipboardCell>;
|
|
/** cut (Strg+X) vs. copy (Strg+C) -- a cut buffer is consumed by its first paste. */
|
|
cut: boolean;
|
|
}
|
|
|
|
/**
|
|
* Rewrite every cell reference in a formula by (dCol, dRow) -- the same
|
|
* relative-reference adjustment Excel/Numbers apply on copy.
|
|
*
|
|
* `restrict`, when given (a cut/move), narrows this 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).
|
|
*
|
|
* The formula engine has no absolute (`$A$1`) references, so every local
|
|
* reference is relative and eligible for this adjustment.
|
|
*/
|
|
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 } = parseA1(tok.value);
|
|
if (
|
|
restrict &&
|
|
!qualified &&
|
|
!(col >= restrict.c0 && col <= restrict.c1 && row >= restrict.r0 && row <= restrict.r1)
|
|
) {
|
|
continue;
|
|
}
|
|
const nc = Math.max(0, col + dCol);
|
|
const nr = Math.max(0, row + dRow);
|
|
out += body.slice(last, tok.pos) + toA1(nc, nr);
|
|
last = tok.pos + tok.value.length;
|
|
}
|
|
out += body.slice(last);
|
|
return '=' + out;
|
|
}
|
|
|
|
function loadInitial(): DocumentState {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (raw) {
|
|
// validateDocument migrates v1, validates + *normalises* v2 (fills in
|
|
// `view` and other defaults for documents saved by an older build).
|
|
return validateDocument(JSON.parse(raw));
|
|
}
|
|
} catch {
|
|
/* missing / corrupt / stale-schema -> clean fallback */
|
|
}
|
|
return createSeedDocument();
|
|
}
|
|
|
|
// ---- store singleton --------------------------------------------------
|
|
|
|
function createStore() {
|
|
const doc = reactive<DocumentState>(loadInitial());
|
|
let wb = new Workbook(toTableModels(doc));
|
|
const recalcTick = ref(0);
|
|
const bump = () => (recalcTick.value += 1);
|
|
|
|
/** View-only state: not part of the document, not persisted. */
|
|
const ui = reactive({ settingsOpen: false });
|
|
/** Copy/cut buffer -- not part of the persisted document. */
|
|
const clipboard = ref<ClipboardData | null>(null);
|
|
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
|
|
|
|
function syncWorkbook() {
|
|
wb = new Workbook(toTableModels(doc));
|
|
bump();
|
|
}
|
|
|
|
// ---- persistence (debounced) --------------------------------------
|
|
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
|
watch(
|
|
() => doc,
|
|
() => {
|
|
clearTimeout(persistTimer);
|
|
persistTimer = setTimeout(() => {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(doc));
|
|
} catch {
|
|
/* quota / privacy mode -- ignore */
|
|
}
|
|
}, 250);
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
// ---- id / name helpers ------------------------------------------
|
|
let seq = 0;
|
|
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}`;
|
|
|
|
function uniqueName(base: string, taken: Set<string>): string {
|
|
const b = base.trim() || 'Element';
|
|
if (!taken.has(b.toLowerCase())) return b;
|
|
let i = 2;
|
|
while (taken.has(`${b} ${i}`.toLowerCase())) i++;
|
|
return `${b} ${i}`;
|
|
}
|
|
const tableNames = () => new Set(allTables(doc).map((t) => t.name.toLowerCase()));
|
|
const objectNames = () =>
|
|
new Set(doc.sheets.flatMap((s) => s.objects).map((o) => o.name.toLowerCase()));
|
|
const sheetNames = () => new Set(doc.sheets.map((s) => s.name.toLowerCase()));
|
|
|
|
// ---- lookups --------------------------------------------------
|
|
const sheetById = (id: string): Sheet | undefined => doc.sheets.find((s) => s.id === id);
|
|
const activeSheet = computed(() => sheetById(doc.activeSheetId) ?? doc.sheets[0]);
|
|
|
|
function objectById(id: string | null): SheetObject | undefined {
|
|
if (!id) return undefined;
|
|
for (const s of doc.sheets) {
|
|
const o = s.objects.find((x) => x.id === id);
|
|
if (o) return o;
|
|
}
|
|
return undefined;
|
|
}
|
|
function sheetIdOfObject(id: string): string | undefined {
|
|
return doc.sheets.find((s) => s.objects.some((o) => o.id === id))?.id;
|
|
}
|
|
const tableById = (id: string): TableObject | undefined => {
|
|
const o = objectById(id);
|
|
return o && o.kind === 'table' ? o : undefined;
|
|
};
|
|
const tablesOf = (sheetId: string): TableObject[] =>
|
|
(sheetById(sheetId)?.objects.filter((o): o is TableObject => o.kind === 'table') ?? []);
|
|
|
|
const activeObject = computed(() => objectById(doc.activeObjectId));
|
|
const activeTable = computed<TableObject | undefined>(() => {
|
|
if (doc.selection) return tableById(doc.selection.tableId);
|
|
return tablesOf(doc.activeSheetId)[0];
|
|
});
|
|
|
|
// ---- sheet structure -----------------------------------------
|
|
function setActiveSheet(id: string) {
|
|
if (!sheetById(id) || id === doc.activeSheetId) return;
|
|
doc.activeSheetId = id;
|
|
doc.activeObjectId = null;
|
|
const first = tablesOf(id)[0];
|
|
doc.selection = first
|
|
? { tableId: first.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } }
|
|
: null;
|
|
}
|
|
|
|
function addSheet(name?: string): string {
|
|
const id = uid('sheet');
|
|
doc.sheets.push({ id, name: uniqueName(name || 'Blatt', sheetNames()), objects: [] });
|
|
setActiveSheet(id);
|
|
return id;
|
|
}
|
|
function renameSheet(id: string, name: string) {
|
|
const s = sheetById(id);
|
|
const clean = name.trim();
|
|
if (!s || !clean) return;
|
|
const others = new Set(doc.sheets.filter((o) => o.id !== id).map((o) => o.name.toLowerCase()));
|
|
s.name = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean;
|
|
}
|
|
function deleteSheet(id: string) {
|
|
if (doc.sheets.length <= 1) return;
|
|
const idx = doc.sheets.findIndex((s) => s.id === id);
|
|
if (idx < 0) return;
|
|
const hadTables = tablesOf(id).length > 0;
|
|
doc.sheets.splice(idx, 1);
|
|
if (doc.activeSheetId === id) setActiveSheet(doc.sheets[Math.max(0, idx - 1)].id);
|
|
if (hadTables) syncWorkbook(); // dependents elsewhere become #REF
|
|
}
|
|
function moveSheet(from: number, to: number) {
|
|
if (from < 0 || to < 0 || from >= doc.sheets.length || to >= doc.sheets.length || from === to)
|
|
return;
|
|
const [s] = doc.sheets.splice(from, 1);
|
|
doc.sheets.splice(to, 0, s);
|
|
}
|
|
|
|
// ---- per-sheet appearance + zoom -----------------------------
|
|
function setSheetBackground(sheetId: string, color: string | null) {
|
|
const s = sheetById(sheetId);
|
|
if (!s) return;
|
|
if (color) s.background = color;
|
|
else delete s.background;
|
|
}
|
|
function setSheetForeground(sheetId: string, color: string | null) {
|
|
const s = sheetById(sheetId);
|
|
if (!s) return;
|
|
if (color) s.foreground = color;
|
|
else delete s.foreground;
|
|
}
|
|
function setSheetShowGrid(sheetId: string, show: boolean) {
|
|
const s = sheetById(sheetId);
|
|
if (s) s.showGrid = show;
|
|
}
|
|
function setSheetZoom(sheetId: string, z: number) {
|
|
const s = sheetById(sheetId);
|
|
if (s) s.zoom = clampZoom(z);
|
|
}
|
|
/** Zoom of the currently active sheet (used by the canvas + zoom bar). */
|
|
const activeZoom = computed(() => activeSheet.value.zoom ?? 1);
|
|
const setZoom = (z: number) => setSheetZoom(doc.activeSheetId, z);
|
|
|
|
// ---- per-object background + text colour (table / chart / text) ----
|
|
function setObjectBg(objId: string, color: string | null) {
|
|
const o = objectById(objId);
|
|
if (!o) return;
|
|
if (color) o.bg = color;
|
|
else delete o.bg;
|
|
if (o.kind !== 'table') bump();
|
|
}
|
|
function setObjectTextColor(objId: string, color: string | null) {
|
|
const o = objectById(objId);
|
|
if (!o) return;
|
|
if (o.kind === 'text') {
|
|
if (color) o.color = color;
|
|
else delete o.color;
|
|
bump();
|
|
} else {
|
|
if (color) o.fg = color;
|
|
else delete o.fg;
|
|
if (o.kind === 'chart') bump();
|
|
}
|
|
}
|
|
|
|
function setCsvDelimiter(d: ',' | ';') {
|
|
doc.csvDelimiter = d === ';' ? ';' : ',';
|
|
}
|
|
|
|
// ---- canvas objects ----------------------------------------
|
|
function nextCascade(sheet: Sheet): { x: number; y: number } {
|
|
const n = sheet.objects.length;
|
|
return { x: 40 + (n % 6) * CASCADE, y: 40 + (n % 6) * CASCADE };
|
|
}
|
|
|
|
function addTable(sheetId: string, pos?: { x: number; y: number }): string {
|
|
const sheet = sheetById(sheetId);
|
|
if (!sheet) return '';
|
|
const id = uid('tbl');
|
|
const p = pos ?? nextCascade(sheet);
|
|
const table: TableObject = {
|
|
kind: 'table',
|
|
id,
|
|
name: uniqueName('Tabelle', new Set([...tableNames(), ...objectNames()])),
|
|
x: Math.max(0, Math.round(p.x)),
|
|
y: Math.max(0, Math.round(p.y)),
|
|
rows: 12,
|
|
cols: 6,
|
|
colWidths: {},
|
|
cells: {},
|
|
view: defaultTableView(),
|
|
};
|
|
sheet.objects.push(table);
|
|
doc.activeObjectId = id;
|
|
doc.activeSheetId = sheetId;
|
|
doc.selection = { tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
|
|
syncWorkbook();
|
|
return id;
|
|
}
|
|
|
|
function addChart(
|
|
sheetId: string,
|
|
pos?: { x: number; y: number },
|
|
sourceTableId?: string | null,
|
|
): string {
|
|
const sheet = sheetById(sheetId);
|
|
if (!sheet) return '';
|
|
const id = uid('cht');
|
|
const p = pos ?? nextCascade(sheet);
|
|
const src = sourceTableId ?? tablesOf(sheetId)[0]?.id ?? allTables(doc)[0]?.id ?? null;
|
|
const chart: ChartObject = {
|
|
kind: 'chart',
|
|
id,
|
|
name: uniqueName('Diagramm', objectNames()),
|
|
x: Math.max(0, Math.round(p.x)),
|
|
y: Math.max(0, Math.round(p.y)),
|
|
width: 460,
|
|
height: 300,
|
|
view: 'bar',
|
|
sourceTableId: src,
|
|
};
|
|
sheet.objects.push(chart);
|
|
doc.activeObjectId = id;
|
|
doc.activeSheetId = sheetId;
|
|
bump();
|
|
return id;
|
|
}
|
|
|
|
function addText(sheetId: string, pos?: { x: number; y: number }): string {
|
|
const sheet = sheetById(sheetId);
|
|
if (!sheet) return '';
|
|
const id = uid('txt');
|
|
const p = pos ?? nextCascade(sheet);
|
|
const text: TextObject = {
|
|
kind: 'text',
|
|
id,
|
|
name: uniqueName('Text', objectNames()),
|
|
x: Math.max(0, Math.round(p.x)),
|
|
y: Math.max(0, Math.round(p.y)),
|
|
width: 260,
|
|
height: 96,
|
|
text: 'Text …',
|
|
fontSize: 15,
|
|
align: 'left',
|
|
};
|
|
sheet.objects.push(text);
|
|
doc.activeObjectId = id;
|
|
doc.activeSheetId = sheetId;
|
|
bump();
|
|
return id;
|
|
}
|
|
function setTextContent(objId: string, text: string) {
|
|
const o = objectById(objId);
|
|
if (o?.kind === 'text') o.text = text;
|
|
}
|
|
function setTextFormat(objId: string, patch: Partial<Omit<TextObject, 'kind' | 'id'>>) {
|
|
const o = objectById(objId);
|
|
if (o?.kind !== 'text') return;
|
|
if (patch.fontSize !== undefined) o.fontSize = Math.min(96, Math.max(9, Math.round(patch.fontSize)));
|
|
if (patch.align !== undefined) o.align = patch.align;
|
|
if (patch.bold !== undefined) o.bold = patch.bold || undefined;
|
|
if (patch.italic !== undefined) o.italic = patch.italic || undefined;
|
|
if ('color' in patch) o.color = patch.color || undefined;
|
|
if ('bg' in patch) o.bg = patch.bg || undefined;
|
|
}
|
|
function resizeText(objId: string, width: number, height: number) {
|
|
const o = objectById(objId);
|
|
if (o?.kind !== 'text') return;
|
|
o.width = Math.max(80, Math.round(width));
|
|
o.height = Math.max(32, Math.round(height));
|
|
}
|
|
|
|
function renameObject(objId: string, name: string) {
|
|
const o = objectById(objId);
|
|
const clean = name.trim();
|
|
if (!o || !clean) return;
|
|
if (o.kind === 'table') {
|
|
const others = new Set(
|
|
allTables(doc).filter((t) => t.id !== objId).map((t) => t.name.toLowerCase()),
|
|
);
|
|
o.name = others.has(clean.toLowerCase()) ? uniqueName(clean, others) : clean;
|
|
syncWorkbook(); // formulas referencing the old/new name must re-resolve
|
|
} else {
|
|
o.name = clean;
|
|
}
|
|
}
|
|
|
|
function deleteObject(objId: string) {
|
|
const sheetId = sheetIdOfObject(objId);
|
|
const sheet = sheetId ? sheetById(sheetId) : undefined;
|
|
if (!sheet) return;
|
|
const o = sheet.objects.find((x) => x.id === objId);
|
|
const isTable = o?.kind === 'table';
|
|
sheet.objects = sheet.objects.filter((x) => x.id !== objId);
|
|
if (doc.activeObjectId === objId) doc.activeObjectId = null;
|
|
if (doc.selection?.tableId === objId) doc.selection = null;
|
|
// charts bound to a deleted table keep a dangling id -> they render empty
|
|
if (isTable) syncWorkbook();
|
|
else bump();
|
|
}
|
|
|
|
function moveObject(objId: string, x: number, y: number) {
|
|
const o = objectById(objId);
|
|
if (!o) return;
|
|
o.x = Math.max(0, Math.round(x));
|
|
o.y = Math.max(0, Math.round(y));
|
|
}
|
|
function resizeChart(objId: string, width: number, height: number) {
|
|
const o = objectById(objId);
|
|
if (!o || o.kind !== 'chart') return;
|
|
o.width = Math.max(200, Math.round(width));
|
|
o.height = Math.max(140, Math.round(height));
|
|
}
|
|
function bringToFront(objId: string) {
|
|
const sheetId = sheetIdOfObject(objId);
|
|
const sheet = sheetId ? sheetById(sheetId) : undefined;
|
|
if (!sheet) return;
|
|
const idx = sheet.objects.findIndex((o) => o.id === objId);
|
|
if (idx < 0 || idx === sheet.objects.length - 1) return;
|
|
const [o] = sheet.objects.splice(idx, 1);
|
|
sheet.objects.push(o);
|
|
}
|
|
function setChartView(objId: string, view: 'bar' | 'line') {
|
|
const o = objectById(objId);
|
|
if (o?.kind === 'chart') o.view = view;
|
|
}
|
|
function setChartSource(objId: string, tableId: string) {
|
|
const o = objectById(objId);
|
|
if (o?.kind === 'chart') o.sourceTableId = tableId;
|
|
}
|
|
function selectObject(objId: string | null) {
|
|
doc.activeObjectId = objId;
|
|
if (!objId) return;
|
|
const sheetId = sheetIdOfObject(objId);
|
|
if (sheetId) doc.activeSheetId = sheetId;
|
|
const o = objectById(objId);
|
|
if (o?.kind === 'table' && doc.selection?.tableId !== objId) {
|
|
doc.selection = { tableId: objId, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } };
|
|
}
|
|
}
|
|
|
|
// ---- cell reads (reactive via recalcTick) -----------------
|
|
function cellInput(tableId: string, col: number, row: number): string {
|
|
return tableById(tableId)?.cells[`${col},${row}`] ?? '';
|
|
}
|
|
function cellValue(tableId: string, col: number, row: number): CellValue {
|
|
void recalcTick.value;
|
|
return wb.cellValue(tableId, col, row);
|
|
}
|
|
function cellDisplay(tableId: string, col: number, row: number): string {
|
|
return formatValue(
|
|
cellValue(tableId, col, row),
|
|
effectiveLocale(tableId),
|
|
tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto',
|
|
);
|
|
}
|
|
|
|
// ---- number-format locale (object -> sheet -> document cascade) ---
|
|
/** Effective locale for a canvas object (table or chart). */
|
|
function effectiveLocale(objId: string | null): NumberLocale {
|
|
const o = objId ? objectById(objId) : undefined;
|
|
if (o && o.kind !== 'text' && o.locale && isNumberLocale(o.locale)) return o.locale;
|
|
const sheetId = objId ? sheetIdOfObject(objId) : doc.activeSheetId;
|
|
const sheetLoc = sheetId ? sheetById(sheetId)?.locale : undefined;
|
|
if (sheetLoc && isNumberLocale(sheetLoc)) return sheetLoc;
|
|
return isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE;
|
|
}
|
|
const activeLocale = computed(() => effectiveLocale(activeTable.value?.id ?? null));
|
|
|
|
function setDocumentLocale(l: string) {
|
|
if (isNumberLocale(l)) doc.locale = l;
|
|
}
|
|
function setSheetLocale(sheetId: string, l: string | null) {
|
|
const s = sheetById(sheetId);
|
|
if (!s) return;
|
|
if (l && isNumberLocale(l)) s.locale = l;
|
|
else delete s.locale;
|
|
}
|
|
function setObjectLocale(objId: string, l: string | null) {
|
|
const o = objectById(objId);
|
|
if (!o || o.kind === 'text') return;
|
|
if (l && isNumberLocale(l)) o.locale = l;
|
|
else delete o.locale;
|
|
}
|
|
const cellIsNumeric = (t: string, c: number, r: number) => isNumeric(cellValue(t, c, r));
|
|
const cellIsError = (t: string, c: number, r: number) => isError(cellValue(t, c, r));
|
|
const cellIsFormula = (t: string, c: number, r: number) => cellInput(t, c, r).startsWith('=');
|
|
|
|
// ---- cell writes --------------------------------------
|
|
function setCellInput(tableId: string, col: number, row: number, raw: string) {
|
|
const table = tableById(tableId);
|
|
if (!table) return;
|
|
const key = `${col},${row}`;
|
|
const trimmed = raw.replace(/\s+$/, '');
|
|
if (trimmed === '') delete table.cells[key];
|
|
else table.cells[key] = trimmed;
|
|
if (col >= table.cols) table.cols = col + 1;
|
|
if (row >= table.rows) table.rows = row + 1;
|
|
wb.setCell(tableId, col, row, trimmed);
|
|
bump();
|
|
}
|
|
function clearRange(tableId: string, a: CellCoord, b: CellCoord) {
|
|
const c0 = Math.min(a.col, b.col);
|
|
const c1 = Math.max(a.col, b.col);
|
|
const r0 = Math.min(a.row, b.row);
|
|
const r1 = Math.max(a.row, b.row);
|
|
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) setCellInput(tableId, c, r, '');
|
|
}
|
|
|
|
// ---- table grid growth -----------------------------
|
|
const addRows = (tableId: string, n = 1) => {
|
|
const t = tableById(tableId);
|
|
if (t) t.rows += n;
|
|
};
|
|
const addColumns = (tableId: string, n = 1) => {
|
|
const t = tableById(tableId);
|
|
if (t) t.cols += n;
|
|
};
|
|
function removeLastRow(tableId: string) {
|
|
const t = tableById(tableId);
|
|
if (!t || t.rows <= 1) return;
|
|
const r = t.rows - 1;
|
|
for (let c = 0; c < t.cols; c++) delete t.cells[`${c},${r}`];
|
|
t.rows -= 1;
|
|
syncWorkbook();
|
|
}
|
|
function removeLastColumn(tableId: string) {
|
|
const t = tableById(tableId);
|
|
if (!t || t.cols <= 1) return;
|
|
const c = t.cols - 1;
|
|
for (let r = 0; r < t.rows; r++) delete t.cells[`${c},${r}`];
|
|
delete t.colWidths[c];
|
|
t.cols -= 1;
|
|
syncWorkbook();
|
|
}
|
|
const MIN_COL_W = 24;
|
|
const MIN_ROW_H = 14;
|
|
|
|
function setColWidth(tableId: string, col: number, width: number) {
|
|
const t = tableById(tableId);
|
|
if (t) t.colWidths[col] = Math.max(MIN_COL_W, Math.round(width));
|
|
}
|
|
/** Numbers-style proportional resize: scale every column width by `factor`. */
|
|
function scaleTableColumns(tableId: string, factor: number, base: number) {
|
|
const t = tableById(tableId);
|
|
if (!t) return;
|
|
for (let c = 0; c < t.cols; c++) {
|
|
const cur = t.colWidths[c] ?? base;
|
|
t.colWidths[c] = Math.max(MIN_COL_W, Math.round(cur * factor));
|
|
}
|
|
}
|
|
/** Height of one row (per-row override, else the table default). */
|
|
function rowHeightOf(tableId: string, row: number): number {
|
|
const t = tableById(tableId);
|
|
return t?.rowHeights?.[row] ?? t?.rowHeight ?? DEFAULT_ROW_H;
|
|
}
|
|
function setRowHeight(tableId: string, row: number, height: number) {
|
|
const t = tableById(tableId);
|
|
if (!t) return;
|
|
if (!t.rowHeights) t.rowHeights = {};
|
|
t.rowHeights[row] = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
|
|
}
|
|
/** Default row height for the whole table. */
|
|
function setTableRowHeight(tableId: string, height: number) {
|
|
const t = tableById(tableId);
|
|
if (t) t.rowHeight = Math.min(240, Math.max(MIN_ROW_H, Math.round(height)));
|
|
}
|
|
|
|
function pruneEmptyMaps(t: TableObject) {
|
|
if (t.formats && !Object.keys(t.formats).length) delete t.formats;
|
|
if (t.rowHeights && !Object.keys(t.rowHeights).length) delete t.rowHeights;
|
|
}
|
|
|
|
/** Grow or shrink a table to an exact row / column count. */
|
|
function setTableRows(tableId: string, target: number) {
|
|
const t = tableById(tableId);
|
|
if (!t) return;
|
|
const n = Math.max(1, Math.floor(target));
|
|
if (n === t.rows) return;
|
|
if (n < t.rows) {
|
|
for (const k of Object.keys(t.cells)) if (Number(k.split(',')[1]) >= n) delete t.cells[k];
|
|
if (t.formats) for (const k of Object.keys(t.formats)) if (Number(k.split(',')[1]) >= n) delete t.formats[k];
|
|
if (t.rowHeights) for (const k of Object.keys(t.rowHeights)) if (Number(k) >= n) delete t.rowHeights[Number(k)];
|
|
}
|
|
t.rows = n;
|
|
pruneEmptyMaps(t);
|
|
clampTableView(t);
|
|
clampSelection(tableId);
|
|
syncWorkbook();
|
|
}
|
|
function setTableCols(tableId: string, target: number) {
|
|
const t = tableById(tableId);
|
|
if (!t) return;
|
|
const n = Math.max(1, Math.floor(target));
|
|
if (n === t.cols) return;
|
|
if (n < t.cols) {
|
|
for (const k of Object.keys(t.cells)) if (Number(k.split(',')[0]) >= n) delete t.cells[k];
|
|
if (t.formats) for (const k of Object.keys(t.formats)) if (Number(k.split(',')[0]) >= n) delete t.formats[k];
|
|
for (const k of Object.keys(t.colWidths)) if (Number(k) >= n) delete t.colWidths[Number(k)];
|
|
}
|
|
t.cols = n;
|
|
pruneEmptyMaps(t);
|
|
clampTableView(t);
|
|
clampSelection(tableId);
|
|
syncWorkbook();
|
|
}
|
|
|
|
// ---- cell formatting (alignment, number format, colours, bold/italic) ---
|
|
const fmtKey = (c: number, r: number) => `${c},${r}`;
|
|
function cellFormat(tableId: string, col: number, row: number): CellFormat {
|
|
return tableById(tableId)?.formats?.[fmtKey(col, row)] ?? {};
|
|
}
|
|
/** Merge `patch` into every cell of a range; a `null`/`undefined` field clears it. */
|
|
function setRangeFormat(
|
|
tableId: string,
|
|
a: CellCoord,
|
|
b: CellCoord,
|
|
patch: Partial<Record<keyof CellFormat, unknown>>,
|
|
) {
|
|
const t = tableById(tableId);
|
|
if (!t) return;
|
|
if (!t.formats) t.formats = {};
|
|
const c0 = Math.min(a.col, b.col);
|
|
const c1 = Math.max(a.col, b.col);
|
|
const r0 = Math.min(a.row, b.row);
|
|
const r1 = Math.max(a.row, b.row);
|
|
for (let r = r0; r <= r1; r++) {
|
|
for (let c = c0; c <= c1; c++) {
|
|
const k = fmtKey(c, r);
|
|
const cur: CellFormat = { ...(t.formats[k] ?? {}) };
|
|
for (const [field, val] of Object.entries(patch)) {
|
|
if (val === null || val === undefined || val === '' || val === false || val === 'auto') {
|
|
delete (cur as Record<string, unknown>)[field];
|
|
} else {
|
|
(cur as Record<string, unknown>)[field] = val;
|
|
}
|
|
}
|
|
if (Object.keys(cur).length) t.formats[k] = cur;
|
|
else delete t.formats[k];
|
|
}
|
|
}
|
|
if (!Object.keys(t.formats).length) delete t.formats;
|
|
bump();
|
|
}
|
|
function clearRangeFormat(tableId: string, a: CellCoord, b: CellCoord) {
|
|
const t = tableById(tableId);
|
|
if (!t?.formats) return;
|
|
const c0 = Math.min(a.col, b.col);
|
|
const c1 = Math.max(a.col, b.col);
|
|
const r0 = Math.min(a.row, b.row);
|
|
const r1 = Math.max(a.row, b.row);
|
|
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) delete t.formats[fmtKey(c, r)];
|
|
if (!Object.keys(t.formats).length) delete t.formats;
|
|
bump();
|
|
}
|
|
/** Format shared by every cell of the current (possibly multi-) selection. */
|
|
const selectionFormat = computed<CellFormat>(() => {
|
|
void recalcTick.value;
|
|
const s = doc.selection;
|
|
const t = s ? tableById(s.tableId) : undefined;
|
|
if (!s || !t) return {};
|
|
let acc: CellFormat | null = null;
|
|
for (const key of selectedCellKeys()) {
|
|
const f = t.formats?.[key] ?? {};
|
|
if (acc === null) acc = { ...f };
|
|
else {
|
|
for (const k of Object.keys(acc) as (keyof CellFormat)[]) {
|
|
if (acc[k] !== f[k]) delete acc[k];
|
|
}
|
|
}
|
|
}
|
|
return acc ?? {};
|
|
});
|
|
|
|
/** Apply / clear formatting across *all* rectangles of the current selection. */
|
|
function applySelectionFormat(patch: Partial<Record<keyof CellFormat, unknown>>) {
|
|
const s = doc.selection;
|
|
if (!s) return;
|
|
for (const k of selectionRects(s)) {
|
|
setRangeFormat(s.tableId, { col: k.c0, row: k.r0 }, { col: k.c1, row: k.r1 }, patch);
|
|
}
|
|
}
|
|
function clearSelectionFormat() {
|
|
const s = doc.selection;
|
|
if (!s) return;
|
|
for (const k of selectionRects(s)) {
|
|
clearRangeFormat(s.tableId, { col: k.c0, row: k.r0 }, { col: k.c1, row: k.r1 });
|
|
}
|
|
}
|
|
/** Delete/Backspace over a multi-selection. */
|
|
function clearSelectionCells() {
|
|
const s = doc.selection;
|
|
if (!s) return;
|
|
for (const key of selectedCellKeys()) {
|
|
const [c, r] = key.split(',').map(Number);
|
|
setCellInput(s.tableId, c, r, '');
|
|
}
|
|
}
|
|
|
|
function clampTableView(t: TableObject) {
|
|
t.view.frozenRows = Math.min(Math.max(0, t.view.frozenRows), Math.max(0, t.rows - 1));
|
|
t.view.frozenCols = Math.min(Math.max(0, t.view.frozenCols), Math.max(0, t.cols - 1));
|
|
}
|
|
function clampSelection(tableId: string) {
|
|
const s = doc.selection;
|
|
const t = tableById(tableId);
|
|
if (!s || !t || s.tableId !== tableId) return;
|
|
const cl = (c: CellCoord) => ({
|
|
col: Math.min(Math.max(0, c.col), t.cols - 1),
|
|
row: Math.min(Math.max(0, c.row), t.rows - 1),
|
|
});
|
|
doc.selection = {
|
|
tableId,
|
|
anchor: cl(s.anchor),
|
|
focus: cl(s.focus),
|
|
ranges: s.ranges?.map((r) => ({ anchor: cl(r.anchor), focus: cl(r.focus) })),
|
|
};
|
|
}
|
|
|
|
/** Patch a table object's view (sizing mode, viewport size, frozen rows/cols). */
|
|
function setTableView(tableId: string, patch: Partial<TableView>) {
|
|
const t = tableById(tableId);
|
|
if (!t) return;
|
|
t.view = { ...t.view, ...patch };
|
|
if (patch.width !== undefined) t.view.width = Math.max(200, Math.round(t.view.width));
|
|
if (patch.height !== undefined) t.view.height = Math.max(120, Math.round(t.view.height));
|
|
clampTableView(t);
|
|
}
|
|
|
|
// ---- selection --------------------------------------
|
|
function rect(a: CellCoord, b: CellCoord): Rect {
|
|
return {
|
|
c0: Math.min(a.col, b.col),
|
|
c1: Math.max(a.col, b.col),
|
|
r0: Math.min(a.row, b.row),
|
|
r1: Math.max(a.row, b.row),
|
|
};
|
|
}
|
|
/** Every rectangle of `sel` (primary first), normalised. */
|
|
function selectionRects(sel: SelectionState): Rect[] {
|
|
const out = [rect(sel.anchor, sel.focus)];
|
|
for (const r of sel.ranges ?? []) out.push(rect(r.anchor, r.focus));
|
|
return out;
|
|
}
|
|
/** Deduplicated set of "c,r" keys covered by the current selection. */
|
|
function selectedCellKeys(): Set<string> {
|
|
const s = doc.selection;
|
|
const keys = new Set<string>();
|
|
if (!s) return keys;
|
|
for (const k of selectionRects(s)) {
|
|
for (let r = k.r0; r <= k.r1; r++) for (let c = k.c0; c <= k.c1; c++) keys.add(`${c},${r}`);
|
|
}
|
|
return keys;
|
|
}
|
|
const isOneByOne = (a: CellCoord, b: CellCoord, c: number, r: number) =>
|
|
a.col === c && a.row === r && b.col === c && b.row === r;
|
|
|
|
function setSelection(sel: SelectionState | null) {
|
|
doc.selection = sel;
|
|
if (sel) {
|
|
doc.activeObjectId = sel.tableId;
|
|
const sheetId = sheetIdOfObject(sel.tableId);
|
|
if (sheetId) doc.activeSheetId = sheetId;
|
|
}
|
|
}
|
|
/** Replace the whole selection with one rectangle (or grow the primary one). */
|
|
function selectCell(tableId: string, col: number, row: number, extend = false) {
|
|
const cur = doc.selection;
|
|
const anchor = extend && cur && cur.tableId === tableId ? cur.anchor : { col, row };
|
|
setSelection({ tableId, anchor, focus: { col, row } });
|
|
}
|
|
|
|
/** Ctrl/Cmd-click: add the cell as a new rectangle, or toggle it off. */
|
|
function addToSelection(tableId: string, col: number, row: number) {
|
|
const cur = doc.selection;
|
|
if (!cur || cur.tableId !== tableId) {
|
|
selectCell(tableId, col, row);
|
|
return;
|
|
}
|
|
const ranges = [...(cur.ranges ?? [])];
|
|
const idx = ranges.findIndex((r) => isOneByOne(r.anchor, r.focus, col, row));
|
|
if (idx >= 0) {
|
|
// toggle an extra 1x1 range off
|
|
ranges.splice(idx, 1);
|
|
doc.selection = { ...cur, ranges: ranges.length ? ranges : undefined };
|
|
return;
|
|
}
|
|
if (isOneByOne(cur.anchor, cur.focus, col, row) && ranges.length) {
|
|
// toggle the primary off -> promote the most recent extra range
|
|
const last = ranges.pop()!;
|
|
doc.selection = {
|
|
tableId,
|
|
anchor: last.anchor,
|
|
focus: last.focus,
|
|
ranges: ranges.length ? ranges : undefined,
|
|
};
|
|
return;
|
|
}
|
|
// keep the current primary as an extra range, make the clicked cell primary
|
|
ranges.push({ anchor: cur.anchor, focus: cur.focus });
|
|
setSelection({ tableId, anchor: { col, row }, focus: { col, row }, ranges });
|
|
}
|
|
/** Ctrl/Cmd-drag: grow the just-added (primary) rectangle. */
|
|
function extendActiveRange(tableId: string, col: number, row: number) {
|
|
const cur = doc.selection;
|
|
if (!cur || cur.tableId !== tableId) return;
|
|
doc.selection = { ...cur, focus: { col, row } };
|
|
}
|
|
/** Ctrl/Cmd-click on a column/row header: add that whole strip. */
|
|
function addRangeToSelection(tableId: string, a: CellCoord, b: CellCoord) {
|
|
const cur = doc.selection;
|
|
if (!cur || cur.tableId !== tableId) {
|
|
setSelection({ tableId, anchor: a, focus: b });
|
|
return;
|
|
}
|
|
const ranges = [...(cur.ranges ?? []), { anchor: cur.anchor, focus: cur.focus }];
|
|
setSelection({ tableId, anchor: a, focus: b, ranges });
|
|
}
|
|
|
|
// ---- clipboard (copy / cut / paste) --------------------------
|
|
/** True while a copy/cut buffer is held (enables "Einfügen" in menus). */
|
|
const hasClipboard = computed(() => clipboard.value !== null);
|
|
/** Source rectangle of the current buffer -- drives the dashed source marker. */
|
|
const clipboardRect = computed(() => {
|
|
const cb = clipboard.value;
|
|
return cb ? { tableId: cb.tableId, ...cb.rect, cut: cb.cut } : null;
|
|
});
|
|
|
|
function copyOrCut(cut: boolean) {
|
|
const s = doc.selection;
|
|
const t = s ? tableById(s.tableId) : undefined;
|
|
if (!s || !t) return;
|
|
const r = rect(s.anchor, s.focus);
|
|
const cells = new Map<string, ClipboardCell>();
|
|
for (let row = r.r0; row <= r.r1; row++) {
|
|
for (let col = r.c0; col <= r.c1; col++) {
|
|
const raw = cellInput(s.tableId, col, row);
|
|
const format = t.formats?.[fmtKey(col, row)];
|
|
if (raw || format) {
|
|
cells.set(`${col - r.c0},${row - r.r0}`, { raw, format: format ? { ...format } : undefined });
|
|
}
|
|
}
|
|
}
|
|
clipboard.value = {
|
|
tableId: s.tableId,
|
|
rect: r,
|
|
width: r.c1 - r.c0 + 1,
|
|
height: r.r1 - r.r0 + 1,
|
|
cells,
|
|
cut,
|
|
};
|
|
}
|
|
const copySelection = () => copyOrCut(false);
|
|
const cutSelection = () => copyOrCut(true);
|
|
/** Drop the buffer (Escape) without pasting. */
|
|
function cancelClipboard() {
|
|
clipboard.value = null;
|
|
}
|
|
|
|
/**
|
|
* Paste the buffer at the current selection. A selection that is an exact,
|
|
* larger multiple of the buffer tiles it (Numbers/Excel "fill by paste");
|
|
* a smaller or single-cell selection anchors the buffer there and overflows
|
|
* past it, same as pasting a whole block onto one cell.
|
|
*/
|
|
function pasteClipboard() {
|
|
const cb = clipboard.value;
|
|
const s = doc.selection;
|
|
if (!cb || !s || !tableById(s.tableId)) return;
|
|
|
|
const dest = rect(s.anchor, s.focus);
|
|
const destW = dest.c1 - dest.c0 + 1;
|
|
const destH = dest.r1 - dest.r0 + 1;
|
|
const tileX = destW > cb.width && destW % cb.width === 0 ? destW / cb.width : 1;
|
|
const tileY = destH > cb.height && destH % cb.height === 0 ? destH / cb.height : 1;
|
|
|
|
for (let ty = 0; ty < tileY; ty++) {
|
|
for (let tx = 0; tx < tileX; tx++) {
|
|
const baseC = dest.c0 + tx * cb.width;
|
|
const baseR = dest.r0 + ty * cb.height;
|
|
const dCol = baseC - cb.rect.c0;
|
|
const dRow = baseR - cb.rect.r0;
|
|
for (let dr = 0; dr < cb.height; dr++) {
|
|
for (let dc = 0; dc < cb.width; dc++) {
|
|
const c = baseC + dc;
|
|
const r = baseR + dr;
|
|
const cell = cb.cells.get(`${dc},${dr}`);
|
|
const raw = cell?.raw ?? '';
|
|
setCellInput(
|
|
s.tableId,
|
|
c,
|
|
r,
|
|
raw ? shiftFormula(raw, dCol, dRow, cb.cut ? cb.rect : undefined) : raw,
|
|
);
|
|
clearRangeFormat(s.tableId, { col: c, row: r }, { col: c, row: r });
|
|
if (cell?.format) {
|
|
setRangeFormat(
|
|
s.tableId,
|
|
{ col: c, row: r },
|
|
{ col: c, row: r },
|
|
cell.format as Partial<Record<keyof CellFormat, unknown>>,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (cb.cut) {
|
|
// clear the moved-from cells, except any the paste itself just wrote into
|
|
const destC1 = dest.c0 + tileX * cb.width - 1;
|
|
const destR1 = dest.r0 + tileY * cb.height - 1;
|
|
for (let row = cb.rect.r0; row <= cb.rect.r1; row++) {
|
|
for (let col = cb.rect.c0; col <= cb.rect.c1; col++) {
|
|
const overlap =
|
|
cb.tableId === s.tableId &&
|
|
col >= dest.c0 && col <= destC1 && row >= dest.r0 && row <= destR1;
|
|
if (!overlap) {
|
|
setCellInput(cb.tableId, col, row, '');
|
|
clearRangeFormat(cb.tableId, { col, row }, { col, row });
|
|
}
|
|
}
|
|
}
|
|
clipboard.value = null; // a cut buffer is consumed by its first paste
|
|
}
|
|
|
|
setSelection({
|
|
tableId: s.tableId,
|
|
anchor: { col: dest.c0, row: dest.r0 },
|
|
focus: { col: dest.c0 + tileX * cb.width - 1, row: dest.r0 + tileY * cb.height - 1 },
|
|
});
|
|
}
|
|
|
|
const activeCell = computed<CellCoord>(() => doc.selection?.focus ?? { col: 0, row: 0 });
|
|
|
|
const activeCellName = computed(() => {
|
|
const sel = doc.selection;
|
|
const t = sel ? tableById(sel.tableId) : activeTable.value;
|
|
const cell = sel?.focus ?? { col: 0, row: 0 };
|
|
return `${t?.name ?? '—'}::${toA1(cell.col, cell.row)}`;
|
|
});
|
|
|
|
const activeCellInput = computed(() => {
|
|
const sel = doc.selection;
|
|
return sel ? cellInput(sel.tableId, sel.focus.col, sel.focus.row) : '';
|
|
});
|
|
|
|
const selectionAggregate = computed(() => {
|
|
void recalcTick.value;
|
|
const sel = doc.selection;
|
|
if (!sel) return { sum: 0, avg: 0, count: 0, cells: 0 };
|
|
let sum = 0;
|
|
let count = 0;
|
|
let cells = 0;
|
|
for (const key of selectedCellKeys()) {
|
|
cells++;
|
|
const [c, r] = key.split(',').map(Number);
|
|
const v = wb.cellValue(sel.tableId, c, r);
|
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
sum += v;
|
|
count++;
|
|
}
|
|
}
|
|
return { sum, avg: count ? sum / count : 0, count, cells };
|
|
});
|
|
|
|
// ---- document-level ops ---------------------------
|
|
function replaceDocument(next: DocumentState) {
|
|
doc.version = 2;
|
|
doc.sheets = next.sheets;
|
|
doc.activeSheetId =
|
|
next.sheets.find((s) => s.id === next.activeSheetId)?.id ?? next.sheets[0].id;
|
|
doc.activeObjectId = next.activeObjectId ?? null;
|
|
doc.selection = next.selection ?? null;
|
|
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
|
|
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
|
syncWorkbook();
|
|
}
|
|
function resetToSeed() {
|
|
replaceDocument(createSeedDocument());
|
|
}
|
|
/** Append fully-formed sheets (JSON-append / XLSX import). */
|
|
function appendSheets(sheets: Sheet[]) {
|
|
for (const s of sheets) {
|
|
let id = s.id;
|
|
while (doc.sheets.some((o) => o.id === id)) id = uid('sheet');
|
|
const sn = new Set(doc.sheets.map((x) => x.name.toLowerCase()));
|
|
doc.sheets.push({ ...s, id, name: uniqueName(s.name, sn) });
|
|
doc.activeSheetId = id;
|
|
}
|
|
syncWorkbook();
|
|
}
|
|
/** Add a new table object built from a cell map (CSV "as new table"). */
|
|
function addTableFromCells(
|
|
sheetId: string,
|
|
name: string,
|
|
cells: Record<string, string>,
|
|
rows: number,
|
|
cols: number,
|
|
): string {
|
|
const id = addTable(sheetId);
|
|
const t = tableById(id)!;
|
|
t.cells = { ...cells };
|
|
t.rows = Math.max(rows, 1);
|
|
t.cols = Math.max(cols, 1);
|
|
renameObject(id, name); // also syncs the workbook
|
|
return id;
|
|
}
|
|
/** Replace an existing table's whole cell map (CSV "replace active table"). */
|
|
function replaceTableCells(
|
|
tableId: string,
|
|
cells: Record<string, string>,
|
|
rows: number,
|
|
cols: number,
|
|
) {
|
|
const t = tableById(tableId);
|
|
if (!t) return;
|
|
t.cells = { ...cells };
|
|
t.rows = Math.max(rows, 1);
|
|
t.cols = Math.max(cols, 1);
|
|
t.colWidths = {};
|
|
syncWorkbook();
|
|
}
|
|
function snapshot(): DocumentState {
|
|
return JSON.parse(JSON.stringify(doc)) as DocumentState;
|
|
}
|
|
|
|
return {
|
|
doc,
|
|
recalcTick,
|
|
// sheets
|
|
sheetById,
|
|
activeSheet,
|
|
setActiveSheet,
|
|
addSheet,
|
|
renameSheet,
|
|
deleteSheet,
|
|
moveSheet,
|
|
// objects
|
|
objectById,
|
|
sheetIdOfObject,
|
|
tablesOf,
|
|
activeObject,
|
|
activeTable,
|
|
addTable,
|
|
addChart,
|
|
addText,
|
|
setTextContent,
|
|
setTextFormat,
|
|
resizeText,
|
|
renameObject,
|
|
deleteObject,
|
|
moveObject,
|
|
resizeChart,
|
|
bringToFront,
|
|
setChartView,
|
|
setChartSource,
|
|
selectObject,
|
|
setObjectBg,
|
|
setObjectTextColor,
|
|
// table grid
|
|
tableById,
|
|
cellInput,
|
|
cellValue,
|
|
cellDisplay,
|
|
cellIsNumeric,
|
|
cellIsError,
|
|
cellIsFormula,
|
|
setCellInput,
|
|
clearRange,
|
|
addRows,
|
|
addColumns,
|
|
removeLastRow,
|
|
removeLastColumn,
|
|
setColWidth,
|
|
scaleTableColumns,
|
|
setRowHeight,
|
|
setTableRowHeight,
|
|
rowHeightOf,
|
|
setTableRows,
|
|
setTableCols,
|
|
setTableView,
|
|
// cell formatting
|
|
cellFormat,
|
|
setRangeFormat,
|
|
clearRangeFormat,
|
|
selectionFormat,
|
|
applySelectionFormat,
|
|
clearSelectionFormat,
|
|
clearSelectionCells,
|
|
// number-format locale
|
|
effectiveLocale,
|
|
activeLocale,
|
|
setDocumentLocale,
|
|
setSheetLocale,
|
|
setObjectLocale,
|
|
// per-sheet appearance + zoom + csv default
|
|
setSheetBackground,
|
|
setSheetForeground,
|
|
setSheetShowGrid,
|
|
setSheetZoom,
|
|
activeZoom,
|
|
setZoom,
|
|
setCsvDelimiter,
|
|
// view-only ui
|
|
ui,
|
|
// selection
|
|
setSelection,
|
|
selectCell,
|
|
addToSelection,
|
|
extendActiveRange,
|
|
addRangeToSelection,
|
|
selectionRects,
|
|
activeCell,
|
|
activeCellName,
|
|
activeCellInput,
|
|
selectionAggregate,
|
|
// clipboard
|
|
copySelection,
|
|
cutSelection,
|
|
pasteClipboard,
|
|
cancelClipboard,
|
|
hasClipboard,
|
|
clipboardRect,
|
|
// document
|
|
replaceDocument,
|
|
resetToSeed,
|
|
appendSheets,
|
|
addTableFromCells,
|
|
replaceTableCells,
|
|
snapshot,
|
|
engine: () => wb,
|
|
};
|
|
}
|
|
|
|
let instance: ReturnType<typeof createStore> | null = null;
|
|
|
|
export function useDocument() {
|
|
if (!instance) instance = createStore();
|
|
return instance;
|
|
}
|
|
|
|
export type DocumentStore = ReturnType<typeof createStore>;
|