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');
});
});