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
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useDocument, type DocumentStore } from '../document';
import { isError, toA1 } from '../../engine';
import { serializeDocument, parseDocument } from '../../io/json';
@@ -489,6 +490,16 @@ describe('cell formatting, number formats, row height', () => {
expect(s.tableById(id)!.rowHeights).toBeUndefined();
});
it('underline is a cell format like bold and survives a JSON round-trip', () => {
const s = fresh();
const id = tableId(s, 'Umsatz');
s.setRangeFormat(id, { col: 0, row: 0 }, { col: 1, row: 1 }, { underline: true, italic: true });
s.replaceDocument(parseDocument(serializeDocument(s.snapshot())));
expect(s.cellFormat(id, 1, 1)).toEqual({ underline: true, italic: true });
s.setRangeFormat(id, { col: 0, row: 0 }, { col: 1, row: 1 }, { underline: false });
expect(s.cellFormat(id, 0, 0)).toEqual({ italic: true });
});
it('formatting + row heights survive a JSON round-trip', () => {
const s = fresh();
const id = tableId(s, 'Umsatz');
@@ -2397,6 +2408,27 @@ describe('formula "point mode" (click/drag a cell to insert a reference)', () =>
expect(ed.text).toBe('=B2');
});
it('pointRect outlines the picked cell/range until typing or closing the editor', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const ed = mockEditor(id, '=');
s.registerFormulaEditor(ed.handle);
expect(s.pointRect.value).toBeNull();
s.pointClick(id, 1, 2);
expect(s.pointRect.value).toEqual({ tableId: id, c0: 1, r0: 2, c1: 1, r1: 2 });
s.pointDrag(id, 2, 3, 0, 1); // dragged up-left: normalised
expect(s.pointRect.value).toEqual({ tableId: id, c0: 0, r0: 1, c1: 2, r1: 3 });
s.noteFormulaTyped();
expect(s.pointRect.value).toBeNull();
s.pointClick(id, 0, 0);
s.registerFormulaEditor(null);
expect(s.pointRect.value).toBeNull();
// no formula in the buffer -> nothing picked, nothing outlined
s.registerFormulaEditor(mockEditor(id, 'text').handle);
s.pointClick(id, 0, 0);
expect(s.pointRect.value).toBeNull();
});
it('typing between two clicks makes the second an insert, not a replace', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
@@ -3016,3 +3048,69 @@ describe('whole-column / whole-row references', () => {
expect(val(s, name, 'A1')).toBe(6);
});
});
describe('references of the formula being edited (coloured, draggable)', () => {
afterEach(() => useDocument().registerFormulaEditor(null));
/** Like mockEditor(), but its text is a ref -- as in the real editors,
* which is what keeps store.formulaRefs live. */
function reactiveEditor(tableId: string, initialText: string) {
const text = ref(initialText);
return {
handle: {
tableId,
getState: () => ({ text: text.value, start: text.value.length, end: text.value.length }),
replace: (s: number, e: number, value: string) => {
text.value = text.value.slice(0, s) + value + text.value.slice(e);
},
},
text,
};
}
it('lists each reference with its table, rectangle and colour, following the text', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const other = s.addTable(s.doc.activeSheetId);
s.renameObject(other, 'Reisen');
const ed = reactiveEditor(id, '=SUMME(B2:C3)+A1+Reisen!A2+B2:C3');
s.registerFormulaEditor(ed.handle);
const refs = s.formulaRefs.value;
expect(refs.map((r) => [r.tableId, r.c0, r.r0, r.c1, r.r1])).toEqual([
[id, 1, 1, 2, 2],
[id, 0, 0, 0, 0],
[other, 0, 1, 0, 1],
[id, 1, 1, 2, 2],
]);
expect(refs[0].color).not.toBe(refs[1].color);
expect(refs[3].color).toBe(refs[0].color); // same reference, same colour
ed.text.value = '=D4';
expect(s.formulaRefs.value.map((r) => [r.c0, r.r0])).toEqual([[3, 3]]);
s.registerFormulaEditor(null);
expect(s.formulaRefs.value).toEqual([]);
});
it('whole columns are clamped to the table and marked as not draggable', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
s.registerFormulaEditor(reactiveEditor(id, '=SUMME(B:B)').handle);
const [r] = s.formulaRefs.value;
expect(r).toMatchObject({ c0: 1, r0: 0, c1: 1, r1: s.tableById(id)!.rows - 1, whole: true });
expect(s.moveFormulaRef(0, 2, 0, 2, 3)).toBe(false);
});
it('moveFormulaRef rewrites one reference, keeping qualifier and $ locks', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const other = s.addTable(s.doc.activeSheetId);
s.renameObject(other, 'Reisen');
const ed = reactiveEditor(id, '=SUMME($A$1:B2)*Reisen!C3');
s.registerFormulaEditor(ed.handle);
s.moveFormulaRef(0, 1, 1, 2, 3); // moved/resized
expect(ed.text.value).toBe('=SUMME($B$2:C4)*Reisen!C3');
s.moveFormulaRef(1, 0, 0, 1, 1); // a single cell grown into a range
expect(ed.text.value).toBe('=SUMME($B$2:C4)*Reisen!A1:B2');
s.moveFormulaRef(0, 3, 3, 3, 3); // shrunk to one cell
expect(ed.text.value).toBe('=SUMME($D$4)*Reisen!A1:B2');
});
});
+21 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { dateToSerial, timeSerial } from '../../engine';
import { formatValue } from '../format';
import { formatValue, readableTextOn } from '../format';
describe('formatValue: date/time', () => {
const day = dateToSerial(2026, 9, 2); // a Wednesday
@@ -46,3 +46,23 @@ describe('formatValue: date/time', () => {
expect(formatValue(day, 'plain', 'dateiso')).toBe('2026-09-02');
});
});
describe('readableTextOn', () => {
it('light text on dark fills, dark text on light fills', () => {
expect(readableTextOn('#000000')).toBe('#f5f5f7');
expect(readableTextOn('#000')).toBe('#f5f5f7');
expect(readableTextOn('#1b1b1b')).toBe('#f5f5f7');
expect(readableTextOn('#0057ff')).toBe('#f5f5f7');
expect(readableTextOn('#ffffff')).toBe('#1d1d1f');
expect(readableTextOn('#ffd400')).toBe('#1d1d1f');
expect(readableTextOn('#FFF')).toBe('#1d1d1f');
});
it('ignores what it cannot read, and mostly transparent fills', () => {
expect(readableTextOn(undefined)).toBeNull();
expect(readableTextOn('red')).toBeNull();
expect(readableTextOn('#12345')).toBeNull();
expect(readableTextOn('#00000020')).toBeNull();
expect(readableTextOn('#000000cc')).toBe('#f5f5f7');
});
});
+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,
+32
View File
@@ -165,3 +165,35 @@ export function rawString(v: CellValue): string {
export function isNumeric(v: CellValue): boolean {
return typeof v === 'number' && Number.isFinite(v);
}
// ---- readable text on a fill ---------------------------------------------
const DARK_TEXT = '#1d1d1f';
const LIGHT_TEXT = '#f5f5f7';
/** sRGB channel (0–255) → linear light, per WCAG 2.x */
const linear = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
/**
* Text colour that stays readable on the fill `bg` (#rgb, #rgba, #rrggbb,
* #rrggbbaa): dark or light, whichever contrasts more (WCAG luminance).
* Used when a cell or table has a fill but no text colour of its own -- the
* theme's text colour would otherwise land on it regardless (black text on
* a black fill). `null` when `bg` can't be read or is mostly transparent
* (the theme's surface shows through, its text colour already fits).
*/
export function readableTextOn(bg: string | undefined | null): string | null {
const m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec((bg ?? '').trim());
if (!m) return null;
let hex = m[1];
if (hex.length <= 4) hex = [...hex].map((ch) => ch + ch).join('');
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16));
const alpha = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
if (alpha < 0.5) return null;
const lum = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
// contrast with black vs. with white: (L + 0.05) / 0.05 vs 1.05 / (L + 0.05)
return (lum + 0.05) / 0.05 >= 1.05 / (lum + 0.05) ? DARK_TEXT : LIGHT_TEXT;
}
+1
View File
@@ -42,6 +42,7 @@ export interface CellFormat {
bg?: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
}
/** How a table object is sized on the canvas. */