Files
numolus/README.md
T
wkhandClaude Sonnet 5 fb5915d5be Add formula "point mode": click/drag a cell to insert its reference
Excel/Numbers-style reference picking: while editing a formula (buffer
starts with "="), a click on any grid cell -- in the same table or a
completely different one -- inserts its reference at the caret instead of
changing the selection; a drag inserts/grows a range reference live as you
drag. A further click/drag before typing anything replaces the just-inserted
reference; typing anything (";" to separate SUM() arguments included, but
not special-cased -- any character ends the window, same as Excel) makes the
next click append a fresh one instead.

- document.ts: whichever editor is open (in-cell editor or formula bar)
  registers a small handle (home tableId + read/replace the buffer);
  pointClick()/pointDrag() insert or replace a reference into it, qualifying
  with `Name!` (quoted if needed) when the clicked table differs from the
  editor's own. formulaPointModeActive() gates it on the buffer being a
  formula; noteFormulaTyped() (wired to real typing, not the programmatic
  inserts) ends the "replace" window.
- TableGrid.vue: onCellMouseDown checks point mode before its normal
  selection/edit handling, tracking a drag the same way normal selection
  does; dblclick during point mode no longer hijacks into editing that cell.
- FormulaBar.vue: registers on focus, same click/drag targeting works from
  there without disturbing the grid's own selection.

Verified interactively against the dev server with Playwright, including a
real bug the store-level tests (a mocked editor handle, no real DOM) missed
entirely: preventDefault() alone kept focus on the editor, but the mousedown
still bubbled to CanvasObject.vue's own handler, which selects/brings the
clicked table to front -- closing the very editor being pointed into. Fixed
with stopPropagation() alongside it. After that fix: same-table and
cross-table click/drag both insert correctly-qualified references, a second
click replaces before typing and appends after, and the live selection/
active object are untouched by a point-mode click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:41:27 +02:00

26 KiB
Raw Blame History

Numbers-Studie — Tabellenkalkulation mit eigener Formel-Engine

Eine browserbasierte Tabellenkalkulation als Case- und Designstudie 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.
  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.
npm install
npm run dev        # Vite-Devserver
npm run test       # Vitest-Suite (Engine + Store + IO), 146 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 dokumentweit eindeutigem Namen. Frei positionierbar (Ziehen an der Kopfleiste). Zwei Darstellungen:
    • Automatisch — so groß wie die Zellen, alle Zeilen sichtbar; der SE-Griff skaliert die Spaltenbreiten proportional.
    • Feste Größe — fester Viewport, intern scrollbar, mit frei wählbarer Zahl fixierter Kopfzeilen und fixierter Spalten (position: sticky gegen den Scroll-Container). Der SE-Griff zieht dann den Viewport auf. Spaltenbreite per Drag am Spaltenkopf (ab 24 px), Zeilenhöhe per Drag an der Unterkante des Zeilenkopfs (Standard + je Zeile).
  • 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+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+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
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

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.

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 <textarea> statt eines einzeiligen Felds — dadurch umbricht eine lange Formel statt seitwärts wegzuscrollen, und an der unteren Kante lässt sie sich per Maus (natives resize: vertical) auf beliebig viele Zeilen aufziehen, um mehr auf einmal zu sehen. Alt+Enter bzw. Cmd+Enter, F4 und Esc verhalten sich wie im In-Zellen-Editor.

Bezüge per Maus einfügen ("Point-Mode"): sobald der Editor (Zelle oder Formelleiste) eine Formel (=…) enthält, fügt ein Klick auf eine Zelle — in derselben Tabelle oder einer ganz anderen, mit Name!-Qualifier bei Bedarf 'in Quotes' — deren Bezug an der Cursorposition ein; Ziehen fügt stattdessen einen Bereichsbezug (A1:C3) ein, live wachsend während der Zug. Ein weiterer Klick/Zug ersetzt den zuletzt eingefügten Bezug, solange nichts dazwischen getippt wurde — jede Eingabe (ein ; als Argumenttrennzeichen ebenso wie jedes andere Zeichen) beendet dieses Fenster, der nächste Klick fügt dann einen neuen Bezug an. Ein Klick während des Point-Mode ändert nie die Tabellen-/Objektauswahl im Hintergrund.

