Formulas accept B:B / A:C / $B:$B and 1:1 / 2:5 / $3:$3, also qualified
(Umsatz!B:B, Umsatz::1:1, 'Blatt 2'!Kosten::A:C). The open end is clamped
to the table's current size wherever cells are read (evaluation, the
dependency graph, SETVALUE targets, FOR sources), and typing past the
table's end now rebuilds the workbook so such a reference covers the new
cells. The Name Box selects the whole column/row.
The token-level rewriters handle the new shape: copy/fill shifts B:B only
sideways and 1:1 only up/down ($ stays), column insert/delete moves or
#REFs B:B like a range (rows leave it alone, and vice versa), and a table
rename rewrites its qualifier.
Also fixes renaming a table referenced with a quoted name ('Tabelle 1'!B2):
the rewriter replaced only as many characters as the unquoted name has,
leaving a stray quote (=Daten1'!B2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
261 lines
11 KiB
TypeScript
261 lines
11 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 };
|
|
}
|
|
|
|
// ---- 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;
|
|
}
|