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 @@
@@ -49,6 +59,7 @@ function onEscape() {
@blur="onBlur"
@keydown.enter.prevent="onEnter"
@keydown.esc.prevent="onEscape"
+ @keydown.f4.prevent="onF4"
/>
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);