Zell-Formatierung

Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im Inspector unter Objekt · Auswahl: horizontale Ausrichtung (links/mitte/rechts), Zahlenformat (Automatisch, Ohne, Ganzzahl, 1/2 Nachkommastellen, Prozent 0/2 Stellen — kombiniert mit der Locale unten), Zell-Textfarbe, Zell-Füllfarbe, Fett/Kursiv, „Formatierung entfernen". Auch exakte Spaltenbreite und Zeilenhöhe der Fokuszelle.

Ein Zahnrad neben der Überschrift öffnet eine linke Inspector-Schublade (SettingsPanel.vue), die die Fläche zur Seite schiebt (auf schmalen Viewports überlagert sie). Sie bündelt alle wichtigen Punkte:

  • Objekt (das gewählte):
    • Tabelle → Name, Zeilen/Spalten (exakt), Darstellung, Viewport-Größe, fixierte Kopfzeilen/Spalten, Position, Zahlenformat-Locale, Standard- Zeilenhöhe, Tabellen-Hintergrund/-Textfarbe, und – bei aktiver Zellauswahl – die Auswahl-Formatierung (s. o.).
    • Diagramm → Name, Ansicht, Quelltabelle, Größe, Position, Zahlenformat-Locale, Hintergrund, Text-/Achsenfarbe.
    • Textfeld → Name, Text, Schriftgröße, Fett/Kursiv, Ausrichtung, Textfarbe, Hintergrund, Größe, Position.
  • Blatt: Name, Zahlenformat, Hintergrundfarbe, Schriftfarbe, Raster ein-/ausblenden (↺ setzt eine Farbe auf den Theme-Wert zurück), neues Blatt, Blatt löschen.
  • Ansicht: Zoom-Regler (pro Blatt gespeichert).
  • Dokument: Erscheinungsbild (Auto/Hell/Dunkel), Zahlenformat (Ländereinstellung), CSV-Standard-Trennzeichen, „Auf Beispiel zurücksetzen".

Blatt-Erscheinungsbild

Jedes Blatt kann eine eigene Hintergrundfarbe (Canvas-Fläche hinter den Objekten) und Schriftfarbe (Standard-Textfarbe der Gitter/Labels) haben und das Punkt-Raster ein- oder ausblenden. Die Farben überschreiben die Theme-Variablen --bg / --text nur für dieses Blatt; ohne Wert gilt das Theme. Der Canvas-Zoom wird ebenfalls pro Blatt gemerkt.

Alle diese Einstellungen sind Teil des Dokuments (DocumentState / Sheet) und werden damit in localStorage gespeichert und in .json exportiert – Round-Trip verlustfrei, ungültige Werte werden beim Import verworfen bzw. geklemmt.

Zahlenformat (Ländereinstellung)

Die Darstellung von Zahlen (Dezimal- und Tausendertrennzeichen) ist eine Kaskade: Objekt → Blatt → Dokument. Jede Ebene kann die darüberliegende überschreiben oder „erben" (leere Auswahl). Auswahl: Ohne Trennzeichen, de-DE, de-CH, en-US, en-GB, fr-FR, it-IT. Wirkt auf Zellwerte, das Live-Aggregat der Statusleiste und die Wertelabels der Diagramme. CSV-/XLSX- Export bleibt bewusst locale-neutral (.-Dezimal, keine Gruppierung) für einen verlustfreien Round-Trip; der CSV-Import erkennt beide Schreibweisen ohnehin.

Das vorbefüllte „Umsatz" ist bereits als feste, scrollbare Tabelle mit fixierter Kopfzeile und fixierter Spalte A angelegt, um das Feature zu zeigen.


Formelgrammatik (EBNF)

formula     = "=" expression ;

expression  = term { ("+" | "-") term } ;          (* links-assoziativ *)
term        = unary { ("*" | "/") unary } ;         (* links-assoziativ *)
unary       = ("+" | "-") unary
            | power ;
power       = primary [ "^" unary ] ;               (* rechts-assoziativ; bindet
                                                       stärker als unäres Minus,
                                                       d.h. -2^2 = -(2^2) = -4    *)
primary     = NUMBER | STRING | "(" expression ")" | funcall | reference ;

funcall     = IDENT "(" [ expression { ";" expression } ] ")" ;

