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:
@@ -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('=(');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user