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
+34
View File
@@ -564,6 +564,40 @@ describe('clipboard: copy / cut / paste', () => {
s.cancelClipboard();
expect(s.hasClipboard.value).toBe(false);
});
it('copy leaves $-locked axes exactly as typed; a relative axis still shifts', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B2', '=$A$1+A$1+$A1+A1');
s.setSelection({ tableId: id, anchor: coords('B2'), focus: coords('B2') });
s.copySelection();
s.setSelection({ tableId: id, anchor: coords('D5'), focus: coords('D5') }); // +2 cols, +3 rows
s.pasteClipboard();
// fully locked: untouched -- row-locked: only the column shifts --
// col-locked: only the row shifts -- relative: both shift
expect(s.cellInput(id, coords('D5').col, coords('D5').row)).toBe('=$A$1+C$1+$A4+C4');
});
it('cutting a $-locked reference still follows the moved cell', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '5'); // stays put -- referenced, not cut
setCell(s, name, 'B1', '10'); // part of the cut block (B1:B2)
setCell(s, name, 'B2', '=$B$1+$A$1'); // one $-locked ref moves with the cut, one doesn't
s.setSelection({ tableId: id, anchor: coords('B1'), focus: coords('B2') });
s.cutSelection();
s.setSelection({ tableId: id, anchor: coords('D1'), focus: coords('D1') });
s.pasteClipboard();
expect(s.cellInput(id, coords('D2').col, coords('D2').row)).toBe('=$D$1+$A$1');
expect(val(s, name, 'D2')).toBe(15);
});
});
describe('text box objects', () => {