reference   = [ qualifier ] CELL [ ":" CELL ] ;
qualifier   = (IDENT | QUOTED) ("!" | "::") ;       (* Tabellenname *)

IDENT       = /[A-Za-z_][A-Za-z0-9_]*/ ;
QUOTED      = /' ( [^'] | '' )* '/ ;                (* Name mit Leerzeichen  *)
CELL        = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;

Präzedenz schwach → stark: + - < * / < unäres + - < ^.

Der Qualifier ist der Tabellenname (nicht der Blattname). ! und der 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).

Fehlermodell (propagiert an abhängige Zellen): #REF (ungültiger/gelöschter Bezug oder unbekannter Tabellenname), #DIV/0!, #NAME? (unbekannte Funktion), #ERR (Parse-Fehler), #CIRC (Zirkelbezug, auch tabellenübergreifend), #VALUE! (Typfehler in Arithmetik — dokumentierte Erweiterung). Referenz auf leere Zelle = 0 in Arithmetik, von COUNT nicht gezählt.


Architektur der Engine-Module

Framework-agnostisches ES-Modul unter src/engine/ — keine Formel-/Spreadsheet-Library. Kennt kein Vue, kein DOM. Die adressierbare Einheit ist eine Tabelle; der Abhängigkeitsgraph läuft dokumentweit über alle Tabellen aller Blätter.

Datei Aufgabe
lexer.ts Tokenizer, flacher Token-Strom (inkl. ! und ::).
parser.ts Recursive-Descent → AST; EBNF im Dateikopf; collectRefs().
evaluator.ts AST + EngineHost → Wert; Fehler-Propagierung.
graph.ts DependencyGraph: affected(), topoOrder(), Zirkel-DFS.
refs.ts A1 ↔ (col,row), Bereichs-Expansion.
functions.ts { NAME → fn(values) } für SUM/AVG/MIN/MAX/COUNT.
workbook.ts Flache Tabellenliste + ASTs + Werte + Graph; inkrementelle Neuberechnung; implementiert EngineHost.

Neuberechnung. setCell reparst eine Zelle → graph.affected(dirty) → graph.topoOrder() → Zyklus-Zellen #CIRC, Rest topologisch auswerten, nicht betroffene Zellen behalten ihren Wert.

Anbindung an Vue. src/store/document.ts ist das „eigene reactive()-Modul": es hält eine Workbook-Instanz und einen recalcTick-Ref. Die UI liest store.cellValue(...), das recalcTick berührt — nach jeder Neuberechnung wird der Tick erhöht, jede Formelzelle re-rendert also wie ein computed.


Selbst gebaut vs. Library

Selbst gebaut Library
Formel-Engine komplett (Lexer, Parser, AST, Evaluator, dokumentweiter Abhängigkeitsgraph, Topo-Sort, Zirkel-Erkennung, Funktionen) Vue 3 + Vite
Die Canvas: Objekte platzieren, verschieben, skalieren, Z-Order, Zoom Chart.js (Balken + Linie)
Das Raster je Tabelle: Auswahl (Klick/Shift/Drag), Tastaturnavigation, In-Zellen-Edit, Spaltenbreite per Drag xlsx (SheetJS) — nur .xlsx I/O, lazy geladen, nie zur Auswertung
Reaktiver Store / Dokumentmodell (v1 → v2 Migration) —
CSV-Parser + -Writer (RFC 4180, Quoting, Delimiter-Sniffing, Locale-Zahlen)
JSON-Serialisierung + Schema-Validierung
Sheet-Tabs (Drag-Sortierung), Kontextmenü, Modals, Toasts, Theme

Bewusst nicht verwendet: Handsontable, AG Grid, Univer, Luckysheet, HyperFormula, formula-parser. @vueuse/core/splitpanes (in der Aufgabe als optionale Komfort-Libs genannt) wurden nach dem Umbau auf das Canvas-Modell nicht mehr gebraucht und wieder entfernt, um den Abhängigkeitsbaum ehrlich zu halten.


Import / Export

Alles clientseitig (Blob + URL.createObjectURL + <a download> + revoke).

Export

