# numolus — Tabellenkalkulation mit eigener Formel-Engine
Eine browserbasierte Tabellenkalkulation nach dem Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
1. **Handwerk** — eine von Grund auf selbst geschriebene, schlanke Formel-Engine
(Tokenizer, Parser, AST, Abhängigkeitsgraph, Neuberechnung, Zirkelbezug-
Erkennung). Dokumentiert in [`src/engine/README.md`](src/engine/README.md).
2. **Produkt** — eine polierte, Numbers-nahe Oberfläche: jedes Blatt ist eine
**freie Fläche (Canvas)**, auf die man beliebig viele **Tabellen- und
Diagramm-Objekte** setzt, verschiebt, skaliert und befüllt.
```bash
npm install
npm run dev # Vite-Devserver
npm run test # Vitest-Suite (Engine + Store + IO), 553 Tests
npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config)
```
Alles läuft **rein clientseitig** — kein Server, keine Netzwerk-Requests. Datei-
I/O über File API und Blob-URLs.
---
## Das Canvas-Modell
Wie in Numbers ist ein *Blatt* kein einzelnes Endlos-Raster, sondern eine
scroll- und zoombare Fläche, die **Objekte** trägt:
- **Tabellen-Objekt** — ein eigenes Gitter mit einem Namen, der **pro Blatt**
eindeutig ist (siehe „Formelgrammatik" unten für Bezüge über mehrere Blätter
mit demselben Tabellennamen). Frei positionierbar (Ziehen an der
Kopfleiste). Zwei Darstellungen:
- **Automatisch** — so groß wie die Zellen, alle Zeilen sichtbar. Der
SE-Griff zieht hier **direkt an Zeilen-/Spaltenzahl**: Vergrößern fügt
leere Zeilen/Spalten hinzu, Verkleinern entfernt sie — aber **nie** über
echten Zellinhalt hinweg. Während des Ziehens zeigt ein gestricheltes
Vorschau-Rechteck die Zielgröße (die eigentliche Änderung ist teuer —
ein voller Neuaufbau der Engine — und committet daher erst beim
Loslassen, nicht bei jeder Mausbewegung); dann wird auf die kleinste
Größe geklemmt, die den vorhandenen Inhalt noch vollständig zeigt.
- **Feste Größe** — ein fester, unabhängig wählbarer **Pixel-Viewport**
mit frei wählbarer Zahl **fixierter Kopfzeilen und fixierter Spalten**
(`position: sticky` gegen den Scroll-Container) — für eine große
Tabelle, die nur einen kleinen Ausschnitt auf dem Canvas belegen soll,
mit internem Scrollen für den Rest. Der SE-Griff zieht hier **nur den
Viewport**, unabhängig von der Zeilen-/Spaltenzahl (die bleibt exakt wie
sie ist) — dieselben Breiten-/Höhenfelder im Einstellungen-Panel (⚙, s.
u.) steuern denselben Wert. **Zeilen-Virtualisierung**: nur die
Zeilen im sichtbaren Ausschnitt (+ etwas Überhang) landen tatsächlich im
DOM — alles andere ist ein Platzhalter-`
` in passender Höhe, damit
Scrollbar-Größe/-Position stimmen. Macht auch eine Tabelle mit
zehntausenden Zeilen praktikabel (gemessen: `PageDown` auf 50.000
Zeilen ging von ~7s auf ~15ms); nur für **feste** Tabellen (die einzige
Ansicht mit einem echten, begrenzten Scroll-Container) — eine **auto**-
große Tabelle dieser Größe wäre ohnehin ein unbrauchbar hohes
Canvas-Objekt, unabhängig vom DOM. Nur Zeilen, nicht Spalten (in der
Praxis wird eher die Zeilenzahl groß).
Spaltenbreite per Drag am Spaltenkopf (ab **24 px**), **Zeilenhöhe** per Drag
an der Unterkante des Zeilenkopfs (Standard + je Zeile). **„Ohne Rahmen/
Kopfzeilen, wenn nicht ausgewählt"** (Einstellungen → Objekt, je Tabelle):
solange die Tabelle nicht das ausgewählte Canvas-Objekt ist, verschwinden
Titelleiste, Spaltenbuchstaben und Zeilennummern komplett — nicht nur
optisch, ihr reservierter Platz fällt mit weg, die Tabelle schrumpft auf
reines Zellraster. Ein Klick hinein (z. B. auf eine Zelle) wählt sie
wieder aus und blendet alles zurück ein.
- **Diagramm-Objekt** — Chart.js (Balken/Linie), frei positionier- **und**
skalierbar. Datenquelle = ein Tabellen-Objekt; jede Zelländerung aktualisiert
es sofort.
- **Text-Objekt** — ein formatierbares Textfeld als Zusatz zu Tabellen und
Diagrammen. Doppelklick zum Bearbeiten; Schriftgröße, Fett/Kursiv, Ausrichtung,
Text- und Hintergrundfarbe im Inspector; frei skalierbar.
Objekte kommen beim Anklicken in den Vordergrund, werden per `Entf` gelöscht
(Diagramm/Text) bzw. über das `×` der Kopfleiste, per Doppelklick auf den Namen
umbenannt. **Je Tabelle, Diagramm und Textfeld** sind eigene Hintergrund- und
Textfarbe einstellbar. Mehrere Blätter über die Tabs unten (hinzufügen,
umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
(`Strg/Cmd` + Mausrad, Zoom-Leiste oder Einstellungen).
### Zellauswahl
- Klick = eine Zelle, **Shift+Klick / Ziehen** = ein Rechteck. Das Ziehen ist
positions- statt event-target-basiert (`elementsFromPoint`), übersteht schnelle
Bewegungen und das Verlassen der Tabelle, scrollt bei festen Tabellen an der
Kante automatisch mit und heilt einen verlorenen `mouseup` selbst.
- Kopf-Klick = ganze Spalte / Zeile, Ecke = alles.
- **`Strg`/`Cmd`+`A`** (zweistufig, Excel-Verhalten): der **erste** Druck
markiert den **zusammenhängenden Bereich** um die aktive Zelle — iterative
Rechteck-Expansion, die an einer vollständig leeren Zeile/Spalte oder am
Tabellenrand stoppt (zwei durch eine leere Spalte/Zeile getrennte
Dateninseln bleiben getrennt). Ein **zweiter, unmittelbar folgender** Druck
erweitert auf **alle befüllten Zellen der Tabelle** — die tatsächlichen
Grenzen befüllter Felder, nicht die (oft größeren) Grunddimensionen der
Tabelle. Jeder weitere Druck bleibt auf dieser Vollauswahl stehen. Der
Zyklus erkennt sich selbst rein über einen Vergleich der aktuellen Auswahl
mit der beim letzten Druck gesetzten — jede andere Aktion dazwischen (Klick,
Pfeiltaste, Bearbeitung, …) setzt ihn auf Stufe 1 zurück, ganz wie in Excel.
- **`Strg`/`Cmd`+Klick** = weitere Zelle **hinzufügen** (erneut = abwählen),
**`Strg`/`Cmd`+Ziehen** = weiteres Rechteck; auch am Spalten-/Zeilenkopf.
- 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**
(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 (mit eigenem Rahmen um deren erste Zelle, bleibt stehen, auch
wenn die aktuelle Auswahl längst woanders ist), `Esc` verwirft den Puffer.
- **Ausfüllkästchen** (das kleine Quadrat unten rechts an der Auswahl, wie in
Excel/Numbers): ziehen wiederholt die Auswahl — Werte, Formeln
(bezugsangepasst) und Formatierung — in die überstrichenen Zellen. Nur eine
Achse gleichzeitig (rein runter/hoch/rechts/links, je nachdem wohin weiter
gezogen wird); ein mehrzeiliger/-spaltiger Ausgangsbereich wird zyklisch
wiederholt, auch über eine unvollständige letzte Wiederholung hinweg.
**Tastaturnavigation (Excel-nah)** — Fokus in der Tabelle:
| Taste | Verhalten |
|---|---|
| Pfeiltasten | eine Zelle; **Shift+Pfeil** erweitert die Auswahl |
| **`Strg`/`Cmd`+Pfeil** | zum Rand des Datenblocks springen (leer↔gefüllt) |
| **Tab / Shift+Tab** | rechts / links |
| **Enter / Shift+Enter** | runter / hoch — nach einer Tab-Folge **zurück zur Startspalte + eine Zeile** |
| bei aktivem **Bereich** | Tab/Enter **rotieren den Cursor im Bereich** (mit Umbruch), der Bereich bleibt markiert |
| 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`+`A`** | 1. Druck: zusammenhängender Bereich um die aktive Zelle · 2. Druck: alle befüllten Zellen der Tabelle (s. o. „Zellauswahl") |
| **`Strg`/`Cmd`+`C`/`X`/`V`** | Kopieren / Ausschneiden / Einfügen (Zellen + Formatierung, bezugsangepasst) |
| **`Strg`/`Cmd`+`Z`** · **`Strg`/`Cmd`+`Y`** / **`Strg`/`Cmd`+`Shift`+`Z`** | Rückgängig · Wiederholen (auch als Symbolleisten-Buttons ↶ ↷) |
| **`F4`** (im Editor/in der Formelleiste) | Bezug am Cursor sperren/zyklisch umschalten: `A1` → `$A$1` → `A$1` → `$A1` → `A1`; bei markiertem Text werden **alle** darin gefundenen Bezüge auf einmal umgeschaltet, jeder ausgehend vom eigenen Zustand |
| **Pfeiltasten** (im Editor/in der Formelleiste, an Stellen wo ein Bezug erwartet wird) | „Point Mode" per Tastatur — zeigt auf eine Zelle statt den Text-Cursor zu bewegen, genau wie ein Klick es täte; nur direkt nach `=`, einem Operator, `(` oder `;` (sonst normale Text-Navigation, mitten in der Formel editierbar). **Shift+Pfeil** erweitert zu einem Bereich (`A1:A5`). Ein Zeichen tippen, `Tab`/`Enter` oder `;` (nächstes Argument) übernehmen den gezeigten Bezug |
| **`Alt`+`Enter`** bzw. **`Cmd`+`Enter`** (im Editor) | Zeilenumbruch in der Zelle einfügen, statt zu committen — `Cmd`+`Enter` als Mac-Fallback, da `⌥` (Option) dort eine Compose-Taste ist und nicht überall zuverlässig als reines Modifier ankommt |
| `↑`/`↓`/`Tab`/`Enter`/`Esc` (bei offenem Funktions-Dropdown) | navigieren / Vorschlag übernehmen / nur das Dropdown schließen — hat Vorrang vor der sonstigen Bedeutung dieser Tasten (s. u. „Funktions-Autovervollständigung") |
Bei **festen (scrollbaren) Tabellen** wird die aktive Zelle bei jeder Navigation
automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter
Zeilen/Spalten).
**Rückgängig/Wiederholen**: dokumentweiter Undo-Stack (Snapshot-basiert, wie
der bereits vorhandene JSON-Export/Import-Round-Trip — einfach und
nachweisbar korrekt für ein Dokument dieser Größe, statt eines Patch-/
Kommando-Logs). Reine Auswahl-/Cursor-Bewegungen zählen nie als Schritt; eine
schnelle Folge von Mutationen (ein Spalten-/Zeilen-Resize-Drag feuert bei
jeder Mausbewegung) wird zu **einem** Schritt zusammengefasst, sofern sie
innerhalb von 150 ms aufeinanderfolgen — knapp unter normalem Tipp-/Klick-
Tempo, damit zwei tatsächlich getrennte schnelle Aktionen nicht verschmelzen,
aber weit über dem Abstand aufeinanderfolgender Drag-Ereignisse (~16–30 ms).
Ein neuer Bearbeitungsschritt verwirft sofort einen etwaigen Wiederholen-Ast.
„Auf Beispiel zurücksetzen" und JSON-Import „Ersetzen" starten eine **neue**
Historie (das vorherige Dokument ist danach nicht mehr zurückholbar), Undo/
Redo selbst tun das nicht. Symbolleiste zeigt zwei Buttons (↶ ↷, deaktiviert
wenn nichts zu tun ist); greift auch ohne Tabellenfokus, außer während eine
Texteingabe (Zell-Editor, Formelleiste, Blatt-Umbenennen, …) den nativen
Undo dieses Feldes benutzen soll.
**Performance bei großen Tabellen**: Undo-Tracking und die (debounced)
`localStorage`-Persistenz werden **nicht** über einen `watch(doc, ...,
{deep:true})` angestoßen — Vues Deep-Watch muss dafür bei *jeder* Mutation den
kompletten reaktiven Objektgraphen erneut durchlaufen, ein Aufwand
proportional zur Gesamtgröße des Dokuments statt zur eigentlichen Änderung
(gemessen: bei einer 50.000-Zeilen-Tabelle kostete ein einzelner
Zellen-Edit dadurch ~450 ms, unabhängig vom Umfang der Änderung). Stattdessen
ruft jede inhaltsverändernde Store-Funktion explizit `markDirty()` auf (nach
demselben Muster wie `bump()`/`recalcTick` für den Engine-Cache) — reine
Auswahl-/Navigations-Funktionen rufen nur `scheduleSave()` (Persistenz ohne
Undo-Schritt). Der Kompromiss: `markDirty()` prüft nicht mehr bei jeder
Mutation per Fingerprint-Vergleich, ob wirklich etwas geändert wurde (genau
dieser Vergleich bei jeder Mutation war der teure Teil) — dieses Sicherheitsnetz
bleibt in `commitHistoryStep()` erhalten, läuft aber nur noch einmal pro
debounced Schritt statt pro Mutation. `setCellInput` hat zusätzlich eine
eigene, günstige No-Op-Prüfung (unverändert übernommener Wert, z. B. `F2`,
`Enter` ohne Änderung), damit der häufigste Fall keinen Schritt erzeugt und
den Wiederholen-Ast nicht verwirft.
**In-Zellen-Editor**: das Eingabefeld liegt als Overlay über der Zelle (nicht
in ihr) und wächst mit dem Inhalt — in der Breite bis zu einer angenäherten
„sichtbaren Anzeigebreite", danach umbrechend; in der Höhe mit `Alt`+`Enter`
eingefügten Zeilenumbrüchen, bis zu einem Maximum, danach intern scrollbar.
Ein Zeilenumbruch bleibt auch nach dem Committen erhalten und wird in der
Zelle als solcher dargestellt (umbrechend statt einzeiliger Ellipse), auf die
Zeilenhöhe der Tabelle geklemmt.
**Formelleiste** (`fx`, oben): ein `