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,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
parseFormula,
completeFormula,
parseFunctionBlock,
parseMacroBlock,
collectRefs,
@@ -489,3 +490,21 @@ describe('collectRefsInBlock -- reference walk used by the macro save-time quali
expect(refs).toHaveLength(2);
});
});
describe('completeFormula -- closing what Enter left open', () => {
it('appends missing closing parentheses', () => {
expect(completeFormula('=SUMME(A1:A5')).toBe('=SUMME(A1:A5)');
expect(completeFormula('=RUNDEN(SUMME(A1;B1)*(2+3')).toBe('=RUNDEN(SUMME(A1;B1)*(2+3))');
expect(completeFormula('=SUMME(A1 ')).toBe('=SUMME(A1 )');
});
it('closes an open text literal before the parentheses', () => {
expect(completeFormula('=WENN(A1>0;"ja')).toBe('=WENN(A1>0;"ja")');
});
it('leaves valid formulas, plain text and unfixable input alone', () => {
expect(completeFormula('=SUMME(A1:A5)')).toBe('=SUMME(A1:A5)');
expect(completeFormula('Hallo (Welt')).toBe('Hallo (Welt');
expect(completeFormula('=SUMME(A1+')).toBe('=SUMME(A1+');
expect(completeFormula('=A1)')).toBe('=A1)');
expect(completeFormula('=(')).toBe('=(');
});
});
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret } from '../refs';
import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret, formulaRefSpans } from '../refs';
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
it('parseA1 reports which axes are $-locked', () => {
@@ -121,3 +121,30 @@ describe('currentIdentifierAtCaret -- function-name autocomplete detection', ()
expect(currentIdentifierAtCaret('=SUM("x', 7)).toBeNull();
});
});
describe('formulaRefSpans -- references with their position in the text', () => {
const texts = (f: string) => formulaRefSpans(f).map((s) => f.slice(s.start, s.end));
it('finds cells, ranges and whole lines in text order', () => {
expect(texts('=SUMME(A1:B3;$C$2)*D4+B:B')).toEqual(['A1:B3', '$C$2', 'D4', 'B:B']);
const [r] = formulaRefSpans('=A1:$B$3');
expect(r.a).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: false });
expect(r.b).toEqual({ col: 1, row: 2, colAbs: true, rowAbs: true });
expect(formulaRefSpans('=2:5')[0]).toMatchObject({ whole: 'row', a: { row: 1 }, b: { row: 4 } });
});
it('includes the qualifier in the span and reports it separately', () => {
const f = "='Kosten Q3'!A1+Analyse!Umsatz::B2:C4";
expect(texts(f)).toEqual(["'Kosten Q3'!A1", 'Analyse!Umsatz::B2:C4']);
const [q, s] = formulaRefSpans(f);
expect(q).toMatchObject({ qualifier: 'Kosten Q3', refStart: f.indexOf('A1') });
expect(s).toMatchObject({ qualifier: 'Umsatz', sheetQualifier: 'Analyse' });
});
it('skips function names, strings and named ranges; tolerates unfinished input', () => {
expect(texts('=LOG10(A1)&"B2"&Umsatz_Q1')).toEqual(['A1']);
expect(texts('=SUMME(A1:A5;B')).toEqual(['A1:A5']);
expect(texts('=WENN(A1;"offen')).toEqual([]);
expect(texts('A1')).toEqual([]);
});
});
+3
View File
@@ -8,6 +8,7 @@
export { tokenize, LexError, type Token, type TokenType } from './lexer';
export {
parseFormula,
completeFormula,
parseFunctionBlock,
parseMacroBlock,
collectRefs,
@@ -37,6 +38,8 @@ export {
WHOLE_LINE_END,
cycleReferenceLocks,
currentIdentifierAtCaret,
formulaRefSpans,
type RefSpan,
} from './refs';
export {
Workbook,
+47
View File
@@ -763,6 +763,53 @@ export function parseFormula(src: string, paramNames?: readonly string[]): Node
return new Parser(toks, paramNames, paramNames === undefined).parse();
}
/**
* Repair the one mistake that is almost always just forgotten typing: a
* formula committed with Enter while parentheses (or a "..." text literal)
* are still open, e.g. `=SUMME(A1:A5` or `=WENN(A1>0;"ja`. Same as Excel and
* Numbers, the missing closers are appended at the end.
*
* Deliberately conservative: anything that already parses, isn't a formula,
* or still doesn't parse after closing (`=SUMME(A1;`, a stray `)` too many)
* comes back unchanged, so the cell shows its usual parse error instead of a
* "repair" that guessed something the user never meant.
*/
export function completeFormula(input: string): string {
if (!input.startsWith('=')) return input;
const parses = (src: string) => {
try {
parseFormula(src);
return true;
} catch {
return false;
}
};
if (parses(input)) return input;
let fixed = input;
let toks: Token[];
try {
toks = tokenize(fixed.slice(1));
} catch (e) {
// an unterminated "..." literal swallows everything up to the end, so
// closing it first is the only way to count the parentheses before it
if (!(e instanceof LexError) || !e.message.startsWith('Unterminated string')) return input;
fixed += '"';
try {
toks = tokenize(fixed.slice(1));
} catch {
return input;
}
}
let depth = 0;
for (const t of toks) {
if (t.type === 'LPAREN') depth++;
else if (t.type === 'RPAREN' && --depth < 0) return input;
}
fixed += ')'.repeat(depth);
return fixed !== input && parses(fixed) ? fixed : input;
}
/**
* Parse a "block" function body -- the statement grammar (assignments,
* `FOR`/`DO`/`END`, `IF`/`THEN`/`ELSE`/`END`, `RETURN` anywhere) used by a
+96
View File
@@ -174,6 +174,102 @@ export function currentIdentifierAtCaret(
return { text: last.value, start: last.pos + 1 };
}
/** One cell/range reference found in a formula's text -- see {@link formulaRefSpans}. */
export interface RefSpan {
/** offsets into the formula (including its leading "="): `start` is where
* the qualifier (if any) begins, `refStart` where the A1 part begins */
start: number;
refStart: number;
end: number;
qualifier: string | null;
sheetQualifier?: string;
/** both corners as written (`end` equals `start` for a single cell) */
a: { col: number; row: number; colAbs: boolean; rowAbs: boolean };
b: { col: number; row: number; colAbs: boolean; rowAbs: boolean };
/** a whole-column/whole-row range (`B:B`, `2:5`); its open axis runs to
* {@link WHOLE_LINE_END} */
whole?: LineAxis;
}
/**
* Every cell/range reference in a formula, in text order, with where it sits
* in the text -- what the grid colours while a formula is edited (and lets
* the user drag to change it, Excel-style). Same token-level reading of the
* `reference` grammar rule the parser applies (optional `Blatt!Tabelle::` /
* `Tabelle!` qualifier, `A1`, `A1:B2`, `B:B`), but tolerant: an incomplete
* formula still yields the references written so far. Named ranges are left
* out (they have no position of their own to drag); a formula that doesn't
* even tokenize (open "..." literal) yields nothing.
*/
export function formulaRefSpans(formula: string): RefSpan[] {
if (!formula.startsWith('=')) return [];
let toks: Token[];
try {
toks = tokenize(formula.slice(1));
} catch {
return [];
}
const isName = (t: Token | undefined) => !!t && (t.type === 'IDENT' || t.type === 'QUOTED');
const out: RefSpan[] = [];
let i = 0;
while (i < toks.length) {
const first = toks[i];
let j = i;
let qualifier: string | null = null;
let sheetQualifier: string | undefined;
if (isName(toks[j]) && toks[j + 1]?.type === 'BANG' && isName(toks[j + 2]) && toks[j + 3]?.type === 'DBLCOLON') {
sheetQualifier = toks[j].value;
qualifier = toks[j + 2].value;
j += 4;
} else if (isName(toks[j]) && (toks[j + 1]?.type === 'BANG' || toks[j + 1]?.type === 'DBLCOLON')) {
qualifier = toks[j].value;
j += 2;
}
const line = wholeLineAt(toks, j);
const cell = toks[j];
if (line) {
const max = WHOLE_LINE_END;
const isCol = line.axis === 'col';
out.push({
start: first.pos + 1,
refStart: line.a.pos + 1,
end: line.b.pos + line.b.value.length + 1,
qualifier,
sheetQualifier,
a: { col: isCol ? line.start : 0, row: isCol ? 0 : line.start, colAbs: isCol && line.startAbs, rowAbs: !isCol && line.startAbs },
b: { col: isCol ? line.end : max, row: isCol ? max : line.end, colAbs: isCol && line.endAbs, rowAbs: !isCol && line.endAbs },
whole: line.axis,
});
i = j + 3;
continue;
}
if (cell?.type === 'IDENT' && looksLikeCell(cell.value) && toks[j + 1]?.type !== 'LPAREN') {
const a = parseA1(cell.value);
let last = cell;
let b = a;
const other = toks[j + 2];
if (toks[j + 1]?.type === 'COLON' && other?.type === 'IDENT' && looksLikeCell(other.value)) {
b = parseA1(other.value);
last = other;
j += 2;
}
out.push({
start: first.pos + 1,
refStart: cell.pos + 1,
end: last.pos + last.value.length + 1,
qualifier,
sheetQualifier,
a,
b,
});
i = j + 1;
continue;
}
i++;
}
return out;
}
// ---- whole-column / whole-row references (`B:B`, `A:C`, `1:1`, `$2:$5`) ----
/** Stand-in for "the last row/column" in a whole-column/whole-row range's