Format Inhalt
.json Natives Format, verlustfreier Round-Trip: alle Blätter, alle Tabellen-/Diagramm-Objekte mit Position & Größe, Rohformeln, Spaltenbreiten, Tab-Reihenfolge, aktives Objekt, Zellauswahl.
.csv Aktive Tabelle, berechnete Werte, RFC-4180-Quoting, Trennzeichen ,/; wählbar, \r\n, optional UTF-8-BOM.
.xlsx Arbeitsmappe via SheetJS, ein Worksheet je Tabellen-Objekt. Formeln als echte Excel-Formeln (;→,, AVG→AVERAGE), immer mit gecachtem Wert.
.png Gewähltes Diagramm-Objekt über chart.toBase64Image().

Import

<input type="file"> und Drag & Drop auf die Canvas; .json .csv .xlsx.

  • .json → Dokument ersetzen (Rückfrage) oder Blätter anhängen. v1-Dokumente (altes Ein-Raster-Format) werden automatisch nach v2 migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema wird validiert; bei Fehler klare Meldung, kein Teil-Import.
  • .csv → „als neue Tabelle (aktuelles Blatt)" / „aktive Tabelle ab A1 ersetzen". Trennzeichen-Sniffing (+ Override). Locale-Erkennung (1.234,56 ↔ 1,234.56). Werte mit führendem = werden zu Formeln.
  • .xlsx → je Worksheet ein neues Blatt mit einer Tabelle.
  • Nach jedem Import: kompletter Reparse + Neuberechnung + Diagramm-Refresh. Schlägt ein Import fehl, bleibt das bestehende Dokument unverändert.

Persistenz

Das gesamte Dokument wird (debounced) nach localStorage gespiegelt, robust gegen leeren/kaputten State — Fallback ist das Seed-Dokument. Ein v1-State im Storage wird beim Laden migriert. Button „Zurücksetzen" stellt das Seed-Dokument wieder her.

Vorbefülltes Dokument

Blatt „Analyse" mit drei Tabellen-Objekten (Umsatz, Kosten, Übersicht) und einem Diagramm (Umsatz je Region), plus ein leeres Blatt „Blatt 2". „Übersicht" demonstriert tabellenübergreifende Bezüge und die vier Grundrechenarten: Übersicht!B2 = =SUM(Umsatz!B2:E2), D2 = =B2-C2, E2 = =D2/B2*100. Siehe src/data/seed.ts.

Hinweis zur Aufgabenstellung: Die Akzeptanzliste nennt „Übersicht!B2 = 6070". Die explizit angegebene Nord-Zeile ergibt jedoch 1200+1450+1600+1720 = 5970. Die Studie hält sich an die explizite Tabelle; das demonstrierte Verhalten (Sofort-Propagierung durch Übersicht, „Gesamt" und das Diagramm bei Änderung von Umsatz!B2) ist davon unberührt.

Tests

