Support $-locked references: relative, mixed and absolute

The engine already anticipated $A1/A$1/$A$1 in its grammar comment, but the
lexer had no rule for '$' at all -- any formula using it failed to parse.

- lexer.ts: '$' is now a valid word character, so "$A$1" tokenizes as one
  IDENT, same as any other bare word (the lexer still doesn't interpret it,
  per its "dumb lexer" design -- the parser/evaluator stay untouched, since
  evaluation is purely positional and $ has no effect on a value).
- refs.ts: parseA1()/toA1() now carry colAbs/rowAbs through the $ markers
  (backward compatible -- existing {col,row} destructuring is unaffected).
  Added cycleReferenceLock() for Excel/Numbers-style F4: relative ->
  absolute -> row-locked -> col-locked -> relative, toggling whichever
  reference sits at the caret (and the correct side of an A1:B2 range).
- document.ts: shiftFormula() now honors the lock on copy (a locked axis
  stays exactly as typed) but not on cut (a moved cell's reference follows
  it regardless of $, since cut relocates the cell itself rather than
  filling a relative offset).
- TableGrid.vue's inline cell editor and FormulaBar.vue both wire up F4 via
  the shared cycleReferenceLock() helper.
- Tests: lexer/parser coverage for $ tokenizing/parsing, a new refs.spec.ts
  for parseA1/toA1 round-tripping and the F4 cycle (incl. range side-
  picking), and document.spec.ts cases for locked-axis copy vs. cut.
- README.md / src/engine/README.md: documented the $ semantics, F4, and
  corrected a couple of now-stale claims (test count, "$ is ignored, there's
  no copy/fill", "active cell is always the last-clicked one").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 10:11:10 +02:00
co-authored by Claude Sonnet 5
parent e45cdfd077
commit 8e43cc294c
12 changed files with 247 additions and 31 deletions
+13 -11
View File
@@ -99,14 +99,16 @@ interface ClipboardData {
* Rewrite every cell reference in a formula by (dCol, dRow) -- the same
* relative-reference adjustment Excel/Numbers apply on copy.
*
* `restrict`, when given (a cut/move), narrows this to *only* references that
* point inside that rectangle -- i.e. cells that moved along with the formula.
* A cut otherwise leaves references untouched, since the cells they point at
* did not move (and a qualified `Table!A1` reference is never "inside" a
* local rectangle, so it is always left alone during a cut).
* A `$`-locked axis (`$A1`, `A$1`, `$A$1`) is exempt from a *copy*'s shift --
* that is the whole point of locking a reference -- but not from a *cut*'s:
* a cut moves the referenced cell itself, so any reference to it, `$` or not,
* must follow to keep pointing at the same (now relocated) cell.
*
* The formula engine has no absolute (`$A$1`) references, so every local
* reference is relative and eligible for this adjustment.
* `restrict`, when given (a cut/move), narrows the shift to *only* references
* that point inside that rectangle -- i.e. cells that moved along with the
* formula. A cut otherwise leaves references untouched, since the cells they
* point at did not move (and a qualified `Table!A1` reference is never
* "inside" a local rectangle, so it is always left alone during a cut).
*/
function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect): string {
if (!raw.startsWith('=') || (dCol === 0 && dRow === 0)) return raw;
@@ -127,7 +129,7 @@ function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect):
const prev = toks[i - 1];
const qualified = !!prev && (prev.type === 'BANG' || prev.type === 'DBLCOLON');
if (restrict && qualified) continue; // cut: a reference into another table never moved
const { col, row } = parseA1(tok.value);
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
if (
restrict &&
!qualified &&
@@ -135,9 +137,9 @@ function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect):
) {
continue;
}
const nc = Math.max(0, col + dCol);
const nr = Math.max(0, row + dRow);
out += body.slice(last, tok.pos) + toA1(nc, nr);
const nc = !restrict && colAbs ? col : Math.max(0, col + dCol);
const nr = !restrict && rowAbs ? row : Math.max(0, row + dRow);
out += body.slice(last, tok.pos) + toA1(nc, nr, colAbs, rowAbs);
last = tok.pos + tok.value.length;
}
out += body.slice(last);