Files
numolus/app/frontend/engine/refs.ts
T
wkh 0fca61eb65 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).
2026-10-24 00:38:01 +02:00

357 lines
14 KiB
TypeScript

/**
* Reference helpers: A1 <-> (col,row) conversion and range expansion.
*
* All coordinates the engine uses internally are 0-based. The A1 notation
* exposed to the user is 1-based for rows and letter-based for columns
* (A=0, B=1, ..., Z=25, AA=26, ...).
*/
import { tokenize, type Token } from './lexer';
const CELL_RE = /^(\$?)([A-Za-z]{1,3})(\$?)([0-9]{1,7})$/;
/** "A" -> 0, "B" -> 1, "Z" -> 25, "AA" -> 26 ... */
export function colToNumber(letters: string): number {
let n = 0;
const up = letters.toUpperCase();
for (let i = 0; i < up.length; i++) {
n = n * 26 + (up.charCodeAt(i) - 64); // 'A' -> 1
}
return n - 1;
}
/** 0 -> "A", 25 -> "Z", 26 -> "AA" ... */
export function numberToCol(n: number): string {
let s = '';
let x = n + 1;
while (x > 0) {
const rem = (x - 1) % 26;
s = String.fromCharCode(65 + rem) + s;
x = Math.floor((x - 1) / 26);
}
return s;
}
/** True if `text` looks like a bare cell reference (e.g. "A1", "$B$2", "aa10"). */
export function looksLikeCell(text: string): boolean {
return CELL_RE.test(text);
}
/**
* Parse "A1" / "$A1" / "A$1" / "$A$1" -> 0-based coords plus which axes are
* `$`-locked (absolute). A locked axis is exempt from the reference shift a
* copy/fill applies (see `shiftFormula` in the store); an unlocked one is
* "relative" and always shifts with it. Throws for malformed input.
*/
export function parseA1(
text: string,
): { col: number; row: number; colAbs: boolean; rowAbs: boolean } {
const m = CELL_RE.exec(text);
if (!m) throw new Error(`Bad cell reference: ${text}`);
return { col: colToNumber(m[2]), row: Number(m[4]) - 1, colAbs: m[1] === '$', rowAbs: m[3] === '$' };
}
/** Build "A1" from 0-based coordinates; `colAbs`/`rowAbs` add the `$` lock markers. */
export function toA1(col: number, row: number, colAbs = false, rowAbs = false): string {
return `${colAbs ? '$' : ''}${numberToCol(col)}${rowAbs ? '$' : ''}${row + 1}`;
}
/**
* Excel/Numbers-style F4: cycle every cell reference through
* relative -> absolute -> row-locked -> column-locked -> relative, each
* from its own current state.
*
* With a collapsed selection (`selStart === selEnd`, the common case -- just
* a caret), this toggles the single reference at/adjacent to it. With a real
* text selection, it toggles *every* reference the selection overlaps at
* once -- select a stretch of formula text spanning several references and
* F4 adjusts them all in one press, same as Excel.
*
* `selStart`/`selEnd` are 0-based offsets into `formula` (which must start
* with "="), i.e. straight from an `<input>`/`<textarea>`'s
* `selectionStart`/`selectionEnd`. Returns the rewritten formula and a
* selection re-spanning the (possibly resized) references, so a repeated F4
* keeps cycling the same ones; `null` when none were found.
*/
export function cycleReferenceLocks(
formula: string,
selStart: number,
selEnd: number,
): { text: string; start: number; end: number } | null {
if (!formula.startsWith('=')) return null;
const body = formula.slice(1);
const lo = Math.min(selStart, selEnd) - 1;
const hi = Math.max(selStart, selEnd) - 1;
const collapsed = lo === hi;
let toks: Token[];
try {
toks = tokenize(body);
} catch {
return null;
}
const targets: Token[] = [];
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
if (t.type !== 'IDENT' || !looksLikeCell(t.value)) continue;
const next = toks[i + 1];
if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell
const tEnd = t.pos + t.value.length;
const hit = collapsed ? lo >= t.pos && lo <= tEnd : t.pos < hi && tEnd > lo;
if (hit) targets.push(t);
}
if (!targets.length) return null;
let out = '';
let last = 0;
let start = -1;
let end = -1;
for (const tok of targets) {
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
const [nColAbs, nRowAbs] = nextLock(colAbs, rowAbs);
const rewritten = toA1(col, row, nColAbs, nRowAbs);
out += body.slice(last, tok.pos) + rewritten;
if (start === -1) start = out.length - rewritten.length;
end = out.length;
last = tok.pos + tok.value.length;
}
out += body.slice(last);
// a collapsed caret in -> a collapsed caret out (after the one toggled
// reference), same as before; a real selection in keeps spanning every
// reference it touched, so a repeated F4 keeps cycling all of them.
return collapsed
? { text: '=' + out, start: 1 + end, end: 1 + end }
: { text: '=' + out, start: 1 + start, end: 1 + end };
}
/** A1 -> $A$1 -> A$1 -> $A1 -> A1 -> ... (the classic F4 cycle). */
function nextLock(colAbs: boolean, rowAbs: boolean): [boolean, boolean] {
if (!colAbs && !rowAbs) return [true, true];
if (colAbs && rowAbs) return [false, true];
if (!colAbs && rowAbs) return [true, false];
return [false, false]; // colAbs && !rowAbs
}
/**
* If the caret sits inside (anywhere within, or right at the end of) a bare
* identifier being typed in a formula -- not yet a resolved cell reference,
* not the start of an already-open function call -- return the identifier's
* text *up to the caret* and its start offset in `formula` (which still
* includes the leading "="). Drives function-name autocomplete; `null`
* means "nothing to suggest here" (not a formula at all, a cell reference
* like "A1", the caret sitting in whitespace or right after a completed
* token rather than still touching it, ...).
*
* Same shape as {@link cycleReferenceLocks} above: strip the leading "=",
* tokenize, and inspect the token(s) around the caret -- except here only
* the prefix up to the caret is tokenized (nothing after it is ever looked
* at, same as how autocomplete works in most editors), so the "identifier"
* found is always exactly what's to the *left* of the caret, regardless of
* whether more of the same word continues to its right.
*/
export function currentIdentifierAtCaret(
formula: string,
caret: number,
): { text: string; start: number } | null {
if (!formula.startsWith('=')) return null;
const pos = caret - 1; // caret position relative to body = formula.slice(1)
if (pos < 0) return null;
let toks: Token[];
try {
toks = tokenize(formula.slice(1, caret));
} catch {
return null;
}
// tokenize() appends a trailing EOF marker -- skip it to get the last
// *real* token.
const real = toks.filter((t) => t.type !== 'EOF');
const last = real[real.length - 1];
if (!last || last.type !== 'IDENT') return null;
// the token must reach all the way to the caret -- if it doesn't, the
// caret has moved past it (trailing whitespace, e.g. "=SUM |") and
// there's no word actively being typed right at the caret any more.
if (last.pos + last.value.length !== pos) return null;
if (looksLikeCell(last.value)) return null; // looks like a cell reference, not a function name
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
* open end. Every consumer clamps it to the actual table size (see
* {@link rangeCells}); a consumer that merely clamps to its table (the
* Name Box selection) gets the right answer for free. */
export const WHOLE_LINE_END = 9_999_998;
export type LineAxis = 'col' | 'row';
const COL_PART_RE = /^(\$?)([A-Za-z]{1,3})$/;
const ROW_PART_RE = /^(\$?)([0-9]{1,7})$/;
/** One side of a whole-line reference: `"B"`/`"$B"` (a column) or
* `"3"`/`"$3"` (a 1-based row) -> its 0-based index, or `null`. */
export function parseLinePart(text: string): { axis: LineAxis; index: number; abs: boolean } | null {
let m = COL_PART_RE.exec(text);
if (m) return { axis: 'col', index: colToNumber(m[2]), abs: m[1] === '$' };
m = ROW_PART_RE.exec(text);
if (m && Number(m[2]) >= 1) return { axis: 'row', index: Number(m[2]) - 1, abs: m[1] === '$' };
return null;
}
export function formatLinePart(axis: LineAxis, index: number, abs: boolean): string {
return (abs ? '$' : '') + (axis === 'col' ? numberToCol(index) : String(index + 1));
}
/** If `toks[i]`, `toks[i+1]`, `toks[i+2]` spell a whole-line range (`B:B`,
* `A:C`, `1:1`, `$2:$5` -- both sides the same axis), return its parts.
* Shared by the parser and every token-level formula rewriter (copy,
* row/column insert/delete, table rename), so they all agree on the shape. */
export function wholeLineAt(
toks: Token[],
i: number,
): { axis: LineAxis; a: Token; b: Token; start: number; end: number; startAbs: boolean; endAbs: boolean } | null {
const a = toks[i];
const colon = toks[i + 1];
const b = toks[i + 2];
if (!a || !colon || !b || colon.type !== 'COLON') return null;
if ((a.type !== 'IDENT' && a.type !== 'NUMBER') || (b.type !== 'IDENT' && b.type !== 'NUMBER')) return null;
const pa = parseLinePart(a.value);
const pb = parseLinePart(b.value);
if (!pa || !pb || pa.axis !== pb.axis) return null;
return { axis: pa.axis, a, b, start: pa.index, end: pb.index, startAbs: pa.abs, endAbs: pb.abs };
}
/** The cells a range covers, with a whole-column/whole-row range's open
* end clamped to its table's current size (`size` null = unknown table ->
* no cells). An ordinary range is just {@link expandRange}. */
export function rangeCells(
r: { start: { col: number; row: number }; end: { col: number; row: number }; whole?: LineAxis },
size: { cols: number; rows: number } | null,
): Array<{ col: number; row: number }> {
if (!r.whole) return expandRange(r.start, r.end);
if (!size) return [];
const clamp = (v: number, n: number) => Math.min(v, n - 1);
const start = { col: Math.min(r.start.col, r.end.col), row: Math.min(r.start.row, r.end.row) };
const end = { col: Math.max(r.start.col, r.end.col), row: Math.max(r.start.row, r.end.row) };
// a whole line entirely beyond the table covers nothing
if (r.whole === 'col' && start.col >= size.cols) return [];
if (r.whole === 'row' && start.row >= size.rows) return [];
return expandRange(start, { col: clamp(end.col, size.cols), row: clamp(end.row, size.rows) });
}
/**
* Expand a rectangular range (given by two corners, any order) into the list of
* cell coordinates it covers, row-major.
*/
export function expandRange(
a: { col: number; row: number },
b: { col: number; row: number },
): Array<{ col: number; row: number }> {
const c0 = Math.min(a.col, b.col);
const c1 = Math.max(a.col, b.col);
const r0 = Math.min(a.row, b.row);
const r1 = Math.max(a.row, b.row);
const out: Array<{ col: number; row: number }> = [];
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
out.push({ col: c, row: r });
}
}
return out;
}