From 8e43cc294cc3aca4b0017702c3ac0aac7ddabcdf Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Wed, 2 Sep 2026 10:11:10 +0200 Subject: [PATCH] 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 --- README.md | 43 ++++++++++++++++--- src/components/FormulaBar.vue | 13 +++++- src/components/TableGrid.vue | 15 ++++++- src/engine/README.md | 18 +++++--- src/engine/__tests__/lexer.spec.ts | 5 +++ src/engine/__tests__/parser.spec.ts | 7 ++++ src/engine/__tests__/refs.spec.ts | 50 ++++++++++++++++++++++ src/engine/index.ts | 1 + src/engine/lexer.ts | 6 ++- src/engine/refs.ts | 62 ++++++++++++++++++++++++---- src/store/__tests__/document.spec.ts | 34 +++++++++++++++ src/store/document.ts | 24 ++++++----- 12 files changed, 247 insertions(+), 31 deletions(-) create mode 100644 src/engine/__tests__/refs.spec.ts diff --git a/README.md b/README.md index 7dba08c..567f07a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt: ```bash npm install npm run dev # Vite-Devserver -npm run test # Vitest-Suite (Engine + Store + IO), 105 Tests +npm run test # Vitest-Suite (Engine + Store + IO), 121 Tests npm run build # Typecheck + Produktionsbuild npm run lint # ESLint (flat config) ``` @@ -63,8 +63,19 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt - Die **diskontinuierliche Mehrfachauswahl** trägt alle folgenden Aktionen: Live-Aggregat (Summe/Mittel/Anzahl) in der Statusleiste, die **Auswahl-Formatierung** im Inspector und `Entf` (leert alle markierten Zellen). -- Pfeiltasten / Enter kollabieren wieder auf eine Zelle. Die aktive Zelle - (Formelleiste) ist stets die zuletzt angeklickte. +- Pfeiltasten / Enter kollabieren wieder auf eine Zelle. Die **aktive Zelle** + (blauer Rahmen, Formelleiste, `F2`/Tippen zum Bearbeiten) ist stets die + **zuerst** angeklickte/gesetzte Zelle einer Auswahl — sie bleibt beim + Ziehen/Erweitern eines Bereichs stehen, statt dem Cursor zu folgen (Excel-/ + Numbers-Verhalten). Während einer Tab/Enter-Dateneingabefolge im Bereich + wandert sie mit dem Cursor mit. +- **Kopieren/Ausschneiden/Einfügen** (`Strg`/`Cmd`+`C`/`X`/`V`, Kontextmenü): + überträgt Zellinhalte **und** Formatierung. Einfügen auf eine Auswahl, die + ein exaktes Vielfaches der kopierten Größe ist, **kachelt**; eine kleinere + oder Einzelzell-Auswahl verankert die kopierte Fläche dort und läuft darüber + hinaus. Formeln werden dabei **bezugsangepasst** wie in Excel/Numbers — siehe + „`$`-Bezüge sperren" unten; ein gestrichelter Rahmen markiert die + Kopierquelle, `Esc` verwirft den Puffer. **Tastaturnavigation (Excel-nah)** — Fokus in der Tabelle: @@ -78,6 +89,8 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt | Tippen / `F2` | Zelle editieren; Enter/Tab committen und ziehen weiter (auch im Bereich) | | `Pos1` / `Strg`+`Pos1` / `Ende` / `Strg`+`Ende` | Zeilenanfang / A1 / Zeilenende / letzte Zelle | | `Bild↑` / `Bild↓` | seitenweise (bei festen Tabellen nach Viewport-Höhe) | +| **`Strg`/`Cmd`+`C`/`X`/`V`** | Kopieren / Ausschneiden / Einfügen (Zellen + Formatierung, bezugsangepasst) | +| **`F4`** (im Editor/in der Formelleiste) | Bezug unter dem Cursor sperren/zyklisch umschalten: `A1` → `$A$1` → `A$1` → `$A1` → `A1` | Bei **festen (scrollbaren) Tabellen** wird die aktive Zelle bei jeder Navigation automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter @@ -175,6 +188,16 @@ Numbers-Stil `::` sind austauschbar. Tabellennamen werden dokumentweit eindeutig gehalten und **case-/whitespace-tolerant** aufgelöst: `Kosten!A2` = `'Kosten'!A2` = `kosten!a2` = `Kosten::A2`. +**`$`-Bezüge sperren** (relativ/gemischt/absolut): `A1` (relativ) vs. `$A1` / +`A$1` (Spalte bzw. Zeile gesperrt) vs. `$A$1` (beide gesperrt). Für die +Auswertung selbst ist `$` irrelevant — sie ist rein positionsbasiert. Es zählt +beim **Kopieren**: eine gesperrte Achse bleibt exakt wie eingegeben stehen, +eine ungesperrte verschiebt sich um das Einfüge-Delta (siehe „Kopieren/ +Ausschneiden/Einfügen" oben). Beim **Ausschneiden** gilt das nicht — dort +verschiebt sich die referenzierte Zelle selbst, also folgt jede Referenz +darauf, `$` hin oder her. `F4` schaltet den Bezug unter dem Cursor durch die +vier Kombinationen. + **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), `MIN`, `MAX`, `COUNT`. Argumente gemischt: Einzelzellen, Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B. `=SUM(Umsatz!B2:E2; 100; A1)`. @@ -285,11 +308,14 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Tests -`npm run test` — Vitest, 105 Tests: +`npm run test` — Vitest, 121 Tests: - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, Bereiche, `;`-Argumente, Parse-Fehler. +- **refs (`$`-Sperrachsen, `F4`)** — `parseA1`/`toA1` runden relativ/absolut/ + gemischt; `cycleReferenceLock` durchläuft die vier Kombinationen, trifft + bei Cursor auf einem Bereich die richtige Seite, liefert `null` ohne Treffer. - **evaluator** — Arithmetik, leere Zelle = 0, alle Funktionen inkl. Alias, Cross-Table, Case-/`::`-Toleranz, jeder Fehlerfall, Fehler-Propagierung. - **graph** — `affected`, topologische Ordnung, Selbst-/2er-/längerer Zyklus. @@ -319,7 +345,14 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, die aktive Zelle, erneutes Klicken schaltet eine 1×1-Auswahl ab, `extendActiveRange` wächst das jüngste Rechteck; Aggregat & Formatierung über **alle** Rechtecke; `#`-genaue Zellzahl; JSON-Round-Trip inkl. `ranges`, - Klemmung beim Verkleinern. + Klemmung beim Verkleinern; die aktive Zelle bleibt beim Erweitern eines + Bereichs der **Anker** (erste Zelle), nicht die zuletzt erreichte. +- **Zwischenablage** — Kopieren passt jede relative Referenz um das Einfüge- + Delta an, `$`-gesperrte Achsen bleiben stehen; Einfügen kachelt eine exakt + passende größere Auswahl bzw. verankert/überläuft eine kleinere; Formatierung + wird mitkopiert und am Ziel ersetzt; Ausschneiden folgt trotz `$` der + verschobenen Zelle, lässt aber unbeteiligte und tabellenfremde Referenzen + unangetastet und leert die Quelle; `Esc` verwirft den Puffer. - **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei → Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting, diff --git a/src/components/FormulaBar.vue b/src/components/FormulaBar.vue index 620123d..101092e 100644 --- a/src/components/FormulaBar.vue +++ b/src/components/FormulaBar.vue @@ -1,6 +1,7 @@ diff --git a/src/components/TableGrid.vue b/src/components/TableGrid.vue index e028d9e..12580d2 100644 --- a/src/components/TableGrid.vue +++ b/src/components/TableGrid.vue @@ -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(); } diff --git a/src/engine/README.md b/src/engine/README.md index 9f93896..1470c4f 100644 --- a/src/engine/README.md +++ b/src/engine/README.md @@ -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 diff --git a/src/engine/__tests__/lexer.spec.ts b/src/engine/__tests__/lexer.spec.ts index bfa169b..44a9ddd 100644 --- a/src/engine/__tests__/lexer.spec.ts +++ b/src/engine/__tests__/lexer.spec.ts @@ -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']); }); diff --git a/src/engine/__tests__/parser.spec.ts b/src/engine/__tests__/parser.spec.ts index d4c7450..3bebb2c 100644 --- a/src/engine/__tests__/parser.spec.ts +++ b/src/engine/__tests__/parser.spec.ts @@ -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)'); diff --git a/src/engine/__tests__/refs.spec.ts b/src/engine/__tests__/refs.spec.ts new file mode 100644 index 0000000..7d106a0 --- /dev/null +++ b/src/engine/__tests__/refs.spec.ts @@ -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(); + }); +}); diff --git a/src/engine/index.ts b/src/engine/index.ts index a96e070..d287dab 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -24,6 +24,7 @@ export { toA1, looksLikeCell, expandRange, + cycleReferenceLock, } from './refs'; export { Workbook, diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts index aa3473b..189595f 100644 --- a/src/engine/lexer.ts +++ b/src/engine/lexer.ts @@ -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); diff --git a/src/engine/refs.ts b/src/engine/refs.ts index c77f8b5..f94a48d 100644 --- a/src/engine/refs.ts +++ b/src/engine/refs.ts @@ -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 } /** diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index bfab37d..901bfd8 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -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', () => { diff --git a/src/store/document.ts b/src/store/document.ts index 329078d..cf86af1 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -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);