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:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { cycleReferenceLock } from '../engine';
|
||||
|
||||
const store = useDocument();
|
||||
const draft = ref('');
|
||||
@@ -31,6 +32,15 @@ function onEscape() {
|
||||
draft.value = store.activeCellInput.value;
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
/** F4: cycle relative -> absolute -> row-locked -> col-locked for the cell
|
||||
* reference at the caret (Excel/Numbers "Bezug sperren"). */
|
||||
function onF4(e: KeyboardEvent) {
|
||||
const el = e.target as HTMLInputElement;
|
||||
const res = cycleReferenceLock(draft.value, el.selectionStart ?? draft.value.length);
|
||||
if (!res) return;
|
||||
draft.value = res.text;
|
||||
nextTick(() => el.setSelectionRange(res.caret, res.caret));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -49,6 +59,7 @@ function onEscape() {
|
||||
@blur="onBlur"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
@keydown.esc.prevent="onEscape"
|
||||
@keydown.f4.prevent="onF4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* in-cell editing and per-column resize.
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||
import { numberToCol } from '../engine';
|
||||
import { numberToCol, cycleReferenceLock } from '../engine';
|
||||
import { useDocument } from '../store/document';
|
||||
import { DEFAULT_ROW_H } from '../store/types';
|
||||
import ContextMenu from './ContextMenu.vue';
|
||||
@@ -472,9 +472,22 @@ function onEditKeydown(e: KeyboardEvent) {
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
} else if (e.key === 'F4') {
|
||||
e.preventDefault();
|
||||
toggleReferenceLock();
|
||||
}
|
||||
e.stopPropagation();
|
||||
}
|
||||
/** F4: cycle relative -> absolute -> row-locked -> col-locked for the cell
|
||||
* reference at the caret (Excel/Numbers "Bezug sperren"). */
|
||||
function toggleReferenceLock() {
|
||||
const el = editInput.value;
|
||||
if (!el) return;
|
||||
const res = cycleReferenceLock(editValue.value, el.selectionStart ?? editValue.value.length);
|
||||
if (!res) return;
|
||||
editValue.value = res.text;
|
||||
nextTick(() => el.setSelectionRange(res.caret, res.caret));
|
||||
}
|
||||
function onEditBlur() {
|
||||
if (!committing && editing.value) commitEdit();
|
||||
}
|
||||
|
||||
+13
-5
@@ -15,7 +15,7 @@ löst `Umsatz!B2` (bzw. `Umsatz::B2`) von jedem Blatt aus identisch auf.
|
||||
| `parser.ts` | Recursive-Descent → AST. Grammatik als EBNF-Kommentarblock im Kopf der Datei. |
|
||||
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. |
|
||||
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
|
||||
| `refs.ts` | A1 ↔ `(col,row)`, Spaltenbuchstaben, Bereichs-Expansion. |
|
||||
| `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLock`). |
|
||||
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. |
|
||||
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. |
|
||||
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
|
||||
@@ -58,10 +58,18 @@ CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||
|
||||
Präzedenz, schwach → stark: `+ -` < `* /` < unäres `+ -` < `^`.
|
||||
|
||||
`$` in Referenzen wird toleriert, aber ignoriert (es gibt kein Kopieren/Füllen,
|
||||
alle Bezüge sind relativ). Tabellennamen werden **case-insensitiv** und
|
||||
whitespace-tolerant aufgelöst: `Kosten!A2`, `'Kosten'!A2`, `kosten!a2`,
|
||||
`Kosten::A2` sind identisch.
|
||||
`$` sperrt eine Referenz-Achse: `$A1` (Spalte fix), `A$1` (Zeile fix), `$A$1`
|
||||
(beide fix) vs. `A1` (relativ). Für die **Auswertung** ist das irrelevant – sie
|
||||
ist rein positionsbasiert, `$` wird geparst und sonst ignoriert. Es wirkt sich
|
||||
ausschließlich beim **Kopieren/Ausschneiden** aus (`shiftFormula` in
|
||||
`src/store/document.ts`, außerhalb der Engine): eine gesperrte Achse bleibt
|
||||
beim *Kopieren* exakt wie eingegeben stehen, folgt aber beim *Ausschneiden*
|
||||
trotzdem der verschobenen Zelle (dort verschiebt sich die referenzierte Zelle
|
||||
selbst – `$` ändert nur, ob eine *Kopie* mitzieht, nicht wohin ein *Verschieben*
|
||||
zeigt). `refs.ts#cycleReferenceLock` implementiert den `F4`-Zyklus
|
||||
(relativ → `$A$1` → `A$1` → `$A1` → relativ) für Formelleiste und Zell-Editor.
|
||||
Tabellennamen werden **case-insensitiv** und whitespace-tolerant aufgelöst:
|
||||
`Kosten!A2`, `'Kosten'!A2`, `kosten!a2`, `Kosten::A2` sind identisch.
|
||||
|
||||
## Auswertungsmodell
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ describe('lexer', () => {
|
||||
expect(tokenize('"a\\"b"')[0]).toMatchObject({ type: 'STRING', value: 'a"b' });
|
||||
});
|
||||
|
||||
it('reads $-locked cell references as one IDENT', () => {
|
||||
expect(types('$A$1 A$1 $A1')).toEqual(['IDENT', 'IDENT', 'IDENT', 'EOF']);
|
||||
expect(values('$A$1 A$1 $A1')).toEqual(['$A$1', 'A$1', '$A1', '']);
|
||||
});
|
||||
|
||||
it('skips whitespace', () => {
|
||||
expect(types(' A1\t+\n B2 ')).toEqual(['IDENT', 'PLUS', 'IDENT', 'EOF']);
|
||||
});
|
||||
|
||||
@@ -56,6 +56,13 @@ describe('parser references', () => {
|
||||
expect(parse('=A1:C5')).toBe('range(0,0:2,4)');
|
||||
});
|
||||
|
||||
it('parses $-locked (absolute/mixed) references to the same coords -- evaluation is positional', () => {
|
||||
expect(parse('=$A$1')).toBe('cell(0,0)');
|
||||
expect(parse('=A$1')).toBe('cell(0,0)');
|
||||
expect(parse('=$A1')).toBe('cell(0,0)');
|
||||
expect(parse('=$A$1:B$2')).toBe('range(0,0:1,1)');
|
||||
});
|
||||
|
||||
it('parses table-qualified refs, bare and quoted', () => {
|
||||
expect(parse('=Umsatz!B2')).toBe('Umsatz!cell(1,1)');
|
||||
expect(parse("='Kosten Q3'!B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseA1, toA1, cycleReferenceLock } from '../refs';
|
||||
|
||||
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
|
||||
it('parseA1 reports which axes are $-locked', () => {
|
||||
expect(parseA1('A1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: false });
|
||||
expect(parseA1('$A$1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: true });
|
||||
expect(parseA1('A$1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: true });
|
||||
expect(parseA1('$A1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: false });
|
||||
});
|
||||
|
||||
it('toA1 round-trips the lock markers', () => {
|
||||
expect(toA1(0, 0)).toBe('A1');
|
||||
expect(toA1(0, 0, true, true)).toBe('$A$1');
|
||||
expect(toA1(0, 0, false, true)).toBe('A$1');
|
||||
expect(toA1(0, 0, true, false)).toBe('$A1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycleReferenceLock (F4)', () => {
|
||||
it('cycles relative -> absolute -> row-locked -> col-locked -> relative', () => {
|
||||
let f = '=A1';
|
||||
let caret = 3;
|
||||
for (const expected of ['=$A$1', '=A$1', '=$A1', '=A1']) {
|
||||
const res = cycleReferenceLock(f, caret)!;
|
||||
expect(res.text).toBe(expected);
|
||||
f = res.text;
|
||||
caret = res.caret;
|
||||
}
|
||||
});
|
||||
|
||||
it('toggles only the reference the caret sits in/against', () => {
|
||||
const res = cycleReferenceLock('=A1+B2', 2)!; // caret inside "A1"
|
||||
expect(res.text).toBe('=$A$1+B2');
|
||||
const res2 = cycleReferenceLock('=A1+B2', 6)!; // caret inside "B2"
|
||||
expect(res2.text).toBe('=A1+$B$2');
|
||||
});
|
||||
|
||||
it('picks the right side of a range depending on the caret', () => {
|
||||
const left = cycleReferenceLock('=A1:B2', 2)!;
|
||||
expect(left.text).toBe('=$A$1:B2');
|
||||
const right = cycleReferenceLock('=A1:B2', 6)!;
|
||||
expect(right.text).toBe('=A1:$B$2');
|
||||
});
|
||||
|
||||
it('returns null when the caret is not on a reference, or the text is not a formula', () => {
|
||||
expect(cycleReferenceLock('=SUM(1;2)', 5)).toBeNull();
|
||||
expect(cycleReferenceLock('42', 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
toA1,
|
||||
looksLikeCell,
|
||||
expandRange,
|
||||
cycleReferenceLock,
|
||||
} from './refs';
|
||||
export {
|
||||
Workbook,
|
||||
|
||||
+5
-1
@@ -53,7 +53,11 @@ function isDigit(ch: string): boolean {
|
||||
return ch >= '0' && ch <= '9';
|
||||
}
|
||||
function isWordStart(ch: string): boolean {
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_';
|
||||
// '$' only ever appears inside a cell reference ("$A1", "A$1", "$A$1"), but
|
||||
// the lexer doesn't distinguish those from any other bare word (see the
|
||||
// module doc) -- it's just part of the word here, and the parser is the one
|
||||
// that validates/interprets it as a locked reference axis.
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_' || ch === '$';
|
||||
}
|
||||
function isWordPart(ch: string): boolean {
|
||||
return isWordStart(ch) || isDigit(ch);
|
||||
|
||||
+55
-7
@@ -6,7 +6,9 @@
|
||||
* (A=0, B=1, ..., Z=25, AA=26, ...).
|
||||
*/
|
||||
|
||||
const CELL_RE = /^\$?([A-Za-z]{1,3})\$?([0-9]{1,7})$/;
|
||||
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 {
|
||||
@@ -35,16 +37,62 @@ export function looksLikeCell(text: string): boolean {
|
||||
return CELL_RE.test(text);
|
||||
}
|
||||
|
||||
/** Parse "A1" -> { col: 0, row: 0 }. Throws for malformed input. */
|
||||
export function parseA1(text: string): { col: number; row: number } {
|
||||
/**
|
||||
* 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[1]), row: Number(m[2]) - 1 };
|
||||
return { col: colToNumber(m[2]), row: Number(m[4]) - 1, colAbs: m[1] === '$', rowAbs: m[3] === '$' };
|
||||
}
|
||||
|
||||
/** Build "A1" from 0-based coordinates. */
|
||||
export function toA1(col: number, row: number): string {
|
||||
return `${numberToCol(col)}${row + 1}`;
|
||||
/** 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 the cell reference at/adjacent to `caret` (a
|
||||
* 0-based offset into `formula`, which must start with "=") through
|
||||
* relative -> absolute -> row-locked -> column-locked -> relative. Returns
|
||||
* the rewritten formula and a caret placed after the toggled reference, or
|
||||
* `null` when there's no reference at the caret to toggle.
|
||||
*/
|
||||
export function cycleReferenceLock(
|
||||
formula: string,
|
||||
caret: number,
|
||||
): { text: string; caret: number } | null {
|
||||
if (!formula.startsWith('=')) return null;
|
||||
const body = formula.slice(1);
|
||||
const at = caret - 1;
|
||||
let toks: Token[];
|
||||
try {
|
||||
toks = tokenize(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const tok = toks.find(
|
||||
(t) => t.type === 'IDENT' && looksLikeCell(t.value) && at >= t.pos && at <= t.pos + t.value.length,
|
||||
);
|
||||
if (!tok) return null;
|
||||
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
|
||||
const [nColAbs, nRowAbs] = nextLock(colAbs, rowAbs);
|
||||
const next = toA1(col, row, nColAbs, nRowAbs);
|
||||
const text = '=' + body.slice(0, tok.pos) + next + body.slice(tok.pos + tok.value.length);
|
||||
return { text, caret: 1 + tok.pos + next.length };
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+13
-11
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user