- 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).
357 lines
14 KiB
TypeScript
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;
|
|
}
|