AND/OR/NOT, Fehler-Literale und Zeilen/Spalten einfügen/löschen

- AND(...)/OR(...)/NOT(...) als gewöhnliche Funktionen (kein Kurzschluss-
  Sonderfall wie IF/XLOOKUP nötig, werten wie in echtem Excel alle
  Argumente eager aus). Dabei die bisher dreifach duplizierte
  Wahrheits-Prüfung zu functions.ts#isTruthy() zusammengezogen, jetzt
  von FUNCTIONS.IF, evalIf() und dem Block-IF-Statement gemeinsam genutzt.
  Schließt "kein AND/OR für zusammengesetzte Bedingungen" aus den
  Bekannten Grenzen, auch nutzbar in "Mit Schleife"-IF-Bedingungen.

- Neue Grammatik-Ergänzung: ErrorLiteralNode. Jeder der sieben Fehlercodes
  (#REF, #DIV/0!, #NAME?, #ERR, #CIRC, #VALUE!, #N/A) lässt sich jetzt
  direkt in eine Formel tippen (=IF(A1=0;#N/A;A1)) und wertet unbedingt zu
  genau diesem Fehler aus.

- Zeilen/Spalten einfügen/löschen mitten im Gitter (Rechtsklick auf einen
  Zeilen-/Spaltenkopf), nicht nur am Rand wie bisher: Zellinhalte, Formate
  und Zeilenhöhen/Spaltenbreiten verschieben sich innerhalb der Tabelle,
  und jede Formel im gesamten Dokument, die die Tabelle referenziert (auch
  über Tabelle!Zelle/Blatt!Tabelle::Zelle-Qualifier aus anderen Tabellen),
  wird automatisch mitgezogen. Ein Bereich, der die Stelle überspannt,
  wächst/schrumpft entsprechend; eine Referenz, die exakt die gelöschte
  Zeile/Spalte trifft, wird zu einem echten #REF-Literal im Formeltext
  (nutzt die neue ErrorLiteralNode-Grammatik). $-Sperren bleiben dabei
  bewusst wirkungslos (anders als beim Kopieren), unveränderte Referenzen
  bleiben byte-identisch. Token-Level-Splice wie shiftFormula (Kopieren/
  Ausschneiden), keine AST-Neuserialisierung.

406 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-08 22:34:33 +02:00
co-authored by Claude Sonnet 5
parent 2c54537427
commit aef8f1349c
14 changed files with 937 additions and 22 deletions
+97 -11
View File
@@ -367,6 +367,41 @@ Beispiel mit.
ganze, vordefinierte Adresse; er kann nicht als Startpunkt einer neu ganze, vordefinierte Adresse; er kann nicht als Startpunkt einer neu
getippten `:`-Bereichsangabe kombiniert werden. getippten `:`-Bereichsangabe kombiniert werden.
### Zeilen & Spalten einfügen/löschen
Rechtsklick auf einen **Zeilen-** oder **Spaltenkopf** öffnet ein Kontextmenü
mit **„Zeile oberhalb/unterhalb einfügen"**, **„Zeile löschen"** (analog für
Spalten) — anders als „Zeile/Spalte hinzufügen" (die bestehenden Buttons/
Menüpunkte, die nur am **Rand** wachsen/schrumpfen, reines Resizing) wird
hier **mitten im Gitter** eingefügt/gelöscht: Zellinhalte, Formatierungen und
Zeilenhöhen-/Spaltenbreiten-Overrides ab der betroffenen Zeile/Spalte
verschieben sich, und **jede Formel im gesamten Dokument**, die eine
Referenz auf die verschobene Tabelle enthält — nicht nur innerhalb der
bearbeiteten Tabelle selbst, auch über `Tabelle!Zelle`/
`Blatt!Tabelle::Zelle`-Qualifier aus anderen Tabellen — wird automatisch
mitgezogen:
- Ein Bereich, der über die Einfüge-/Löschstelle hinausragt, **wächst bzw.
schrumpft** entsprechend (`=SUM(B2:B6)` bleibt beim Einfügen einer Zeile
mittendrin ein gültiger, nur größerer Bereich) — genau wie in Excel/Numbers.
- Eine Referenz, die **genau** auf die gelöschte Zeile/Spalte zeigt (eine
Einzelzelle, oder ein Bereich, der komplett darin lag), wird zu einem
echten **`#REF`**-Literal *in der Formel selbst* (z. B. `=B2+1` wird zu
`=#REF+1`) — dasselbe, was Excel inline anzeigt. `#REF` ist dabei keine
Notation, sondern ein echtes Grammatik-Element (`ErrorLiteralNode`, s.
„Formelgrammatik" unten): jeder der sieben Fehlercodes (`#REF`, `#DIV/0!`,
`#NAME?`, `#ERR`, `#CIRC`, `#VALUE!`, `#N/A`) lässt sich jetzt auch direkt
in eine Formel **tippen**, z. B. `=IF(A1=0;#N/A;A1)`.
- `$`-gesperrte Referenzen (`$B$5`) verschieben sich **trotzdem** — anders
als beim Kopieren ist `$` hier irrelevant: die referenzierte Zelle hat sich
strukturell bewegt, unabhängig davon, ob ihr Bezug gesperrt war.
- Eine Referenz **vor** der Einfüge-/Löschstelle bleibt **byte-identisch**
stehen (auch Groß-/Kleinschreibung, Leerzeichen) — nur das Notwendige wird
angefasst.
- Die aktuelle **Auswahl** wandert mit, falls sie auf der bearbeiteten
Tabelle liegt.
- **Undo/Redo-fähig** wie jede andere Änderung.
### Zell-Formatierung ### Zell-Formatierung
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
@@ -457,7 +492,7 @@ unary = ("+" | "-") unary
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
stärker als unäres Minus, stärker als unäres Minus,
d.h. -2^2 = -(2^2) = -4 *) d.h. -2^2 = -(2^2) = -4 *)
primary = NUMBER | STRING | "(" expression ")" | funcall | reference | namedref ; primary = NUMBER | STRING | ERRORLIT | "(" expression ")" | funcall | reference | namedref ;
funcall = IDENT "(" [ expression { ";" expression } ] ")" ; funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
@@ -484,6 +519,11 @@ sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ; IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ;
QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *) QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *)
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
"#VALUE!" | "#N/A" ; (* ein Fehlercode, direkt
getippt oder von einem
Zeilen/Spalten-Löschen
erzeugt, s. u. *)
``` ```
Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` < Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` <
@@ -518,6 +558,17 @@ harter Parse-Fehler wie zuvor (s. „Benannte Bereiche"s „Bewusst ohne"). Ein
*qualifizierter* nicht-zellförmiger Bezeichner (`Tabelle!Foo`) ist immer ein *qualifizierter* nicht-zellförmiger Bezeichner (`Tabelle!Foo`) ist immer ein
Parse-Fehler — ein benannter Bereich ist nie qualifiziert. Parse-Fehler — ein benannter Bereich ist nie qualifiziert.
Ein **Fehlercode lässt sich auch direkt in eine Formel tippen**, z. B.
`=IF(A1=0;#N/A;A1)` oder `=1+#REF`. Wertet unbedingt zu genau diesem Fehler
aus, wie eine Zahl- oder Text-Konstante. Alle sieben Codes sind erlaubt:
`#REF`, `#DIV/0!`, `#NAME?`, `#ERR`, `#CIRC`, `#VALUE!`, `#N/A` — **ohne**
das in Excel übliche `!` am Ende von `#REF!`, da diese Engine ihren eigenen
Fehlercode konsequent ohne `!` schreibt (`#REF`, nicht `#REF!`; siehe
Fehlermodell unten). Genau das erzeugt auch ein **Zeilen-/Spalten-Löschen**
automatisch für eine dadurch ungültig gewordene Referenz (s. „Zeilen &
Spalten einfügen/löschen" oben) — die Formel zeigt danach z. B. `=#REF+1`
statt einer stillen Fehladressierung.
Der (innere) Qualifier ist der **Tabellenname**. `!` und der Numbers-Stil `::` Der (innere) Qualifier ist der **Tabellenname**. `!` und der Numbers-Stil `::`
sind dabei austauschbar und **case-/whitespace-tolerant**: sind dabei austauschbar und **case-/whitespace-tolerant**:
`Kosten!A2` = `'Kosten'!A2` = `kosten!a2` = `Kosten::A2`. `Kosten!A2` = `'Kosten'!A2` = `kosten!a2` = `Kosten::A2`.
@@ -565,8 +616,9 @@ das Dropdown, ohne die Bearbeitung selbst abzubrechen.
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
`MIN`, `MAX`, `COUNT`, `CONCAT` (Alias `CONCATENATE`), `IF(Bedingung; Dann; `MIN`, `MAX`, `COUNT`, `CONCAT` (Alias `CONCATENATE`), `IF(Bedingung; Dann;
Sonst?)`, `DATE(Jahr; Monat; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, Sonst?)`, `AND(Bedingung1; Bedingung2; …)`, `OR(Bedingung1; Bedingung2; …)`,
`XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; `NOT(Bedingung)`, `DATE(Jahr; Monat; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`,
`NOW()`, `XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
[Vergleichsmodus]; [Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten [Vergleichsmodus]; [Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten
benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben), benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben),
die sich für den Aufrufer identisch verhalten. Argumente gemischt: die sich für den Aufrufer identisch verhalten. Argumente gemischt:
@@ -588,6 +640,18 @@ Zahl außer `0` sowie jeder nicht-leere Text gilt als wahr, z. B.
Bedingung `0`. Ein benutzerdefiniertes „IF" ist nicht anlegbar — der Name Bedingung `0`. Ein benutzerdefiniertes „IF" ist nicht anlegbar — der Name
kollidiert mit der eingebauten Funktion. kollidiert mit der eingebauten Funktion.
**`AND`/`OR`/`NOT`** verknüpfen mehrere Bedingungen — z. B.
`=IF(AND(A1>0; A1<10); "im Bereich"; "außerhalb")`, da ein einzelner
Vergleich (`= <> > < >= <=`) nicht verkettbar ist (`1<A1<10` ist kein
gültiger Ausdruck). Anders als `IF` werten sie **alle** Argumente aus, auch
wenn das Ergebnis schon feststeht — genau wie in Excel: `=AND(1=2; 1/0)`
ergibt `#DIV/0!`, nicht `0`/falsch. `AND`/`OR` akzeptieren beliebig viele
Argumente inkl. Bereichen (`=AND(A1:A5)` prüft jede Zelle einzeln), `NOT`
genau eines. Dieselbe Wahrheits-Semantik wie überall sonst: jede Zahl außer
`0` sowie jeder nicht-leere Text gilt als wahr, eine leere Zelle als falsch.
Funktioniert genauso in einer „Mit Schleife"-`IF`-Bedingung (s. o.), z. B.
`IF AND(x > 0; x < schwelle) THEN … END`.
**Datum & Zeit**: ein Datum ist — wie in jeder Tabellenkalkulation — intern **Datum & Zeit**: ein Datum ist — wie in jeder Tabellenkalkulation — intern
einfach eine **Zahl** (Tage seit dem 30.12.1899, Uhrzeit als Bruchteil eines einfach eine **Zahl** (Tage seit dem 30.12.1899, Uhrzeit als Bruchteil eines
Tages), keine eigene Zellart. Deshalb funktioniert Datums-Arithmetik ganz Tages), keine eigene Zellart. Deshalb funktioniert Datums-Arithmetik ganz
@@ -872,6 +936,11 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
- **`&`-Verkettung** — Präzedenz zwischen `+ -` und einem Vergleich, - **`&`-Verkettung** — Präzedenz zwischen `+ -` und einem Vergleich,
links-assoziativ, Zahl rendert locale-neutral, leere Zelle trägt `''` bei, links-assoziativ, Zahl rendert locale-neutral, leere Zelle trägt `''` bei,
Fehler propagieren; `CONCAT`/`CONCATENATE` als Funktions-Äquivalent. Fehler propagieren; `CONCAT`/`CONCATENATE` als Funktions-Äquivalent.
- **`AND`/`OR`/`NOT`** — korrekte Wahrheitstabellen, Bereichsargument (jede
Zelle einzeln), **kein** Kurzschluss (ein Fehler in einem späteren
Argument schlägt durch, selbst wenn das Ergebnis schon feststeht, wie in
echtem Excel), Komposition innerhalb eines `IF` und innerhalb einer
„Mit Schleife"-`IF`-Bedingung, Arity-Fehler → `#ERR`.
- **Benannte Bereiche** — Auflösung als Einzelzelle/Bereich (Skalar- und - **Benannte Bereiche** — Auflösung als Einzelzelle/Bereich (Skalar- und
Funktionsargument-Position), case-insensitives/getrimmtes Namens-Matching, Funktionsargument-Position), case-insensitives/getrimmtes Namens-Matching,
Abhängigkeitsgraph-Kante (Neuberechnung bei Änderung der Zielzelle), Abhängigkeitsgraph-Kante (Neuberechnung bei Änderung der Zielzelle),
@@ -882,6 +951,18 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
`FOR`-Quelle in einer benutzerdefinierten Funktion (als übergebenes `FOR`-Quelle in einer benutzerdefinierten Funktion (als übergebenes
Argument), harter Parse-Fehler statt Fallback innerhalb eines Argument), harter Parse-Fehler statt Fallback innerhalb eines
Funktionskörpers selbst. Funktionskörpers selbst.
- **Fehler-Literale** (`ErrorLiteralNode`) — alle sieben Codes tokenisieren
als ein Token, Komposition mit Operatoren/Funktionen, Fehlerpropagation
wie bei jedem anderen Fehlerwert.
- **Zeilen/Spalten einfügen/löschen** — Datenverschiebung (Zellen, Formate,
Zeilenhöhen/Spaltenbreiten) innerhalb der Tabelle, dokumentweites
Umschreiben von Formeln (auch über `Tabelle!Zelle`- und
`Blatt!Tabelle::Zelle`-Qualifier aus *anderen* Tabellen), Wachsen/
Schrumpfen eines Bereichs, der die Stelle überspannt, Kollaps zu `#REF`
bei einer exakt getroffenen Einzelzelle/einem vollständig gelöschten
Bereich, `$`-Sperren bleiben wirkungslos (anders als beim Kopieren),
unveränderte Referenzen bleiben byte-identisch, Undo/Redo, Schutz gegen
Löschen der letzten verbleibenden Zeile/Spalte.
- **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein - **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein
`FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen `FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF` nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF`
@@ -940,8 +1021,13 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Bekannte Grenzen ## Bekannte Grenzen
- **Zeilen/Spalten nur am Rand.** Einfügen/Löschen *innerhalb* eines Gitters (mit - **Zeilen/Spalten einfügen/löschen** (s. „Zeilen & Spalten einfügen/löschen"
automatischem Umschreiben relativer Bezüge) ist bewusst nicht implementiert. oben) bewusst ohne: Mehrfachauswahl (immer genau eine Zeile/Spalte pro
Aufruf), automatisches Nachziehen von `CellFormat`-Regeln über die
Grenze hinaus (eine neue Zeile ist immer unformatiert), und ein
`OFFSET`/`INDEX`-Äquivalent — ein `#REF`, das aus einer gelöschten Zeile
entsteht, muss von Hand korrigiert werden, es gibt keine „nächstbeste
Zelle"-Heuristik.
- **Objekt-Bezüge beim Anhängen.** Beim JSON-*Anhängen* zweier Dokumente werden - **Objekt-Bezüge beim Anhängen.** Beim JSON-*Anhängen* zweier Dokumente werden
nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein
angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der
@@ -985,12 +1071,12 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
zählt — wie in den meisten Editoren), Live-Update der Vorschlagsliste bei zählt — wie in den meisten Editoren), Live-Update der Vorschlagsliste bei
reiner Cursor-Bewegung ohne Tippen. reiner Cursor-Bewegung ohne Tippen.
- **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`, - **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`,
nur ein Vergleich pro Ausdruck), `AND`/`OR`/`NOT` für zusammengesetzte nur ein Vergleich pro Ausdruck — `AND`/`OR` lösen das aber, s. u.),
Bedingungen, `BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg), `BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg), echtes
echtes Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar).
sichtbar). Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger Stelle sind
Stelle sind dagegen unterstützt (s. o.), ebenso Text-Vergleich in `IF`- dagegen unterstützt (s. o.), ebenso Text-Vergleich in `IF`-Bedingungen
Bedingungen (s. „SUMIF/COUNTIF/AVERAGEIF nachbauen" oben). (s. „SUMIF/COUNTIF/AVERAGEIF nachbauen" oben).
- **Keine Positions-Adressierung zwischen zwei Bereichen** (kein - **Keine Positions-Adressierung zwischen zwei Bereichen** (kein
`OFFSET`/`INDEX`-Äquivalent) — eine `FOR`-Schleife kann nur den Bereich `OFFSET`/`INDEX`-Äquivalent) — eine `FOR`-Schleife kann nur den Bereich
erreichen, über den sie selbst iteriert, nicht „die Zelle an derselben erreichen, über den sie selbst iteriert, nicht „die Zelle an derselben
+33
View File
@@ -952,13 +952,44 @@ function onKeydown(e: KeyboardEvent) {
// ---- context menu ----------------------------------------- // ---- context menu -----------------------------------------
const menu = ref({ x: 0, y: 0, open: false }); const menu = ref({ x: 0, y: 0, open: false });
/** Which row/col header (if any) the menu was opened on -- drives whether
* it shows the row/col insert-delete items (Excel-style, only from a
* header right-click) or the ordinary cell menu. */
const menuCtx = ref<{ row: number | null; col: number | null }>({ row: null, col: null });
function onContextMenu(e: MouseEvent) { function onContextMenu(e: MouseEvent) {
const target = e.target as HTMLElement | null;
const rowHeadEl = target?.closest?.('th[data-rowhead]') as HTMLElement | null;
const colHeadEl = target?.closest?.('th[data-colhead]') as HTMLElement | null;
menuCtx.value = {
row: rowHeadEl ? Number(rowHeadEl.dataset.rowhead) : null,
col: colHeadEl ? Number(colHeadEl.dataset.colhead) : null,
};
const hit = cellFromEvent(e); const hit = cellFromEvent(e);
// keep a multi-selection if the click landed inside it; otherwise select the cell // keep a multi-selection if the click landed inside it; otherwise select the cell
if (hit && !inSelection(hit.c, hit.r)) select(hit.c, hit.r); if (hit && !inSelection(hit.c, hit.r)) select(hit.c, hit.r);
menu.value = { x: e.clientX, y: e.clientY, open: true }; menu.value = { x: e.clientX, y: e.clientY, open: true };
} }
const menuItems = computed(() => { const menuItems = computed(() => {
const t = table.value;
const ctx = menuCtx.value;
if (t && ctx.row !== null) {
const r = ctx.row;
const items = [
{ label: 'Zeile oberhalb einfügen', action: () => store.insertRow(props.tableId, r) },
{ label: 'Zeile unterhalb einfügen', action: () => store.insertRow(props.tableId, r + 1) },
];
if (t.rows > 1) items.push({ label: 'Zeile löschen', action: () => store.deleteRow(props.tableId, r) });
return items;
}
if (t && ctx.col !== null) {
const c = ctx.col;
const items = [
{ label: 'Spalte links einfügen', action: () => store.insertColumn(props.tableId, c) },
{ label: 'Spalte rechts einfügen', action: () => store.insertColumn(props.tableId, c + 1) },
];
if (t.cols > 1) items.push({ label: 'Spalte löschen', action: () => store.deleteColumn(props.tableId, c) });
return items;
}
const items = [ const items = [
{ label: 'Kopieren', action: () => store.copySelection() }, { label: 'Kopieren', action: () => store.copySelection() },
{ label: 'Ausschneiden', action: () => store.cutSelection() }, { label: 'Ausschneiden', action: () => store.cutSelection() },
@@ -1061,6 +1092,7 @@ defineExpose({
v-for="c in table.cols" v-for="c in table.cols"
:key="'h' + (c - 1)" :key="'h' + (c - 1)"
class="col-head" class="col-head"
:data-colhead="c - 1"
:class="{ hot: colHot(c - 1), 'freeze-edge-right': c - 1 === frozenCols - 1 }" :class="{ hot: colHot(c - 1), 'freeze-edge-right': c - 1 === frozenCols - 1 }"
:style="c - 1 < frozenCols ? { left: colLeft(c - 1) + 'px', zIndex: '7' } : {}" :style="c - 1 < frozenCols ? { left: colLeft(c - 1) + 'px', zIndex: '7' } : {}"
@click="selectColumn(c - 1, $event)" @click="selectColumn(c - 1, $event)"
@@ -1074,6 +1106,7 @@ defineExpose({
<tr v-for="r in table.rows" :key="'r' + (r - 1)" :style="{ height: rowH(r - 1) + 'px' }"> <tr v-for="r in table.rows" :key="'r' + (r - 1)" :style="{ height: rowH(r - 1) + 'px' }">
<th <th
class="row-head" class="row-head"
:data-rowhead="r - 1"
:class="{ hot: rowHot(r - 1), 'frozen-row': r - 1 < frozenRows, 'freeze-edge-bottom': r - 1 === frozenRows - 1 }" :class="{ hot: rowHot(r - 1), 'frozen-row': r - 1 < frozenRows, 'freeze-edge-bottom': r - 1 === frozenRows - 1 }"
:style="rowHeadStyle(r - 1)" :style="rowHeadStyle(r - 1)"
@click="selectRow(r - 1, $event)" @click="selectRow(r - 1, $event)"
+24
View File
@@ -55,6 +55,7 @@ power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
-2^2 = -(2^2) = -4 *) -2^2 = -(2^2) = -4 *)
primary = NUMBER primary = NUMBER
| STRING | STRING
| ERRORLIT
| "(" expression ")" | "(" expression ")"
| funcall | funcall
| reference | reference
@@ -85,6 +86,9 @@ STRING = /" ... "/ (Backslash-Escapes) ;
IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ; IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ;
QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *) QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *)
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
"#VALUE!" | "#N/A" ; (* longest-match in the lexer -- see
lexer.ts's ERROR_CODES_BY_LENGTH *)
``` ```
Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` < unäres `+ -` < `^`. Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` < unäres `+ -` < `^`.
@@ -417,6 +421,16 @@ dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung.
Konvention wie bei `kind: 'block'`s `IF`-Abhängigkeiten (s. u.). Ein Konvention wie bei `kind: 'block'`s `IF`-Abhängigkeiten (s. u.). Ein
benutzerdefiniertes „IF" ist nicht anlegbar (Namenskollision mit dem benutzerdefiniertes „IF" ist nicht anlegbar (Namenskollision mit dem
eingebauten `FUNCTIONS.IF`-Eintrag). eingebauten `FUNCTIONS.IF`-Eintrag).
- **`AND`/`OR`/`NOT` brauchen dagegen keine Sonderbehandlung** — anders als
`IF`/`XLOOKUP` sind es ganz normale `functions.ts`-Einträge, ohne
`evaluator.ts`-Kurzschluss: `AND`/`OR` werten (wie in echtem Excel) **alle**
Argumente eager aus, ein Fehler in irgendeinem Argument propagiert also
immer, egal ob das logische Ergebnis schon feststeht (`=AND(1=2; 1/0)` →
`#DIV/0!`, nicht `0`). `functions.ts#isTruthy()` ist die eine
Wahrheits-Konvention, die überall im Motor geteilt wird, wo eine Bedingung
gebraucht wird – `evalIf()` und `execStatement`s `ifstmt`-Fall importieren
sie jetzt aus `functions.ts`, statt sie (wie vor diesem Feature) je einmal
inline zu duplizieren.
## Datum & Zeit ## Datum & Zeit
@@ -558,3 +572,13 @@ rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
Es gibt **keinen** Absturz und **keinen** Hang: Lexer/Parser werfen intern, Es gibt **keinen** Absturz und **keinen** Hang: Lexer/Parser werfen intern,
`Workbook` fängt alles ab und schreibt einen Fehlerwert in die Zelle. `Workbook` fängt alles ab und schreibt einen Fehlerwert in die Zelle.
Jeder dieser sieben Codes ist außerdem als **`ErrorLiteralNode`** direkt in
eine Formel tippbar (`=#REF`, `=IF(A1=0;#N/A;A1)`, …) – der Lexer erkennt
ihn als eigenes `ERRORLIT`-Token (längster Treffer zuerst, s. o. EBNF),
`evalScalar`s `case 'errorliteral':` gibt unbedingt `new FormulaError(code)`
zurück. `store/document.ts`s Zeilen-/Spalten-Löschen ist der eine Ort, der
selbst *Formeltext* mit `#REF`-Literalen erzeugt (für eine dadurch
kollabierte Referenz) – ansonsten ist es reine Nutzer-Eingabe, wie eine
Zahl- oder Text-Konstante. Kein `!` am Ende (`#REF`, nicht Excels `#REF!`)
– derselbe Code, den diese Engine selbst überall sonst produziert.
+68
View File
@@ -136,6 +136,74 @@ describe('evaluator IF', () => {
}); });
}); });
describe('evaluator AND/OR/NOT', () => {
const host = makeHost({ S1: { A1: 8, B1: 0, C1: 'x' } });
it('AND is true only when every argument is truthy', () => {
expect(ev('=AND(1;1;1)', host)).toBe(1);
expect(ev('=AND(1;0;1)', host)).toBe(0);
expect(ev('=AND(A1>0;A1<10)', host)).toBe(1);
expect(ev('=AND(A1>0;A1<5)', host)).toBe(0);
});
it('OR is true when at least one argument is truthy', () => {
expect(ev('=OR(0;0;1)', host)).toBe(1);
expect(ev('=OR(0;0;0)', host)).toBe(0);
});
it('NOT inverts a single condition', () => {
expect(ev('=NOT(1)', host)).toBe(0);
expect(ev('=NOT(0)', host)).toBe(1);
expect(ev('=NOT(B1=0)', host)).toBe(0);
});
it('a range argument checks every cell (Excel behaviour)', () => {
const h = makeHost({ S1: { A1: 1, A2: 1, A3: 0 } });
expect(ev('=AND(A1:A3)', h)).toBe(0);
expect(ev('=OR(A1:A3)', h)).toBe(1);
});
it('empty cell and non-empty text follow the shared truthy convention', () => {
expect(ev('=AND(Z9)', host)).toBe(0); // empty cell -> falsy
expect(ev('=AND(C1)', host)).toBe(1); // non-empty text -> truthy
});
it('composes inside IF, unlike a bare chained comparison', () => {
expect(ev('=IF(AND(A1>0;A1<10);"ok";"nein")', host)).toBe('ok');
});
it('does NOT short-circuit -- every argument is evaluated, same as real Excel', () => {
expect(isError(ev('=AND(1=2;1/0)', host))).toBe(true);
expect(isError(ev('=OR(1=1;1/0)', host))).toBe(true);
});
it('wrong arity -> #ERR', () => {
expect(isError(ev('=AND()', host))).toBe(true);
expect(isError(ev('=NOT(1;2)', host))).toBe(true);
expect(isError(ev('=NOT()', host))).toBe(true);
});
});
describe('evaluator error literals (ErrorLiteralNode)', () => {
const host = makeHost({ S1: {} });
it('evaluates directly to that FormulaError', () => {
const r = ev('=#REF', host);
expect(isError(r) && r.code).toBe('#REF');
});
it('propagates through arithmetic like any other error', () => {
const r = ev('=1+#DIV/0!', host);
expect(isError(r) && r.code).toBe('#DIV/0!');
});
it('usable as an IF branch, e.g. a deliberate #N/A', () => {
expect(ev('=IF(1=0;#N/A;5)', host)).toBe(5);
const r = ev('=IF(1=1;#N/A;5)', host);
expect(isError(r) && r.code).toBe('#N/A');
});
});
describe('evaluator comparisons', () => { describe('evaluator comparisons', () => {
const host = makeHost({ S1: { A1: 'Nord', B1: 5 } }); const host = makeHost({ S1: { A1: 'Nord', B1: 5 } });
+23
View File
@@ -89,4 +89,27 @@ describe('lexer', () => {
expect(types('A1<B2')).toEqual(['IDENT', 'LT', 'IDENT', 'EOF']); expect(types('A1<B2')).toEqual(['IDENT', 'LT', 'IDENT', 'EOF']);
expect(types('A1>B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']); expect(types('A1>B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']);
}); });
it('tokenizes every error code as one ERRORLIT token', () => {
expect(types('#REF')).toEqual(['ERRORLIT', 'EOF']);
expect(values('#REF')).toEqual(['#REF', '']);
expect(types('#DIV/0!')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#NAME?')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#ERR')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#CIRC')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#VALUE!')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#N/A')).toEqual(['ERRORLIT', 'EOF']);
});
it('an ERRORLIT is followed by whatever comes next, not swallowed into it', () => {
expect(types('1+#REF')).toEqual(['NUMBER', 'PLUS', 'ERRORLIT', 'EOF']);
// "#REF" matches greedily -- a trailing "!" some might expect (Excel
// spells it "#REF!") is its own BANG token, not part of the literal;
// this engine's own error code is "#REF" without "!" (see types.ts).
expect(types('#REF!')).toEqual(['ERRORLIT', 'BANG', 'EOF']);
});
it('throws on an unrecognized "#..." sequence', () => {
expect(() => tokenize('#FOO')).toThrow();
});
}); });
+19
View File
@@ -16,6 +16,8 @@ function sexpr(n: Node): string {
return String(n.value); return String(n.value);
case 'string': case 'string':
return JSON.stringify(n.value); return JSON.stringify(n.value);
case 'errorliteral':
return n.code;
case 'unary': case 'unary':
return `(${n.op} ${sexpr(n.operand)})`; return `(${n.op} ${sexpr(n.operand)})`;
case 'binary': case 'binary':
@@ -242,6 +244,23 @@ describe('parser named-range references (NamedRefNode)', () => {
}); });
}); });
describe('parser error literals (ErrorLiteralNode)', () => {
it('every error code parses as a leaf', () => {
expect(parse('=#REF')).toBe('#REF');
expect(parse('=#DIV/0!')).toBe('#DIV/0!');
expect(parse('=#NAME?')).toBe('#NAME?');
expect(parse('=#ERR')).toBe('#ERR');
expect(parse('=#CIRC')).toBe('#CIRC');
expect(parse('=#VALUE!')).toBe('#VALUE!');
expect(parse('=#N/A')).toBe('#N/A');
});
it('composes with operators/functions like any other literal', () => {
expect(parse('=1+#REF')).toBe('(+ 1 #REF)');
expect(parse('=IF(1=0;#N/A;5)')).toBe('(IF (= 1 0) #N/A 5)');
});
});
describe('parser comparison operators (available in every formula, not just block IF conditions)', () => { describe('parser comparison operators (available in every formula, not just block IF conditions)', () => {
it('parses all six comparison operators', () => { it('parses all six comparison operators', () => {
expect(parse('=A1=B1')).toBe('(= cell(0,0) cell(1,0))'); expect(parse('=A1=B1')).toBe('(= cell(0,0) cell(1,0))');
@@ -395,6 +395,21 @@ describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined f
expect(val(wb, 's-umsatz', 'B9')).toBe(0); expect(val(wb, 's-umsatz', 'B9')).toBe(0);
}); });
it('AND/OR compose a compound condition inside an IF, same as any other function call', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=INRANGE(A1)' });
const inRange = def({
id: 'f1',
name: 'INRANGE',
params: ['x'],
kind: 'block',
body: 'IF AND(x > 0; x < 10) THEN RETURN 1 ELSE RETURN 0 END',
});
const wb = new Workbook([umsatz], [inRange]);
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
wb.setCell('s-umsatz', 0, 0, '20'); // A1: 5 -> 20, out of range
expect(val(wb, 's-umsatz', 'B9')).toBe(0);
});
it('an early RETURN nested inside an IF inside a FOR stops the loop -- finds the first match, never touches later cells', () => { it('an early RETURN nested inside an IF inside a FOR stops the loop -- finds the first match, never touches later cells', () => {
// A3 is the first cell > 100; A4 must never affect the result // A3 is the first cell > 100; A4 must never affect the result
const umsatz = sheet('s-umsatz', 'Umsatz', { const umsatz = sheet('s-umsatz', 'Umsatz', {
+9 -9
View File
@@ -40,7 +40,7 @@
*/ */
import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser'; import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
import { getFunction } from './functions'; import { getFunction, isTruthy } from './functions';
import { expandRange } from './refs'; import { expandRange } from './refs';
import { substitute } from './userFunctions'; import { substitute } from './userFunctions';
import { FormulaError, isError, type CellValue, type EngineHost } from './types'; import { FormulaError, isError, type CellValue, type EngineHost } from './types';
@@ -55,6 +55,8 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
return node.value; return node.value;
case 'string': case 'string':
return node.value; return node.value;
case 'errorliteral':
return new FormulaError(node.code);
case 'unary': { case 'unary': {
const v = toNumber(evalScalar(node.operand, host)); const v = toNumber(evalScalar(node.operand, host));
@@ -173,18 +175,17 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
* comment). Only `node.args[0]` is always evaluated; whichever of the * comment). Only `node.args[0]` is always evaluated; whichever of the
* other two branches is taken is evaluated next, and the other is never * other two branches is taken is evaluated next, and the other is never
* touched at all -- so an error (or, in a block-mode call, any other side * touched at all -- so an error (or, in a block-mode call, any other side
* effect) in the untaken branch never surfaces. Same truthy convention as * effect) in the untaken branch never surfaces. Truthy via functions.ts's
* everywhere else in this engine (`execStatement`'s `ifstmt` case, above): * `isTruthy()` -- the one convention shared everywhere in this engine a
* a number is truthy unless it's exactly 0; text/refs are truthy unless * condition is needed (also `execStatement`'s `ifstmt` case, below, and
* empty. */ * `AND`/`OR`/`NOT`). */
function evalIf(node: FuncNode, host: EngineHost): CellValue { function evalIf(node: FuncNode, host: EngineHost): CellValue {
if (node.args.length !== 2 && node.args.length !== 3) { if (node.args.length !== 2 && node.args.length !== 3) {
return new FormulaError('#ERR', 'IF expects 2 or 3 arguments'); return new FormulaError('#ERR', 'IF expects 2 or 3 arguments');
} }
const cond = evalScalar(node.args[0], host); const cond = evalScalar(node.args[0], host);
if (isError(cond)) return cond; if (isError(cond)) return cond;
const truthy = typeof cond === 'number' ? cond !== 0 : cond !== null && cond !== ''; if (isTruthy(cond)) return evalScalar(node.args[1], host);
if (truthy) return evalScalar(node.args[1], host);
return node.args.length === 3 ? evalScalar(node.args[2], host) : 0; return node.args.length === 3 ? evalScalar(node.args[2], host) : 0;
} }
@@ -403,8 +404,7 @@ function execStatement(
case 'ifstmt': { case 'ifstmt': {
const c = evalInEnv(stmt.cond, env, host); const c = evalInEnv(stmt.cond, env, host);
if (isError(c)) throw new ReturnSignal(c); if (isError(c)) throw new ReturnSignal(c);
const truthy = typeof c === 'number' ? c !== 0 : c !== null && c !== ''; if (isTruthy(c)) execBlock(stmt.then, env, host, budget);
if (truthy) execBlock(stmt.then, env, host, budget);
else if (stmt.else) execBlock(stmt.else, env, host, budget); else if (stmt.else) execBlock(stmt.else, env, host, budget);
return; return;
} }
+35 -2
View File
@@ -27,6 +27,17 @@ function nums(values: CellValue[]): number[] {
return out; return out;
} }
/** The one truthy convention used everywhere in this engine a condition is
* needed (IF's condition -- both the formula function and its real
* short-circuit implementation in evaluator.ts's evalIf() --, a "Mit
* Schleife" block's IF statement, and AND/OR/NOT below): a number is
* truthy unless it's exactly `0`; text is truthy unless empty; an empty
* cell (`null`) is never truthy. Exported so evaluator.ts can reuse the
* exact same rule instead of duplicating it. */
export function isTruthy(v: CellValue): boolean {
return typeof v === 'number' ? v !== 0 : v !== null && v !== '';
}
export const FUNCTIONS: Record<string, FunctionFn> = { export const FUNCTIONS: Record<string, FunctionFn> = {
SUM(values) { SUM(values) {
return nums(values).reduce((a, b) => a + b, 0); return nums(values).reduce((a, b) => a + b, 0);
@@ -78,11 +89,30 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
} }
const [cond, thenValue, elseValue] = values; const [cond, thenValue, elseValue] = values;
if (isError(cond)) return cond; if (isError(cond)) return cond;
const truthy = typeof cond === 'number' ? cond !== 0 : cond !== null && cond !== ''; if (isTruthy(cond)) return thenValue;
if (truthy) return thenValue;
return values.length === 3 ? elseValue : 0; return values.length === 3 ? elseValue : 0;
}, },
// Ordinary (non-short-circuit) functions -- like real Excel, EVERY
// argument is evaluated regardless of the others (so e.g.
// =AND(1=2; 1/0) is still #DIV/0!, not FALSE, exactly as in Excel) --
// no special-casing in evaluator.ts needed, unlike IF/XLOOKUP. A range
// argument flattens into one truthy check per cell, same as Excel.
AND(values) {
if (values.length === 0) return new FormulaError('#ERR', 'AND expects at least 1 argument');
return values.every(isTruthy) ? 1 : 0;
},
OR(values) {
if (values.length === 0) return new FormulaError('#ERR', 'OR expects at least 1 argument');
return values.some(isTruthy) ? 1 : 0;
},
NOT(values) {
if (values.length !== 1) return new FormulaError('#ERR', 'NOT expects exactly 1 argument');
return isTruthy(values[0]) ? 0 : 1;
},
// Dates/times are plain numbers here (see dates.ts) -- DATE/TIME build one, // Dates/times are plain numbers here (see dates.ts) -- DATE/TIME build one,
// TODAY/NOW read the current one. `null` (empty cell) arguments count as 0, // TODAY/NOW read the current one. `null` (empty cell) arguments count as 0,
// same as everywhere else in this engine; a non-numeric text argument is // same as everywhere else in this engine; a non-numeric text argument is
@@ -169,6 +199,9 @@ export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> =
{ name: 'CONCAT', hint: 'CONCAT(Text1; Text2; …)' }, { name: 'CONCAT', hint: 'CONCAT(Text1; Text2; …)' },
{ name: 'CONCATENATE', hint: 'CONCATENATE(Text1; Text2; …)' }, { name: 'CONCATENATE', hint: 'CONCATENATE(Text1; Text2; …)' },
{ name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' }, { name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
{ name: 'AND', hint: 'AND(Bedingung1; Bedingung2; …)' },
{ name: 'OR', hint: 'OR(Bedingung1; Bedingung2; …)' },
{ name: 'NOT', hint: 'NOT(Bedingung)' },
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' }, { name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' }, { name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
{ name: 'TODAY', hint: 'TODAY()' }, { name: 'TODAY', hint: 'TODAY()' },
+22
View File
@@ -8,11 +8,14 @@
* them by look-ahead (`(` -> function, `!` -> sheet qualifier, digits -> cell). * them by look-ahead (`(` -> function, `!` -> sheet qualifier, digits -> cell).
*/ */
import { ERROR_CODES } from './types';
export type TokenType = export type TokenType =
| 'NUMBER' | 'NUMBER'
| 'STRING' | 'STRING'
| 'IDENT' // bare word: A1, SUM, Region, Q1 ... | 'IDENT' // bare word: A1, SUM, Region, Q1 ...
| 'QUOTED' // 'Kosten Q3' -- a sheet name in single quotes | 'QUOTED' // 'Kosten Q3' -- a sheet name in single quotes
| 'ERRORLIT' // a literal error code, e.g. "#REF" or "#DIV/0!" -- see ERROR_CODES
| 'PLUS' | 'PLUS'
| 'MINUS' | 'MINUS'
| 'STAR' | 'STAR'
@@ -74,6 +77,10 @@ function isWordPart(ch: string): boolean {
return isWordStart(ch) || isDigit(ch); return isWordStart(ch) || isDigit(ch);
} }
/** Longest-first, so a prefix of another code (none collide today, but this
* stays correct if one ever did) never wins the match by accident. */
const ERROR_CODES_BY_LENGTH = [...ERROR_CODES].sort((a, b) => b.length - a.length);
export function tokenize(src: string): Token[] { export function tokenize(src: string): Token[] {
const tokens: Token[] = []; const tokens: Token[] = [];
let i = 0; let i = 0;
@@ -182,6 +189,21 @@ export function tokenize(src: string): Token[] {
continue; continue;
} }
// an error-code literal, e.g. "#REF" or "#DIV/0!" -- lets a formula
// literally contain/produce one, same as typing it in Excel; also how a
// structural row/col delete marks a now-broken reference in formula text
// (see store/document.ts's row/col delete). Recognized as one token
// regardless of what follows (a subsequent "!"/"?" is already consumed
// as part of the matched code, never re-tokenized as BANG etc.).
if (ch === '#') {
const code = ERROR_CODES_BY_LENGTH.find((c) => src.startsWith(c, i));
if (code) {
tokens.push({ type: 'ERRORLIT', value: code, pos: i });
i += code.length;
continue;
}
}
// single-character punctuation / operators // single-character punctuation / operators
const t = SINGLE[ch]; const t = SINGLE[ch];
if (t) { if (t) {
+23
View File
@@ -31,6 +31,7 @@
* -2^2 = -(2^2) = -4 *) * -2^2 = -(2^2) = -4 *)
* primary = NUMBER * primary = NUMBER
* | STRING * | STRING
* | ERRORLIT
* | "(" expression ")" * | "(" expression ")"
* | funcall * | funcall
* | reference ; * | reference ;
@@ -63,6 +64,12 @@
* (backward-compatible -- * (backward-compatible --
* see reference() below). *) * see reference() below). *)
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; * CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
* ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
* "#VALUE!" | "#N/A" ; (* a literal error code,
* typed directly or
* produced by a
* structural row/col
* delete *)
* *
* Precedence, lowest to highest: comparison < & < + - < * / < unary +/- < ^ * Precedence, lowest to highest: comparison < & < + - < * / < unary +/- < ^
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
@@ -84,10 +91,12 @@
import { tokenize, type Token, type TokenType, LexError } from './lexer'; import { tokenize, type Token, type TokenType, LexError } from './lexer';
import { looksLikeCell, parseA1 } from './refs'; import { looksLikeCell, parseA1 } from './refs';
import type { ErrorCode } from './types';
export type Node = export type Node =
| NumberNode | NumberNode
| StringNode | StringNode
| ErrorLiteralNode
| BinaryNode | BinaryNode
| UnaryNode | UnaryNode
| FuncNode | FuncNode
@@ -106,6 +115,16 @@ export interface StringNode {
kind: 'string'; kind: 'string';
value: string; value: string;
} }
/** A literal error code typed directly into a formula, e.g. `=#REF!+1` or
* `=IF(A1=0;#N/A;A1)` -- same as typing one in Excel. Evaluates directly to
* that `FormulaError`, unconditionally (it's not the *result* of anything,
* just a value like a number or string literal). Also how a structural
* row/col delete represents a now-broken reference in stored formula text
* (see store/document.ts). */
export interface ErrorLiteralNode {
kind: 'errorliteral';
code: ErrorCode;
}
export interface BinaryNode { export interface BinaryNode {
kind: 'binary'; kind: 'binary';
op: '+' | '-' | '*' | '/' | '^' | '&' | '=' | '<>' | '>' | '<' | '>=' | '<='; op: '+' | '-' | '*' | '/' | '^' | '&' | '=' | '<>' | '>' | '<' | '>=' | '<=';
@@ -487,6 +506,10 @@ class Parser {
this.next(); this.next();
return { kind: 'string', value: t.value }; return { kind: 'string', value: t.value };
} }
if (t.type === 'ERRORLIT') {
this.next();
return { kind: 'errorliteral', code: t.value as ErrorCode };
}
if (t.type === 'LPAREN') { if (t.type === 'LPAREN') {
this.next(); this.next();
const inner = this.expression(); const inner = this.expression();
+2
View File
@@ -91,6 +91,7 @@ function expand(
switch (node.kind) { switch (node.kind) {
case 'number': case 'number':
case 'string': case 'string':
case 'errorliteral':
case 'cell': case 'cell':
case 'range': case 'range':
case 'namedref': case 'namedref':
@@ -179,6 +180,7 @@ export function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
return bound.get(node.name.toUpperCase()) ?? node; return bound.get(node.name.toUpperCase()) ?? node;
case 'number': case 'number':
case 'string': case 'string':
case 'errorliteral':
case 'cell': case 'cell':
case 'range': case 'range':
case 'namedref': case 'namedref':
+230
View File
@@ -1003,6 +1003,236 @@ describe('named ranges (document-level names for a fixed cell/range)', () => {
}); });
}); });
describe('insertRow/deleteRow/insertColumn/deleteColumn (mid-grid, not just edge growth)', () => {
const raw = (s: DocumentStore, table: string, a1: string) => {
const { col, row } = coords(a1);
return s.cellInput(tableId(s, table), col, row);
};
it('insertRow shifts cell data down from the insertion point, leaves earlier rows alone', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', 'x');
setCell(s, name, 'A2', 'y');
s.insertRow(id, 1);
expect(val(s, name, 'A1')).toBe('x'); // row 0, before the insertion point -- untouched
expect(val(s, name, 'A2')).toBeNull(); // new empty row
expect(val(s, name, 'A3')).toBe('y'); // shifted down
expect(s.tableById(id)!.rows).toBe(13); // default 12 + 1
});
it('deleteRow removes that row and shifts the rest up', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', 'x');
setCell(s, name, 'A2', 'y');
setCell(s, name, 'A3', 'z');
s.deleteRow(id, 1);
expect(val(s, name, 'A1')).toBe('x');
expect(val(s, name, 'A2')).toBe('z'); // 'y' is gone, 'z' shifted up
expect(s.tableById(id)!.rows).toBe(11);
});
it('deleteRow refuses to remove the last remaining row', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const initialRows = s.tableById(id)!.rows;
for (let i = 0; i < initialRows - 1; i++) s.deleteRow(id, 0);
expect(s.tableById(id)!.rows).toBe(1);
s.deleteRow(id, 0);
expect(s.tableById(id)!.rows).toBe(1); // no-op, refused
});
it('insertColumn/deleteColumn mirror the row behaviour, incl. colWidths', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', 'x');
setCell(s, name, 'B1', 'y');
s.setColWidth(id, 1, 111); // column B
s.insertColumn(id, 1);
expect(val(s, name, 'A1')).toBe('x');
expect(val(s, name, 'B1')).toBeNull();
expect(val(s, name, 'C1')).toBe('y'); // shifted right, width override moved with it
expect(s.tableById(id)!.colWidths[2]).toBe(111);
s.deleteColumn(id, 1); // remove the now-empty B
expect(val(s, name, 'A1')).toBe('x');
expect(val(s, name, 'B1')).toBe('y'); // shifted back left
expect(s.tableById(id)!.colWidths[1]).toBe(111);
});
it('a self-referencing formula (same table) is rewritten to keep pointing at the same logical cell', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B5', '20');
setCell(s, name, 'C1', '=B5');
expect(val(s, name, 'C1')).toBe(20);
s.insertRow(id, 2); // B5 (row 4) shifts to B6; C1 (row 0) stays put
expect(raw(s, name, 'C1')).toBe('=B6');
expect(val(s, name, 'C1')).toBe(20);
});
it('a cross-table qualified reference is rewritten too, not just formulas inside the edited table', () => {
const s = fresh();
const src = s.addTable(s.doc.activeSheetId);
s.renameObject(src, 'Quelle');
const dst = s.addTable(s.doc.activeSheetId);
s.renameObject(dst, 'Ziel');
setCell(s, 'Quelle', 'B5', '42');
setCell(s, 'Ziel', 'A1', '=Quelle!B5');
expect(val(s, 'Ziel', 'A1')).toBe(42);
s.insertRow(src, 2);
expect(raw(s, 'Ziel', 'A1')).toBe('=Quelle!B6');
expect(val(s, 'Ziel', 'A1')).toBe(42);
});
it('a range that straddles the insertion point grows by one row instead of just sliding', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B1', '1');
setCell(s, name, 'B2', '2');
setCell(s, name, 'B3', '3');
setCell(s, name, 'D1', '=SUM(B1:B3)');
expect(val(s, name, 'D1')).toBe(6);
s.insertRow(id, 1); // inside the range (row 1, between B1 and what was B2)
expect(raw(s, name, 'D1')).toBe('=SUM(B1:B4)'); // grew by one
setCell(s, name, 'B2', '99'); // the newly inserted row is now part of the range
expect(val(s, name, 'D1')).toBe(1 + 99 + 2 + 3);
});
it('a range fully after the deletion point shifts down, keeps its size', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B2', '10');
setCell(s, name, 'B3', '20');
setCell(s, name, 'D5', '=SUM(B2:B3)'); // away from row 0, so it survives the delete itself
s.deleteRow(id, 0); // delete row 0, entirely before the range -- range shifts, doesn't shrink in size
expect(raw(s, name, 'D4')).toBe('=SUM(B1:B2)');
expect(val(s, name, 'D4')).toBe(30);
});
it('deleting a row *inside* a multi-row range shrinks it by one, keeps the rest', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B1', '1');
setCell(s, name, 'B2', '2');
setCell(s, name, 'B3', '3');
setCell(s, name, 'D1', '=SUM(B1:B3)');
s.deleteRow(id, 1); // remove the middle row of the range
expect(raw(s, name, 'D1')).toBe('=SUM(B1:B2)');
expect(val(s, name, 'D1')).toBe(1 + 3); // only 1 and (shifted) 3 remain
});
it('a single-cell reference exactly on the deleted row becomes a literal #REF', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B2', '10');
setCell(s, name, 'C1', '=B2+1');
s.deleteRow(id, 1); // row 1 is exactly B2's row
expect(raw(s, name, 'C1')).toBe('=#REF+1');
expect(isError(val(s, name, 'C1'))).toBe(true);
});
it('a range entirely on the deleted row collapses to a literal #REF', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B2', '1');
setCell(s, name, 'D2', '2');
setCell(s, name, 'D1', '=SUM(B2:D2)'); // a 1-row range, entirely row 1
s.deleteRow(id, 1);
expect(raw(s, name, 'D1')).toBe('=SUM(#REF)');
expect(isError(val(s, name, 'D1'))).toBe(true);
});
it('a $-locked reference still shifts -- unlike copy/paste, $ has no effect on a structural insert/delete', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B5', '7');
setCell(s, name, 'C1', '=$B$5');
s.insertRow(id, 1);
expect(raw(s, name, 'C1')).toBe('=$B$6');
expect(val(s, name, 'C1')).toBe(7);
});
it('a reference to a row before the insertion/deletion point is left untouched, byte-identical', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '5');
// lower-case + extra spacing in the formula text -- proves an untouched
// reference (row 0, nowhere near the insertion point at row 5) is left
// completely alone, not just numerically equivalent
setCell(s, name, 'C1', '=a1 + 1');
s.insertRow(id, 5);
expect(raw(s, name, 'C1')).toBe('=a1 + 1');
s.deleteRow(id, 5);
expect(raw(s, name, 'C1')).toBe('=a1 + 1');
});
it('the sheet-qualified 3-part form ("Blatt!Tabelle::Zelle") is resolved and rewritten too', () => {
const s = fresh();
// fresh sheets, names that don't collide with the seed's own "Analyse"/
// "Umsatz" (the whole point of the 3-part form is disambiguating a name
// used on more than one sheet, but that's not what this test is about)
const sheetA = s.addSheet();
s.renameSheet(sheetA, 'ErsterBogen');
const src = s.addTable(sheetA);
s.renameObject(src, 'Quelle3');
const sheetB = s.addSheet();
s.renameSheet(sheetB, 'ZweiterBogen');
const dst = s.addTable(sheetB);
s.renameObject(dst, 'Ziel3');
setCell(s, 'Quelle3', 'B5', '42');
setCell(s, 'Ziel3', 'A1', '=ErsterBogen!Quelle3::B5');
expect(val(s, 'Ziel3', 'A1')).toBe(42);
s.insertRow(src, 2);
expect(raw(s, 'Ziel3', 'A1')).toBe('=ErsterBogen!Quelle3::B6');
expect(val(s, 'Ziel3', 'A1')).toBe(42);
});
it('undo/redo covers a row insert/delete, incl. the document-wide formula rewrite', () => {
vi.useFakeTimers();
try {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B5', '10');
setCell(s, name, 'C1', '=B5');
vi.advanceTimersByTime(600);
s.insertRow(id, 2);
vi.advanceTimersByTime(600);
expect(raw(s, name, 'C1')).toBe('=B6');
s.undo();
expect(raw(s, name, 'C1')).toBe('=B5');
expect(s.tableById(id)!.rows).toBe(12);
s.redo();
expect(raw(s, name, 'C1')).toBe('=B6');
expect(s.tableById(id)!.rows).toBe(13);
} finally {
vi.runOnlyPendingTimers();
vi.useRealTimers();
}
});
});
describe('functionSuggestions (formula autocomplete)', () => { describe('functionSuggestions (formula autocomplete)', () => {
it('filters built-ins by prefix, case-insensitively', () => { it('filters built-ins by prefix, case-insensitively', () => {
const s = fresh(); const s = fresh();
+337
View File
@@ -203,6 +203,203 @@ function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect):
return '=' + out; return '=' + out;
} }
// ---- row/col insert & delete: document-wide reference rewriting ---------
// A mid-grid row/col insert/delete (unlike edge growth/shrink, or copy/paste's
// shiftFormula above) can invalidate/relocate references from *any* formula
// in the *whole document*, not just the edited table's own -- another table's
// `Tabelle!Zelle` qualifier can point straight at it. `rewriteRefsForStructuralChange`
// below is the token-level rewriter that does that (same splice technique as
// shiftFormula: only the affected cell/range tokens' exact text is touched,
// everything else -- including whitespace -- is byte-identical); it's the
// engine used by `insertRow`/`deleteRow`/`insertColumn`/`deleteColumn` (in the
// store proper, below) via `rewriteDocumentRefs`.
type Axis = 'row' | 'col';
/** Insert-side shift for one coordinate: everything at/after `at` moves by
* +1. Applied independently to a single cell and to each range endpoint --
* for a range straddling `at` (start < at <= end), only the end moves, so
* the range *grows* by one to include the newly inserted row/col (the same
* behaviour Excel has for "insert a row inside a SUM range"). */
function insertShift(v: number, at: number): number {
return v >= at ? v + 1 : v;
}
/** Delete-side shift for a range's *start* endpoint: `at` itself stays `at`
* (the next surviving row/col slides into that slot); only something
* strictly after `at` moves down by one. */
function deleteShiftStart(v: number, at: number): number {
return v > at ? v - 1 : v;
}
/** Delete-side shift for a range's *end* endpoint: unlike the start
* endpoint, `at` itself also moves down by one here -- that row/col is
* gone, so the range's last surviving row/col is one less than before. */
function deleteShiftEnd(v: number, at: number): number {
return v >= at ? v - 1 : v;
}
function axisValue(axis: Axis, c: { col: number; row: number }): number {
return axis === 'row' ? c.row : c.col;
}
function withAxisValue(
axis: Axis,
c: { col: number; row: number; colAbs: boolean; rowAbs: boolean },
v: number,
): string {
return axis === 'row' ? toA1(c.col, v, c.colAbs, c.rowAbs) : toA1(v, c.row, c.colAbs, c.rowAbs);
}
/** True iff, with `toks[i]` a known "!" (BANG), the shape ahead is
* IDENT/QUOTED "::" -- i.e. this "!" really introduces a SHEET name
* followed by a table qualifier (the 3-part `Blatt!Tabelle::Zelle` form),
* not a bare (old-style) table qualifier of its own. Exact same lookahead
* as `parser.ts#Parser.looksLikeSheetThenTable`. */
function looksLikeSheetThenTable(toks: Token[], i: number): boolean {
const n1 = toks[i + 1];
const n2 = toks[i + 2];
return !!n1 && (n1.type === 'IDENT' || n1.type === 'QUOTED') && !!n2 && n2.type === 'DBLCOLON';
}
/**
* Rewrite every reference to `targetTableId` inside one formula for a
* structural row/col insert (`delta` `1`) or delete (`delta` `-1`) at index
* `at` on `axis`. `homeTableId` is the formula's *own* table (needed to
* resolve a bare, unqualified reference, and -- via a transiently-set
* `wb.homeTableId` -- to break a same-table-name-on-two-sheets ambiguity
* the exact same way `wb.resolveTableName` already does for evaluation;
* this function literally calls it).
*
* A reference landing exactly on a deleted row/col -- or a range that
* collapses entirely (every row/col it covered was the one deleted) --
* becomes a literal `#REF` (see engine/parser.ts's `ErrorLiteralNode`),
* the same thing Excel shows inline for a broken reference. A range that
* merely *shrinks* (only part of it was the deleted line) stays a valid,
* smaller range, exactly like Excel.
*/
function rewriteRefsForStructuralChange(
raw: string,
homeTableId: string,
targetTableId: string,
axis: Axis,
at: number,
delta: 1 | -1,
wb: Workbook,
): string {
if (!raw.startsWith('=')) return raw;
const body = raw.slice(1);
let toks: Token[];
try {
toks = tokenize(body);
} catch {
return raw; // unparsable -- leave as-is, the evaluator already surfaces #ERR
}
const savedHome = wb.homeTableId;
wb.homeTableId = homeTableId;
const resolve = (name: string, sheetName: string | null): string | null =>
wb.resolveTableName(name, sheetName);
interface Repl {
pos: number;
end: number;
text: string;
}
const repls: Repl[] = [];
let i = 0;
while (i < toks.length && toks[i].type !== 'EOF') {
const tok = toks[i];
let cellIdx = -1;
let resolvedTableId: string | null = null;
if ((tok.type === 'IDENT' || tok.type === 'QUOTED') && toks[i + 1]) {
if (toks[i + 1].type === 'BANG' && looksLikeSheetThenTable(toks, i + 1)) {
resolvedTableId = resolve(toks[i + 2].value, tok.value); // sheet=tok, table=toks[i+2]
cellIdx = i + 4; // consumed: IDENT/QUOTED "!" IDENT/QUOTED "::"
} else if (toks[i + 1].type === 'BANG' || toks[i + 1].type === 'DBLCOLON') {
resolvedTableId = resolve(tok.value, null);
cellIdx = i + 2;
}
}
if (cellIdx === -1 && tok.type === 'IDENT' && looksLikeCell(tok.value)) {
resolvedTableId = homeTableId;
cellIdx = i;
}
if (cellIdx === -1) {
i++;
continue;
}
const cellTokA = toks[cellIdx];
if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) {
i++; // qualifier not actually followed by a cell -- malformed, leave alone
continue;
}
const isRangeStart = toks[cellIdx + 1]?.type === 'COLON';
const cellTokB = isRangeStart ? toks[cellIdx + 2] : undefined;
const validRange = !!cellTokB && cellTokB.type === 'IDENT' && looksLikeCell(cellTokB.value);
if (resolvedTableId === targetTableId) {
const a = parseA1(cellTokA.value);
if (validRange && cellTokB) {
const b = parseA1(cellTokB.value);
const va = axisValue(axis, a);
const vb = axisValue(axis, b);
const lo = Math.min(va, vb);
const hi = Math.max(va, vb);
if (delta > 0) {
const newLo = insertShift(lo, at);
const newHi = insertShift(hi, at);
if (newLo !== lo) {
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, va === lo ? newLo : newHi) });
}
if (newHi !== hi) {
repls.push({ pos: cellTokB.pos, end: cellTokB.pos + cellTokB.value.length, text: withAxisValue(axis, b, vb === lo ? newLo : newHi) });
}
} else {
const newLo = deleteShiftStart(lo, at);
const newHi = deleteShiftEnd(hi, at);
if (newLo > newHi) {
repls.push({ pos: cellTokA.pos, end: cellTokB.pos + cellTokB.value.length, text: '#REF' });
} else {
if (newLo !== lo) {
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, va === lo ? newLo : newHi) });
}
if (newHi !== hi) {
repls.push({ pos: cellTokB.pos, end: cellTokB.pos + cellTokB.value.length, text: withAxisValue(axis, b, vb === lo ? newLo : newHi) });
}
}
}
} else {
const v = axisValue(axis, a);
if (delta > 0) {
const nv = insertShift(v, at);
if (nv !== v) {
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, nv) });
}
} else if (v === at) {
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: '#REF' });
} else if (v > at) {
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, v - 1) });
}
}
}
i = validRange ? cellIdx + 3 : cellIdx + 1;
}
wb.homeTableId = savedHome;
if (repls.length === 0) return raw;
repls.sort((x, y) => x.pos - y.pos);
let out = '';
let cursor = 0;
for (const r of repls) {
out += body.slice(cursor, r.pos) + r.text;
cursor = r.end;
}
out += body.slice(cursor);
return '=' + out;
}
function loadInitial(): DocumentState { function loadInitial(): DocumentState {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
@@ -1176,6 +1373,142 @@ function createStore() {
syncWorkbook(); syncWorkbook();
} }
// ---- row/col insert & delete (mid-grid, not just edge growth) ---------
// Unlike setTableRows/setTableCols above (pure edge resize) or
// removeLastRow/removeLastColumn, these shift cell data/formats/row-height
// (or column-width) overrides *within* the table around the insertion/
// deletion point, and rewrite every formula in the *whole document* that
// references a row/col at/after it on this table -- see
// `rewriteRefsForStructuralChange` above for the "how".
/** Move `doc.selection` (if it's on `tableId`) the same way a row/col
* insert/delete moves cell data -- so the active cell/marquee stays on
* the "same" logical cell it was on before, not wherever that grid
* position now happens to be. `clampSelection` (called by every caller
* right after) catches anything this pushes out of the shrunk bounds. */
function shiftSelectionForStructuralChange(tableId: string, axis: Axis, at: number, delta: 1 | -1) {
const s = doc.selection;
if (!s || s.tableId !== tableId) return;
const shift = (c: CellCoord): CellCoord => {
const v = axis === 'row' ? c.row : c.col;
const nv = delta > 0 ? insertShift(v, at) : deleteShiftStart(v, at);
return axis === 'row' ? { ...c, row: nv } : { ...c, col: nv };
};
doc.selection = {
tableId,
anchor: shift(s.anchor),
focus: shift(s.focus),
ranges: s.ranges?.map((r) => ({ anchor: shift(r.anchor), focus: shift(r.focus) })),
};
}
/** Rewrite every formula in the whole document referencing `targetTableId`
* after its rows/cols and cell data have already been shifted to their
* new shape (self-references inside `targetTableId` are included -- it's
* just another table as far as this loop is concerned). */
function rewriteDocumentRefs(targetTableId: string, axis: Axis, at: number, delta: 1 | -1) {
for (const s of doc.sheets) {
for (const o of s.objects) {
if (o.kind !== 'table') continue;
for (const [key, raw] of Object.entries(o.cells)) {
const next = rewriteRefsForStructuralChange(raw, o.id, targetTableId, axis, at, delta, wb);
if (next !== raw) o.cells[key] = next;
}
}
}
}
/** Re-key every entry of a "col,row" map, shifting the given axis at/after
* `at` by `delta` (dropping entries whose axis value === `at` when
* `delta` is -1 -- that row/col's own cells are gone). Shared by
* insertRow/deleteRow (axis 'row') and insertColumn/deleteColumn (axis
* 'col'). */
function shiftCellKeyedMap<T>(
map: Record<string, T>,
axis: Axis,
at: number,
delta: 1 | -1,
): Record<string, T> {
const out: Record<string, T> = {};
for (const [k, v] of Object.entries(map)) {
const [c, r] = k.split(',').map(Number);
const cur = axis === 'row' ? r : c;
if (delta < 0 && cur === at) continue; // this row/col's own cell is gone
const nv = delta > 0 ? insertShift(cur, at) : deleteShiftStart(cur, at);
const key = axis === 'row' ? `${c},${nv}` : `${nv},${r}`;
out[key] = v;
}
return out;
}
/** Same idea as shiftCellKeyedMap, for a single-axis map keyed by plain
* row/col number (rowHeights, colWidths). */
function shiftAxisKeyedMap(map: Record<number, number>, at: number, delta: 1 | -1): Record<number, number> {
const out: Record<number, number> = {};
for (const [k, v] of Object.entries(map)) {
const cur = Number(k);
if (delta < 0 && cur === at) continue;
out[delta > 0 ? insertShift(cur, at) : deleteShiftStart(cur, at)] = v;
}
return out;
}
function insertRow(tableId: string, at: number) {
const t = tableById(tableId);
if (!t) return;
const clamped = Math.max(0, Math.min(Math.floor(at), t.rows));
t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, 1);
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, 1);
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, 1);
t.rows += 1;
shiftSelectionForStructuralChange(tableId, 'row', clamped, 1);
rewriteDocumentRefs(tableId, 'row', clamped, 1);
clampSelection(tableId);
syncWorkbook();
}
function deleteRow(tableId: string, at: number) {
const t = tableById(tableId);
if (!t || t.rows <= 1) return;
const clamped = Math.max(0, Math.min(Math.floor(at), t.rows - 1));
t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, -1);
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, -1);
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, -1);
t.rows -= 1;
shiftSelectionForStructuralChange(tableId, 'row', clamped, -1);
rewriteDocumentRefs(tableId, 'row', clamped, -1);
pruneEmptyMaps(t);
clampTableView(t);
clampSelection(tableId);
syncWorkbook();
}
function insertColumn(tableId: string, at: number) {
const t = tableById(tableId);
if (!t) return;
const clamped = Math.max(0, Math.min(Math.floor(at), t.cols));
t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, 1);
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, 1);
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, 1);
t.cols += 1;
shiftSelectionForStructuralChange(tableId, 'col', clamped, 1);
rewriteDocumentRefs(tableId, 'col', clamped, 1);
clampSelection(tableId);
syncWorkbook();
}
function deleteColumn(tableId: string, at: number) {
const t = tableById(tableId);
if (!t || t.cols <= 1) return;
const clamped = Math.max(0, Math.min(Math.floor(at), t.cols - 1));
t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, -1);
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, -1);
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, -1);
t.cols -= 1;
shiftSelectionForStructuralChange(tableId, 'col', clamped, -1);
rewriteDocumentRefs(tableId, 'col', clamped, -1);
pruneEmptyMaps(t);
clampTableView(t);
clampSelection(tableId);
syncWorkbook();
}
// ---- cell formatting (alignment, number format, colours, bold/italic) --- // ---- cell formatting (alignment, number format, colours, bold/italic) ---
const fmtKey = (c: number, r: number) => `${c},${r}`; const fmtKey = (c: number, r: number) => `${c},${r}`;
function cellFormat(tableId: string, col: number, row: number): CellFormat { function cellFormat(tableId: string, col: number, row: number): CellFormat {
@@ -1837,6 +2170,10 @@ function createStore() {
rowHeightOf, rowHeightOf,
setTableRows, setTableRows,
setTableCols, setTableCols,
insertRow,
deleteRow,
insertColumn,
deleteColumn,
setTableView, setTableView,
// cell formatting // cell formatting
cellFormat, cellFormat,