npm run test — Vitest, 146 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; cycleReferenceLocks durchläuft die vier Kombinationen, trifft bei Cursor auf einem Bereich die richtige Seite, liefert null ohne Treffer; bei echter Textauswahl schaltet es alle überlappten Bezüge auf einmal um (jeden vom eigenen Zustand aus) und lässt Tabellen-Qualifier, die wie eine Zelle aussehen (AB12!A1), unangetastet.
  • 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.
  • workbook / store — Seed-Aggregate, Cross-Table-Summen, Edit-Ripple durch die ganze Kette, #REF nach Tabellen-/Blatt-Löschung, neues Tabellen-Objekt mit Cross-Sheet-Formel, resetToSeed, Live-Auswahl-Aggregat, Neuberechnungs-Reihenfolge, v1 → v2 Migration.
  • Tabellen-Ansicht — Seed-Umsatz ist fixiert/scrollbar; setTableView klemmt fixierte Zeilen/Spalten an die Gittergröße; Verkleinern löscht Zellen außerhalb und klemmt nach; Ansichts-Einstellungen im JSON-Round-Trip.
  • Zahlenformat-Kaskade — cellDisplay nutzt Dokument-Locale; Blatt-Override schlägt Dokument, Objekt-Override schlägt Blatt; Zurücksetzen fällt die Kaskade hinab; Overrides im JSON-Round-Trip, ungültige Werte werden verworfen.
  • Blatt-Erscheinungsbild — Hintergrund/Schrift/Raster sind pro Blatt und löschbar; Zoom pro Blatt (folgt dem Tab); Erscheinungsbild + Zoom + CSV-Default im JSON-Round-Trip, kaputte Farb-/Zoom-Werte verworfen bzw. geklemmt.
  • Zell-Formatierung — per-Zelle Zahlenformat × Locale (inkl. Prozent); setRangeFormat über einen Bereich, Löschen per Falsy/auto; selectionFormat meldet nur gemeinsame Werte; Zeilenhöhe (Standard + je Zeile), Spaltenbreite ab 24 px; Verkleinern pruned formats/rowHeights; alles im JSON-Round-Trip.
  • Text-Objekte — einfügen/bearbeiten/formatieren + Round-Trip; kaputtes Text-Objekt im Import wird abgelehnt, Dokument bleibt unverändert.
  • Objekt-Farben — je Tabelle/Diagramm/Textfeld eigenes bg/fg, löschbar, im JSON-Round-Trip.
  • Mehrfachauswahl — addToSelection behält frühere Rechtecke & verschiebt 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; 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.
  • Ausfüllkästchen — füllt runter/rechts mit bezugsangepassten Formeln; ein mehrzeiliger/-spaltiger Quellbereich wiederholt sich zyklisch, auch bei einer unvollständigen letzten Wiederholung; füllt ebenso hoch/links; Formatierung wird mitkopiert.
  • Rückgängig/Wiederholen — Einzel-Edit rückgäng-/wiederholbar; mehrere Edits schrittweise in der richtigen Reihenfolge zurück; ein neuer Edit nach einem Undo verwirft den Wiederholen-Ast; reine Auswahländerungen sind nie ein Schritt; deckt Kopieren/Einfügen, Ausfüllen und Formatierung ab (nicht nur setCellInput); „Auf Beispiel zurücksetzen" beginnt eine neue Historie.
  • Formel-Point-Mode — Klick fügt einen lokalen Bezug an der Cursorposition ein; ein zweiter Klick ohne Zwischen-Tippen ersetzt ihn, mit Tippen dazwischen hängt er einen neuen an; Ziehen fügt/erweitert einen Bereichsbezug; ein Klick in eine andere Tabelle qualifiziert mit deren Namen (Quoting bei Leerzeichen); ohne Formel im Puffer greift nichts.
  • 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, =1+2→3, Locale-Zahlen.

Bekannte Grenzen

  • Zeilen/Spalten nur am Rand. Einfügen/Löschen innerhalb eines Gitters (mit automatischem Umschreiben relativer Bezüge) ist bewusst nicht implementiert.
  • Objekt-Bezüge beim Anhängen. Beim JSON-Anhängen zweier Dokumente werden nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der erste Treffer. „Ersetzen" (der Round-Trip-Fall) ist davon nicht betroffen.
  • Kein Virtual Scrolling — „Feste Größe" scrollt intern, aber alle Zeilen bleiben im DOM (nur geclippt); sehr große Tabellen/Importe (viele Tausend Zeilen) werden träge.
  • Große Dateien werden per setTimeout-Yield verarbeitet (UI bleibt reaktiv), kein echter Web Worker.
  • xlsx-Formel-Übernahme ist heuristisch (Separator-Swap unter Beachtung von Quotes); exotische Excel-Formeln fallen auf den Wert zurück.
  • Kein Text-Verkettungs-/Vergleichsoperator, keine benannten Bereiche, kein Kopieren/Einfügen von Zellbereichen, keine Undo-Historie, kein Snapping der Canvas-Objekte.

Projektstruktur

src/
  engine/           # Formel-Engine (Eigenbau) + Unit-Tests
  store/            # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
  io/               # csv / json (Eigenbau, inkl. v1→v2), xlsx (SheetJS, lazy)
  components/       # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
                    #   TableGrid, ChartView, TextBox, SheetTabs, StatusBar, TheLogo, …
  numolus-logo.svg  # Marke: drei überlappende Pop-Art-Quadrate + Schriftzug
  config/brand.ts   # Herausgeber-Metadaten (wicom.li) + Social-Media-Liste
  composables/      # Theme, Toasts
  data/seed.ts      # vorbefülltes Dokument
  styles/main.css   # Design-Tokens (Light/Dark)