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).
This commit is contained in:
wkh
2026-10-24 00:38:01 +02:00
parent 0368e96e0c
commit 0fca61eb65
19 changed files with 1420 additions and 16 deletions
+112 -5
View File
@@ -27,6 +27,7 @@ import {
parseA1,
wholeLineAt,
formatLinePart,
formulaRefSpans,
parseFormula,
parseFunctionBlock,
parseMacroBlock,
@@ -206,6 +207,25 @@ interface ClipboardData {
* the table the edited cell itself lives in (the "home" table for deciding
* whether an inserted reference needs a `Name!` qualifier).
*/
/** Colours for the references of the formula being edited, in text order
* (cycling), same idea as Excel: the n-th reference in the text and its
* outline in the grid share a colour. */
const REF_COLORS = ['#2f6fde', '#d6453d', '#8e44ad', '#1e9e5a', '#c77c02', '#0b9bb5', '#d6338a'];
/** One reference of the formula being edited, as the grid outlines it. */
export interface FormulaRefHighlight {
/** position among the formula's references -- what `moveFormulaRef` takes */
index: number;
tableId: string;
c0: number;
r0: number;
c1: number;
r1: number;
color: string;
/** whole column/row: outlined, but not draggable */
whole: boolean;
}
interface FormulaEditorHandle {
tableId: string;
getState(): { text: string; start: number; end: number };
@@ -670,6 +690,12 @@ function createStore() {
* 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;
/** The cell/range the last point-mode click, drag or arrow key picked, so
* the grid can outline it while it is still the reference being chosen
* (otherwise picking with the keyboard happens completely blind). Lives
* exactly as long as `pointSpan`: typing, a caret click or closing the
* editor ends it. View state only, never synced or saved. */
const pointRect = ref<{ tableId: string; c0: number; r0: number; c1: number; r1: number } | null>(null);
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
function syncWorkbook() {
@@ -2335,7 +2361,7 @@ function createStore() {
markDirty();
}
// ---- cell formatting (alignment, number format, colours, bold/italic) ---
// ---- cell formatting (alignment, number format, colours, bold/italic/underline) ---
const fmtKey = (c: number, r: number) => `${c},${r}`;
function cellFormat(tableId: string, col: number, row: number): CellFormat {
return tableById(tableId)?.formats?.[fmtKey(col, row)] ?? {};
@@ -2719,6 +2745,7 @@ function createStore() {
function registerFormulaEditor(handle: FormulaEditorHandle | null) {
formulaEditor.value = handle;
pointSpan = null;
pointRect.value = 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
@@ -2728,6 +2755,7 @@ function createStore() {
* and before picking the next). */
function noteFormulaTyped() {
pointSpan = null;
pointRect.value = null;
}
/** True while the registered editor's buffer is a formula -- point mode
* (click-to-insert-reference) only applies then. */
@@ -2893,21 +2921,97 @@ function createStore() {
pointSpan = { start: span.start, end: span.start + text.length };
return true;
}
/** The table a reference in the edited formula points at: its own table
* when unqualified, else by name -- preferring the editor's sheet when the
* name exists on several, as the engine does (Workbook.resolveTableName). */
function refTableId(homeTableId: string, qualifier: string | null, sheetQualifier?: string): string | null {
if (qualifier === null) return homeTableId;
const needle = qualifier.trim().toLowerCase();
const sheetNeedle = sheetQualifier?.trim().toLowerCase();
const candidates = allTables(doc).filter((t) => {
if (t.name.trim().toLowerCase() !== needle) return false;
if (sheetNeedle === undefined) return true;
const sid = sheetIdOfObject(t.id);
return (sid && sheetById(sid)?.name.trim().toLowerCase()) === sheetNeedle;
});
if (candidates.length <= 1) return candidates[0]?.id ?? null;
const homeSheet = sheetIdOfObject(homeTableId);
return (candidates.find((t) => sheetIdOfObject(t.id) === homeSheet) ?? candidates[0]).id;
}
/** Every cell/range reference of the formula being edited (in-cell editor
* or formula bar), resolved to its table and clamped to it, so each grid
* can outline the ones that point into it. Reactive on the editor's text:
* both editors' `getState()` read their own text ref. */
const formulaRefs = computed<FormulaRefHighlight[]>(() => {
const ed = formulaEditor.value;
if (!ed) return [];
const colorOf = new Map<string, string>();
const out: FormulaRefHighlight[] = [];
formulaRefSpans(ed.getState().text).forEach((span, index) => {
const tableId = refTableId(ed.tableId, span.qualifier, span.sheetQualifier);
const t = tableId ? tableById(tableId) : undefined;
if (!t) return;
const c0 = Math.min(span.a.col, span.b.col);
const r0 = Math.min(span.a.row, span.b.row);
if (c0 >= t.cols || r0 >= t.rows) return; // entirely outside the table
const rect = {
c0,
r0,
c1: Math.min(Math.max(span.a.col, span.b.col), t.cols - 1),
r1: Math.min(Math.max(span.a.row, span.b.row), t.rows - 1),
};
// the same reference written twice keeps one colour (as in Excel)
const key = `${t.id}|${rect.c0},${rect.r0},${rect.c1},${rect.r1}`;
if (!colorOf.has(key)) colorOf.set(key, REF_COLORS[colorOf.size % REF_COLORS.length]);
out.push({ index, tableId: t.id, ...rect, color: colorOf.get(key)!, whole: !!span.whole });
});
return out;
});
/** Rewrite the `index`-th reference of the formula being edited to cover
* (c0,r0)-(c1,r1) instead -- dragging a reference outline's edge (move)
* or corner (resize) in the grid, Excel-style. Its qualifier and `$`
* locks stay as written; a range shrunk to one cell becomes a plain cell
* reference. Whole-column/-row references are left alone. */
function moveFormulaRef(index: number, c0: number, r0: number, c1: number, r1: number): boolean {
const ed = formulaEditor.value;
if (!ed) return false;
const text = ed.getState().text;
const span = formulaRefSpans(text)[index];
if (!span || span.whole) return false;
const single = span.a.col === span.b.col && span.a.row === span.b.row;
const lockB = single ? span.a : span.b;
const lo = toA1(Math.min(c0, c1), Math.min(r0, r1), span.a.colAbs, span.a.rowAbs);
const hi = toA1(Math.max(c0, c1), Math.max(r0, r1), lockB.colAbs, lockB.rowAbs);
const body = c0 === c1 && r0 === r1 ? lo : `${lo}:${hi}`;
const next = text.slice(span.start, span.refStart) + body;
if (next === text.slice(span.start, span.end)) return true;
ed.replace(span.start, span.end, next);
// the text around the caret moved: a following grid click must insert a
// fresh reference, not overwrite a span that no longer exists
pointSpan = null;
pointRect.value = null;
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));
if (!applyPointRef(tableId, toA1(col, row))) return false;
pointRect.value = { tableId, c0: col, r0: row, c1: col, r1: row };
return true;
}
/** 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}`);
const rect = { tableId, c0: Math.min(c0, c1), r0: Math.min(r0, r1), c1: Math.max(c0, c1), r1: Math.max(r0, r1) };
const a = toA1(rect.c0, rect.r0);
const b = toA1(rect.c1, rect.r1);
if (!applyPointRef(tableId, a === b ? a : `${a}:${b}`)) return false;
pointRect.value = rect;
return true;
}
// The "active cell" is always the anchor -- the first cell of the current
@@ -3224,6 +3328,9 @@ function createStore() {
jumpToNameInput,
pointClick,
pointDrag,
pointRect,
formulaRefs,
moveFormulaRef,
// document
replaceDocument,
newDocument,