Files
wkh 0fca61eb65 Add in-app help, formula reference outlines, underline and auto-closing
- Help dialog (?, F1, Datei > Hilfe): user topics, all shortcuts and the
  formula functions with one search over everything. Topics and shortcuts
  are plain data in data/help.ts; the function list comes from
  codeReference.ts so new built-ins appear by themselves.
- While a formula is edited, the grid outlines each referenced cell/range
  in the colour of its position in the text, and the outline can be
  dragged to move or resize the reference, as in Excel. Point-mode picks
  (click, drag, arrow keys) are outlined too, so keyboard picking is no
  longer blind.
- Underline as a cell format, plus Mod+Shift+B/I/U toggles that switch on
  unless every selected cell already has the format.
- A formula committed with open parentheses or an open text literal gets
  the missing closers appended; anything that still doesn't parse is left
  alone so the usual error shows instead of a guess.
- Cells and tables with a fill but no text colour get dark or light text
  by WCAG contrast instead of the theme colour (no black on black).
2026-10-24 00:38:01 +02:00

560 lines
20 KiB
TypeScript

/**
* Native JSON format -- full-fidelity, lossless round-trip.
* Contains every sheet, every table/chart object with position & size, raw
* formulas, column widths, tab order, the active object and the cell selection.
* No library: `JSON.stringify` / `JSON.parse` plus schema validation.
*
* Version 1 documents (one grid per sheet) are migrated to version 2
* (canvas + objects) on import.
*/
import type {
ButtonObject,
CellCoord,
CellFormat,
ChartObject,
CustomFunction,
DocumentState,
HAlign,
Macro,
NamedRange,
Sheet,
SheetObject,
TableObject,
TableView,
TextObject,
} from '../store/types';
import { defaultTableView, MIN_GRID_SIZE, MAX_GRID_SIZE } from '../store/types';
import {
DEFAULT_LOCALE,
isCellNumFmt,
isNumberLocale,
type NumberLocale,
} from '../store/format';
/** Keep a valid locale override, drop anything else (undefined = inherit). */
function localeOrUndef(x: unknown): NumberLocale | undefined {
return isNumberLocale(x) ? x : undefined;
}
/** Defaults to `[]` -- absent in every document saved before this feature
* existed (and in v1 documents entirely) -- and drops any malformed entry
* rather than rejecting the whole import over one bad function. */
function normaliseCustomFunctions(x: unknown): CustomFunction[] {
if (!Array.isArray(x)) return [];
const out: CustomFunction[] = [];
for (const f of x) {
if (
!f ||
typeof f !== 'object' ||
typeof (f as { id?: unknown }).id !== 'string' ||
typeof (f as { name?: unknown }).name !== 'string' ||
typeof (f as { body?: unknown }).body !== 'string' ||
!Array.isArray((f as { params?: unknown }).params) ||
!(f as { params: unknown[] }).params.every((p) => typeof p === 'string')
) {
continue;
}
const rec = f as CustomFunction;
const kind = rec.kind === 'block' ? 'block' : 'expression'; // default for anything missing/invalid
out.push({ id: rec.id, name: rec.name, params: [...rec.params], body: rec.body, kind });
}
return out;
}
function isCellCoord(x: unknown): x is CellCoord {
return (
!!x &&
typeof x === 'object' &&
typeof (x as CellCoord).col === 'number' &&
typeof (x as CellCoord).row === 'number'
);
}
/** Defaults to `[]` -- absent in every document saved before this feature
* existed. Drops a malformed entry, and any entry whose `tableId` doesn't
* match a real table *in this document* (a dangling reference -- e.g. a
* hand-edited file, or a table that no longer exists) -- rather than
* leaving it to resolve to `#NAME?` forever; the live "delete a table"
* path (`document.ts`) does leave such entries in place, for consistency
* with how a deleted table's ordinary formula references are never
* auto-pruned either -- this is stricter only because *importing* JSON is
* already a validating boundary for everything else in the document. */
function normaliseNamedRanges(x: unknown, tableIds: ReadonlySet<string>): NamedRange[] {
if (!Array.isArray(x)) return [];
const out: NamedRange[] = [];
for (const r of x) {
if (
!r ||
typeof r !== 'object' ||
typeof (r as { id?: unknown }).id !== 'string' ||
typeof (r as { name?: unknown }).name !== 'string' ||
typeof (r as { tableId?: unknown }).tableId !== 'string' ||
!isCellCoord((r as { start?: unknown }).start) ||
!isCellCoord((r as { end?: unknown }).end)
) {
continue;
}
const rec = r as NamedRange;
if (!tableIds.has(rec.tableId)) continue;
out.push({ id: rec.id, name: rec.name, tableId: rec.tableId, start: { ...rec.start }, end: { ...rec.end } });
}
return out;
}
/** Defaults to `[]` -- absent in every document saved before this feature
* existed. Unlike a named range, a macro carries no `tableId`/other
* document-internal reference to validate against -- its body is a plain
* string, parsed and validated lazily (store/document.ts's `validateMacro`)
* only when actually edited/run, same lenient convention as a custom
* function's `body` (an import doesn't re-run every save-time check, only
* shape validation). */
function normaliseMacros(x: unknown): Macro[] {
if (!Array.isArray(x)) return [];
const out: Macro[] = [];
for (const m of x) {
if (
!m ||
typeof m !== 'object' ||
typeof (m as { id?: unknown }).id !== 'string' ||
typeof (m as { name?: unknown }).name !== 'string' ||
typeof (m as { body?: unknown }).body !== 'string'
) {
continue;
}
const rec = m as Macro;
const approvedHosts =
Array.isArray(rec.approvedHosts) && rec.approvedHosts.every((h) => typeof h === 'string')
? rec.approvedHosts
: undefined;
out.push({ id: rec.id, name: rec.name, body: rec.body, ...(approvedHosts ? { approvedHosts } : {}) });
}
return out;
}
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
function colorOrUndef(x: unknown): string | undefined {
return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined;
}
function boolOrUndef(x: unknown): boolean | undefined {
return typeof x === 'boolean' ? x : undefined;
}
function zoomOrUndef(x: unknown): number | undefined {
return typeof x === 'number' && Number.isFinite(x)
? Math.min(2, Math.max(0.4, Math.round(x * 20) / 20))
: undefined;
}
function gridSizeOrUndef(x: unknown): number | undefined {
return typeof x === 'number' && Number.isFinite(x)
? Math.round(Math.min(MAX_GRID_SIZE, Math.max(MIN_GRID_SIZE, x)))
: undefined;
}
function clampNum(x: unknown, lo: number, hi: number, fallback: number): number {
return typeof x === 'number' && Number.isFinite(x)
? Math.min(hi, Math.max(lo, Math.round(x)))
: fallback;
}
const H_ALIGNS = new Set<HAlign>(['left', 'center', 'right']);
function alignOrUndef(x: unknown): HAlign | undefined {
return H_ALIGNS.has(x as HAlign) ? (x as HAlign) : undefined;
}
/** Validate + trim one CellFormat; returns undefined if nothing valid remains. */
function normaliseCellFormat(x: unknown): CellFormat | undefined {
if (!x || typeof x !== 'object') return undefined;
const f = x as CellFormat;
const out: CellFormat = {};
if (alignOrUndef(f.align)) out.align = f.align;
if (f.valign === 'top' || f.valign === 'middle' || f.valign === 'bottom') out.valign = f.valign;
if (isCellNumFmt(f.numFmt) && f.numFmt !== 'auto') out.numFmt = f.numFmt;
if (colorOrUndef(f.color)) out.color = f.color;
if (colorOrUndef(f.bg)) out.bg = f.bg;
if (f.bold === true) out.bold = true;
if (f.italic === true) out.italic = true;
if (f.underline === true) out.underline = true;
return Object.keys(out).length ? out : undefined;
}
function normaliseFormats(x: unknown): Record<string, CellFormat> | undefined {
if (!x || typeof x !== 'object') return undefined;
const out: Record<string, CellFormat> = {};
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
if (!/^\d+,\d+$/.test(k)) continue;
const f = normaliseCellFormat(v);
if (f) out[k] = f;
}
return Object.keys(out).length ? out : undefined;
}
function normaliseRowHeights(x: unknown): Record<number, number> | undefined {
if (!x || typeof x !== 'object') return undefined;
const out: Record<number, number> = {};
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
if (!/^\d+$/.test(k)) continue;
if (typeof v === 'number' && Number.isFinite(v)) {
out[Number(k)] = Math.min(240, Math.max(14, Math.round(v)));
}
}
return Object.keys(out).length ? out : undefined;
}
function coord(x: unknown): { col: number; row: number } | null {
const c = x as { col?: unknown; row?: unknown };
if (!c || typeof c !== 'object') return null;
if (typeof c.col !== 'number' || typeof c.row !== 'number') return null;
return { col: Math.max(0, Math.floor(c.col)), row: Math.max(0, Math.floor(c.row)) };
}
function normaliseSelection(x: unknown): DocumentState['selection'] {
const s = x as { tableId?: unknown; anchor?: unknown; focus?: unknown; ranges?: unknown };
const anchor = coord(s.anchor);
const focus = coord(s.focus);
if (typeof s.tableId !== 'string' || !anchor || !focus) return null;
const ranges = Array.isArray(s.ranges)
? s.ranges
.map((r) => {
const a = coord((r as { anchor?: unknown }).anchor);
const f = coord((r as { focus?: unknown }).focus);
return a && f ? { anchor: a, focus: f } : null;
})
.filter((r): r is { anchor: { col: number; row: number }; focus: { col: number; row: number } } => !!r)
: [];
return { tableId: s.tableId, anchor, focus, ranges: ranges.length ? ranges : undefined };
}
function numMapOrUndef(x: unknown): Record<number, number> | undefined {
if (!x || typeof x !== 'object') return undefined;
const out: Record<number, number> = {};
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
if (/^\d+$/.test(k) && typeof v === 'number' && Number.isFinite(v)) {
out[Number(k)] = Math.max(20, Math.round(v));
}
}
return Object.keys(out).length ? out : undefined;
}
export class JsonImportError extends Error {}
// ---- v2 validation ----------------------------------------------------
function isRecordOfString(x: unknown): x is Record<string, string> {
return (
!!x &&
typeof x === 'object' &&
Object.values(x as Record<string, unknown>).every((v) => typeof v === 'string')
);
}
function isTableObject(x: unknown): x is TableObject {
const o = x as TableObject;
return (
!!o &&
o.kind === 'table' &&
typeof o.id === 'string' &&
typeof o.name === 'string' &&
Number.isFinite(o.x) &&
Number.isFinite(o.y) &&
Number.isFinite(o.rows) &&
Number.isFinite(o.cols) &&
!!o.colWidths &&
typeof o.colWidths === 'object' &&
isRecordOfString(o.cells) &&
Object.keys(o.cells).every((k) => /^\d+,\d+$/.test(k))
);
}
function isChartObject(x: unknown): x is ChartObject {
const o = x as ChartObject;
return (
!!o &&
o.kind === 'chart' &&
typeof o.id === 'string' &&
typeof o.name === 'string' &&
Number.isFinite(o.x) &&
Number.isFinite(o.y) &&
Number.isFinite(o.width) &&
Number.isFinite(o.height) &&
(o.view === 'bar' || o.view === 'line')
);
}
function isTextObject(x: unknown): x is TextObject {
const o = x as TextObject;
return (
!!o &&
o.kind === 'text' &&
typeof o.id === 'string' &&
typeof o.name === 'string' &&
Number.isFinite(o.x) &&
Number.isFinite(o.y) &&
Number.isFinite(o.width) &&
Number.isFinite(o.height) &&
typeof o.text === 'string'
);
}
function isButtonObject(x: unknown): x is ButtonObject {
const o = x as ButtonObject;
return (
!!o &&
o.kind === 'button' &&
typeof o.id === 'string' &&
typeof o.name === 'string' &&
Number.isFinite(o.x) &&
Number.isFinite(o.y) &&
Number.isFinite(o.width) &&
Number.isFinite(o.height) &&
typeof o.label === 'string'
// macroId is intentionally NOT checked against a real macro here --
// same lenient "dangling id renders as unassigned" convention as a
// chart's sourceTableId, see ButtonObject's doc comment.
);
}
function normaliseTableView(v: Partial<TableView> | undefined, rows: number, cols: number): TableView {
const d = defaultTableView();
const mode = v?.mode === 'fixed' ? 'fixed' : 'auto';
return {
mode,
width: Number.isFinite(v?.width) ? Math.max(200, Math.round(v!.width!)) : d.width,
height: Number.isFinite(v?.height) ? Math.max(120, Math.round(v!.height!)) : d.height,
frozenRows: Math.min(Math.max(0, Math.floor(v?.frozenRows ?? 0)), Math.max(0, rows - 1)),
frozenCols: Math.min(Math.max(0, Math.floor(v?.frozenCols ?? 0)), Math.max(0, cols - 1)),
};
}
function normaliseObject(o: SheetObject): SheetObject {
if (o.kind === 'table') {
const rows = Math.max(1, Math.floor(o.rows));
const cols = Math.max(1, Math.floor(o.cols));
return {
kind: 'table',
id: o.id,
name: o.name,
x: Math.max(0, Math.round(o.x)),
y: Math.max(0, Math.round(o.y)),
rows,
cols,
colWidths: numMapOrUndef(o.colWidths) ?? {},
rowHeight:
typeof o.rowHeight === 'number' && Number.isFinite(o.rowHeight)
? Math.min(240, Math.max(14, Math.round(o.rowHeight)))
: undefined,
rowHeights: normaliseRowHeights(o.rowHeights),
cells: { ...o.cells },
formats: normaliseFormats(o.formats),
view: normaliseTableView(o.view, rows, cols),
locale: localeOrUndef(o.locale),
bg: colorOrUndef(o.bg),
fg: colorOrUndef(o.fg),
hideChromeUnfocused: boolOrUndef(o.hideChromeUnfocused),
};
}
if (o.kind === 'text') {
return {
kind: 'text',
id: o.id,
name: o.name,
x: Math.max(0, Math.round(o.x)),
y: Math.max(0, Math.round(o.y)),
width: Math.max(80, Math.round(o.width)),
height: Math.max(32, Math.round(o.height)),
text: String(o.text ?? ''),
fontSize: clampNum(o.fontSize, 9, 96, 15),
align: alignOrUndef(o.align) ?? 'left',
bold: o.bold === true || undefined,
italic: o.italic === true || undefined,
color: colorOrUndef(o.color),
bg: colorOrUndef(o.bg),
};
}
if (o.kind === 'button') {
return {
kind: 'button',
id: o.id,
name: o.name,
x: Math.max(0, Math.round(o.x)),
y: Math.max(0, Math.round(o.y)),
width: Math.max(60, Math.round(o.width)),
height: Math.max(28, Math.round(o.height)),
label: String(o.label ?? ''),
macroId: typeof o.macroId === 'string' ? o.macroId : null,
bg: colorOrUndef(o.bg),
fg: colorOrUndef(o.fg),
};
}
if (o.kind === 'chart') {
return {
kind: 'chart',
id: o.id,
name: o.name,
x: Math.max(0, Math.round(o.x)),
y: Math.max(0, Math.round(o.y)),
width: Math.max(200, Math.round(o.width)),
height: Math.max(140, Math.round(o.height)),
view: o.view,
sourceTableId: o.sourceTableId ?? null,
locale: localeOrUndef(o.locale),
bg: colorOrUndef(o.bg),
fg: colorOrUndef(o.fg),
};
}
// Every SheetObject kind is handled above -- reachable only if a new kind
// is added to the union without a branch here (a compile-time error at
// the call site would already catch that; this is the runtime backstop).
throw new JsonImportError('Unbekannte Objektart.');
}
export function validateDocument(x: unknown): DocumentState {
if (!x || typeof x !== 'object') throw new JsonImportError('Keine gültige JSON-Struktur.');
const d = x as { version?: unknown };
if (d.version === 1) return migrateToV2(x);
if (d.version !== 2) throw new JsonImportError(`Nicht unterstützte Version: ${String(d.version)}`);
const doc = x as DocumentState;
if (!Array.isArray(doc.sheets) || doc.sheets.length === 0)
throw new JsonImportError('Dokument enthält keine Blätter.');
const sheetIds = new Set<string>();
const objectIds = new Set<string>();
const tableIds = new Set<string>();
for (const s of doc.sheets) {
if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects))
throw new JsonImportError('Mindestens ein Blatt ist fehlerhaft.');
if (sheetIds.has(s.id)) throw new JsonImportError('Doppelte Blatt-ID.');
sheetIds.add(s.id);
for (const o of s.objects) {
if (!isTableObject(o) && !isChartObject(o) && !isTextObject(o) && !isButtonObject(o))
throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.');
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
objectIds.add(o.id);
if (isTableObject(o)) tableIds.add(o.id);
}
}
if (typeof doc.activeSheetId !== 'string' || !sheetIds.has(doc.activeSheetId))
throw new JsonImportError('activeSheetId zeigt auf kein vorhandenes Blatt.');
return {
version: 2,
sheets: doc.sheets.map((s) => ({
id: s.id,
name: s.name,
objects: s.objects.map(normaliseObject),
locale: localeOrUndef(s.locale),
background: colorOrUndef(s.background),
foreground: colorOrUndef(s.foreground),
showGrid: boolOrUndef(s.showGrid),
gridSize: gridSizeOrUndef(s.gridSize),
zoom: zoomOrUndef(s.zoom),
})),
activeSheetId: doc.activeSheetId,
activeObjectId:
doc.activeObjectId && objectIds.has(doc.activeObjectId) ? doc.activeObjectId : null,
selection:
doc.selection && objectIds.has(doc.selection.tableId)
? normaliseSelection(doc.selection)
: null,
locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE,
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
customFunctions: normaliseCustomFunctions(doc.customFunctions),
namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds),
macros: normaliseMacros(doc.macros),
};
}
// ---- v1 -> v2 migration --------------------------------------------
interface V1Sheet {
id: string;
name: string;
rows: number;
cols: number;
colWidths?: Record<number, number>;
cells: Record<string, string>;
}
interface V1Doc {
version: 1;
sheets: V1Sheet[];
activeSheetId: string;
selection?: { sheetId: string; anchor: { col: number; row: number }; focus: { col: number; row: number } } | null;
}
let migSeq = 0;
const migId = (p: string) => `${p}-mig-${Date.now().toString(36)}-${migSeq++}`;
export function migrateToV2(x: unknown): DocumentState {
const v1 = x as V1Doc;
if (!Array.isArray(v1.sheets) || v1.sheets.length === 0)
throw new JsonImportError('v1-Dokument enthält keine Tabellen.');
const sheetIdMap = new Map<string, { sheetId: string; tableId: string }>();
const sheets: Sheet[] = v1.sheets.map((s) => {
const sheetId = migId('sheet');
const tableId = migId('tbl');
sheetIdMap.set(s.id, { sheetId, tableId });
const table: TableObject = {
kind: 'table',
id: tableId,
name: s.name, // keep the name -> cross-sheet formulas still resolve
x: 40,
y: 40,
rows: Math.max(1, Math.floor(s.rows ?? 20)),
cols: Math.max(1, Math.floor(s.cols ?? 8)),
colWidths: { ...(s.colWidths ?? {}) },
cells: { ...(s.cells ?? {}) },
view: defaultTableView(),
};
return { id: sheetId, name: s.name, objects: [table] };
});
const active = sheetIdMap.get(v1.activeSheetId) ?? [...sheetIdMap.values()][0];
const selSheet = v1.selection ? sheetIdMap.get(v1.selection.sheetId) : undefined;
return {
version: 2,
sheets,
activeSheetId: active.sheetId,
activeObjectId: active.tableId,
selection:
v1.selection && selSheet
? { tableId: selSheet.tableId, anchor: v1.selection.anchor, focus: v1.selection.focus }
: null,
locale: DEFAULT_LOCALE,
csvDelimiter: ',',
customFunctions: [],
namedRanges: [],
macros: [],
};
}
// ---- (de)serialise ------------------------------------------------
export function serializeDocument(doc: DocumentState): string {
return JSON.stringify(doc, null, 2);
}
export function parseDocument(text: string): DocumentState {
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new JsonImportError('Datei ist kein gültiges JSON.');
}
return validateDocument(data);
}
/** Give imported sheets & their objects fresh ids so they can be appended. */
export function reidSheets(doc: DocumentState): Sheet[] {
return doc.sheets.map((s, i) => {
const idMap = new Map<string, string>();
const objects = s.objects.map((o, j) => {
const nid = `imp-${Date.now().toString(36)}-${i}-${j}`;
idMap.set(o.id, nid);
return { ...o, id: nid } as SheetObject;
});
// repoint chart sources that referenced a sibling table in the same import
for (const o of objects) {
if (o.kind === 'chart' && o.sourceTableId && idMap.has(o.sourceTableId)) {
o.sourceTableId = idMap.get(o.sourceTableId)!;
}
}
return { id: `imp-sheet-${Date.now().toString(36)}-${i}`, name: s.name, objects };
});
}