11 KiB
components/canvas/ — Canvas-Engine
Der Canvas ist das Herzstück von LemonSpace. Er basiert auf @xyflow/react (React Flow) und synchronisiert seinen Zustand bidirektional mit Convex.
Architektur
app/(app)/canvas/[canvasId]/page.tsx
└── <CanvasShell canvasId={...} /> ← components/canvas/canvas-shell.tsx
├── Resizable Sidebar/Main Layout ← shadcn `resizable`
├── <CanvasSidebar railMode={...}> ← collapsible Rail/Fulllayout
└── <Canvas canvasId={...} /> ← components/canvas/canvas.tsx
├── <ReactFlowProvider>
│ └── <CanvasInner> ← Haupt-Komponente (~1800 Zeilen)
│ ├── Convex useQuery ← Realtime-Sync
│ ├── nodeTypes Map ← node-types.ts
│ ├── localStorage Cache ← canvas-local-persistence.ts
│ ├── Interaction-Hooks ← canvas-*.ts Helper
│ └── Panel-Komponenten
└── Context Providers
canvas.tsx ist weiterhin die zentrale Orchestrierungsdatei. Viel Low-Level-Logik wurde in dedizierte Module ausgelagert, aber Mutations-Flow, Event-Wiring und Render-Composition liegen weiterhin hier.
Interne Module von canvas.tsx
| Datei | Zweck |
|---|---|
canvas-helpers.ts |
Shared Utility-Layer (Optimistic IDs, Node-Merge, Compare-Resolution, Edge/Hit-Helpers, Konstante Defaults) |
canvas-presets-context.tsx |
Shared Preset-Provider für Adjustment-Nodes; bündelt presets.list zu einer einzigen Query |
canvas-node-change-helpers.ts |
Dimensions-/Resize-Transformationen für asset und ai-image Nodes |
canvas-generation-failures.ts |
Hook für AI-Generation-Error-Tracking mit Schwellenwert-Toast |
canvas-scissors.ts |
Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) |
canvas-delete-handlers.ts |
Hook für onBeforeDelete, onNodesDelete, onEdgesDelete inkl. Bridge-Edges |
canvas-reconnect.ts |
Hook für Edge-Reconnect (onReconnectStart, onReconnect, onReconnectEnd) |
canvas-media-utils.ts |
Media-Helfer wie getImageDimensions(file) |
Convex ↔ React Flow Mapping
Convex und React Flow verwenden unterschiedliche Datenmodelle. Das Mapping liegt in lib/canvas-utils.ts:
| Richtung | Funktion |
|---|---|
| Convex Node → RF Node | convexNodeToRF(doc) |
| Convex Edge → RF Edge | convexEdgeToRF(doc) |
| Edge + Glow | convexEdgeToRFWithSourceGlow(edge, sourceType, colorMode) |
| StorageId → URL merge | convexNodeDocWithMergedStorageUrl(node, urlMap, prevMap) |
Wichtig: Convex speichert positionX / positionY als separate Felder. React Flow erwartet position: { x, y }. Niemals RF-Node-Objekte direkt in Convex schreiben.
Status-Injection: convexNodeToRF schreibt _status, _statusMessage und retryCount in data, damit Node-Komponenten darauf zugreifen können ohne das Node-Dokument direkt zu kennen.
URL-Caching: Images mit storageId werden im Canvas nicht mehr über eine reaktive Query aufgelöst. canvas.tsx sammelt die aktuellen storageIds aus nodes.list und ruft storage.batchGetUrlsForCanvas gezielt per Mutation auf, nur wenn sich das Set ändert. Die vorherige URL wird in previousDataByNodeId gecacht, um Flackern beim Reload zu vermeiden.
Load-Shedding-Hot-Path: Der Canvas-Hot-Path soll so wenig Convex-Abhängigkeiten wie möglich haben. Direkt reaktiv bleiben nur die Kernmodelle (nodes.list, edges.list, canvases.get). Nebenpfade wie Storage-URL-Auflösung, Adjustment-Presets und Toolbar-Credits sind bewusst entkoppelt oder zusammengefasst.
Node-Typen (Phase 1)
Alle registrierten Node-Typen in node-types.ts:
| Typ | Komponente | Kategorie | Handles |
|---|---|---|---|
image |
ImageNode |
Quelle | source (default), target (default) |
text |
TextNode |
Quelle | source (default), target (default) |
prompt |
PromptNode |
KI-Ausgabe | source: prompt-out, target: image-in |
ai-image |
AiImageNode |
KI-Ausgabe | source: image-out, target: prompt-in |
group |
GroupNode |
Layout | source (default), target (default) |
frame |
FrameNode |
Layout | source: frame-out, target: frame-in |
note |
NoteNode |
Layout | source (default), target (default) |
compare |
CompareNode |
Layout | source: compare-out, targets: left, right |
asset |
AssetNode |
Quelle | source (default), target (default) |
video |
VideoNode |
Quelle | source (default), target (default) |
nodeTypes-Map muss außerhalb jeder React-Komponente definiert sein (sonst re-rendert React Flow bei jedem Render alle Nodes).
Default-Größen (lib/canvas-utils.ts → NODE_DEFAULTS)
image: 280 × 200 prompt: 288 × 220
text: 256 × 120 ai-image: 320 × 408
group: 400 × 300 frame: 400 × 300
note: 208 × 100 compare: 500 × 380
Node-Status-Modell
idle → analyzing → clarifying → executing (retry N/2) → done
→ error
Status + statusMessage werden direkt am Node angezeigt. Kein globales Loading-Banner. Bei error zeigt statusMessage die Kategorie: Credits: ..., Timeout: ..., Provider: ... etc.
Edge-Glow-System
Jede Edge bekommt einen drop-shadow-Filter entsprechend dem Quell-Node-Typ. Farben in lib/canvas-utils.ts → SOURCE_NODE_GLOW_RGB:
prompt,ai-image→ Violett (139, 92, 246)image,text,note→ Teal (13, 148, 136)frame→ Orange (249, 115, 22)group,compare→ Grau (100, 116, 139)
Compare-Node hat zusätzlich Handle-spezifische Farben (left → Blau, right → Smaragd).
Im Light Mode wird der eigentliche Edge-stroke ebenfalls aus dieser Akzentfarbe abgeleitet und mit 50% Transparenz gerendert (rgba(..., 0.5)), damit Linie und Glow farblich konsistent bleiben.
Optimistic Updates & Local Persistence
Optimistic Prefix: Temporäre Nodes/Edges erhalten IDs mit optimistic_ / optimistic_edge_-Prefix. Sie werden durch echte Convex-IDs ersetzt sobald die Mutation abgeschlossen ist.
Persistenz-Schichten:
-
lib/canvas-op-queue.ts(IndexedDB, mit localStorage-Fallback): robuste Sync-Queue inkl. Retry/TTL. -
lib/canvas-local-persistence.ts(localStorage): Snapshot + leichtgewichtiger Op-Mirror für sofortige UI-Pins/Recovery. -
Key-Schema:
lemonspace.canvas:snapshot:v1:<canvasId>undlemonspace.canvas:ops:v1:<canvasId> -
Snapshot = letzter bekannter State (Nodes + Edges) für schnellen initialen Render
-
Ops-Queue ist aktiv für:
createNode*(inkl.createNodeWithEdgeSplit),createEdge,splitEdgeAtExistingNode,moveNode,resizeNode,updateData,removeEdge,batchRemoveNodes. -
Reconnect synchronisiert als
createEdge + removeEdge(statt rein lokalem UI-Umbiegen). -
ID-Handover
optimistic_* → realIdremappt Folge-Operationen in Queue + localStorage-Mirror, damit Verbindungen während/ nach Replay stabil bleiben. -
Unsupported offline (weiterhin online-only): Datei-Upload/Storage-Mutations, AI-Generierung.
Panel-Komponenten
| Datei | Zweck |
|---|---|
canvas-shell.tsx |
Client-Layout-Wrapper für Sidebar/Main inkl. Resizing, Auto-Collapse und Rail-Mode-Umschaltung |
canvas-toolbar.tsx |
Werkzeug-Leiste (Select, Pan, Zoom-Controls) inkl. Canvas-Name im rechten Cluster neben Credits/Export |
canvas-app-menu.tsx |
App-Menü oben rechts (Umbenennen, Löschen, Theme) |
canvas-sidebar.tsx |
Node-Palette links; zeigt im Full-Mode das LemonSpace-Wordmark, im Rail-Mode einen kompakten Header und vor dem User-Menü einen visuellen Bottom-Fade |
canvas-command-palette.tsx |
Cmd+K Command Palette |
canvas-connection-drop-menu.tsx |
Kontext-Menü beim Loslassen einer Verbindung |
canvas-node-template-picker.tsx |
Node aus Template einfügen |
canvas-placement-context.tsx |
Context für Drag-and-Drop-Platzierung |
asset-browser-panel.tsx |
Freepik/Stock-Asset-Browser |
video-browser-panel.tsx |
Video-Asset-Browser |
canvas-user-menu.tsx |
User-Avatar und Menü |
credit-display.tsx |
Credit-Balance Anzeige in der Toolbar (nur credits.getBalance, kein Tier-Badge) |
export-button.tsx |
Export-Button mit Format-Auswahl |
connection-banner.tsx |
Offline-Banner bei Convex-Verbindungsverlust |
custom-connection-line.tsx |
Angepasste temporäre Verbindungslinie |
Sidebar Resizing & Rail-Mode
- Resizing läuft über
react-resizable-panelsviacomponents/ui/resizable.tsxincanvas-shell.tsx. - Wichtige Größen werden als Strings mit Einheit gesetzt (z. B.
"18%","40%","64px"). In der verwendeten Library-Version werden numerische Werte als Pixel interpretiert. - Sidebar ist
collapsible; bei Unterschreiten vonminSizewird aufcollapsedSizereduziert. - Eingeklappt bedeutet nicht „unsichtbar“:
collapsedSizeist absichtlich > 0 (64px), damit ein sichtbarer Rail bleibt. canvas-shell.tsxschaltet peronResizeabhängig von der tatsächlichen Pixelbreite zwischen Full-Mode und Rail-Mode um (railModeProp anCanvasSidebar).- Im Full-Mode zeigt die Sidebar nicht mehr den Canvas-Namen, sondern das LemonSpace-Wordmark aus
public/logos/:- Light Mode →
lemonspace-logo-v2-black-rgb.svg - Dark Mode →
lemonspace-logo-v2-white-rgb.svg
- Light Mode →
- Der Canvas-Name liegt stattdessen in der Toolbar (
canvas-toolbar.tsx) als kompakter, truncating Label/Chip im rechten Bereich. CanvasUserMenuunterstützt ebenfalls einen kompakten Rail-Mode übercompact.- Scroll-Chaining ist begrenzt (
overscroll-containin der Sidebar-Scrollfläche +overscroll-noneam Shell-Root), um visuelle Artefakte beim Scrollen am Ende zu verhindern. - Vor dem
CanvasUserMenuliegt im Sidebar-Body einpointer-events-noneBottom-Fade (schwarz → transparent), der die unteren Palette-Einträge nur visuell ausblendet; Scrollen, Drag-and-Drop und Klicks bleiben unverändert funktionsfähig.
Wichtige Gotchas
data.urlvsstorageId: Node-Komponenten erhaltendata.url(aufgelöste HTTP-URL), nichtstorageIddirekt. Die URL wird vonconvexNodeDocWithMergedStorageUrlinjiziert. Bei neuen Node-Typen mit Bild immer diesen Flow prüfen.- Adjustment-Presets:
curves,color-adjust,light-adjustunddetail-adjustdürfen keine eigenepresets.list-Query feuern. ImmerCanvasPresetsProvider+useCanvasAdjustmentPresets(...)verwenden. - Min-Zoom:
CANVAS_MIN_ZOOM = 0.5 / 3— dreimal weiter raus als React-Flow-Default. - Parent-Nodes:
parentIdzeigt auf einen Group- oder Frame-Node. React Flow erwartet, dass Parent-Nodes vor Child-Nodes in dernodes-Array stehen. - Bridge-Edges: Beim Löschen eines mittleren Nodes werden Kanten automatisch neu verbunden (
computeBridgeCreatesForDeletedNodesauslib/canvas-utils.ts). "null"-Handles: Convex kann"null"als String speichern.convexEdgeToRFsanitized Handles:"null"→undefined.