Files
lemonspace_app/.docs/Canvas_Implementation_Guide.md
2026-03-25 14:21:19 +01:00

25 KiB
Raw Blame History

🍋 LemonSpace — Canvas Implementation Guide

Schritte 13: Basis-Canvas mit Convex-Sync


Voraussetzungen

Das Convex-Backend ist deployed und folgende Funktionen existieren bereits:

  • api.nodes.list (Query, benötigt canvasId)
  • api.nodes.create (Mutation)
  • api.nodes.move (Mutation, benötigt nodeId + position)
  • api.nodes.resize (Mutation)
  • api.nodes.batchMove (Mutation)
  • api.nodes.updateData (Mutation)
  • api.nodes.updateStatus (Mutation)
  • api.nodes.remove (Mutation)
  • api.edges.list (Query, benötigt canvasId)
  • api.edges.create (Mutation)
  • api.edges.remove (Mutation)
  • api.canvases.list, api.canvases.get, api.canvases.create

Auth via Better Auth + @convex-dev/better-auth ist funktionsfähig.


Schritt 0 — Package-Installation

pnpm add @xyflow/react

dnd-kit wird erst in einem späteren Schritt benötigt (Sidebar → Canvas Drag). Für Schritt 13 reicht @xyflow/react allein — das bringt Drag & Drop von bestehenden Nodes bereits mit.


Schritt 1 — Dateistruktur anlegen

components/
  canvas/
    canvas.tsx                 ← Haupt-Canvas (ReactFlow + Convex-Sync)
    canvas-toolbar.tsx         ← Toolbar oben (Node hinzufügen, Zoom)
    node-types.ts              ← nodeTypes-Map (AUSSERHALB jeder Komponente!)
    nodes/
      image-node.tsx           ← Bild-Node (Upload/URL)
      text-node.tsx            ← Freitext (Markdown)
      prompt-node.tsx          ← Prompt für KI-Nodes
      ai-image-node.tsx        ← KI-Bild-Output
      group-node.tsx           ← Container/Gruppe
      frame-node.tsx           ← Artboard/Export-Boundary
      note-node.tsx            ← Annotation
      compare-node.tsx         ← Slider-Vergleich
      base-node-wrapper.tsx    ← Shared Wrapper (Border, Selection-Ring, Status)

app/
  (app)/
    canvas/
      [canvasId]/
        page.tsx               ← Canvas-Page (Server Component, Auth-Check)

lib/
  canvas-utils.ts              ← Hilfsfunktionen (Convex → RF Mapping)

Schritt 2 — Custom Node Components

2.1 Base Node Wrapper

Jeder Node teilt sich visuelle Grundeigenschaften: Border, Selection-Ring, Status-Anzeige. Das kapseln wir in einem Wrapper.

// components/canvas/nodes/base-node-wrapper.tsx
'use client';

import type { ReactNode } from 'react';

interface BaseNodeWrapperProps {
  selected?: boolean;
  status?: 'idle' | 'executing' | 'done' | 'error';
  children: ReactNode;
  className?: string;
}

export default function BaseNodeWrapper({
  selected,
  status = 'idle',
  children,
  className = '',
}: BaseNodeWrapperProps) {
  const statusStyles = {
    idle: '',
    executing: 'animate-pulse border-yellow-400',
    done: 'border-green-500',
    error: 'border-red-500',
  };

  return (
    <div
      className={`
        rounded-xl border bg-card shadow-sm transition-shadow
        ${selected ? 'ring-2 ring-primary shadow-md' : ''}
        ${statusStyles[status]}
        ${className}
      `}
    >
      {children}
    </div>
  );
}

2.2 Note Node (einfachster Node — guter Startpunkt)

// components/canvas/nodes/note-node.tsx
'use client';

import { type NodeProps, type Node } from '@xyflow/react';
import BaseNodeWrapper from './base-node-wrapper';

export type NoteNodeData = {
  content?: string;
};

export type NoteNode = Node<NoteNodeData, 'note'>;

export default function NoteNode({ data, selected }: NodeProps<NoteNode>) {
  return (
    <BaseNodeWrapper selected={selected} className="w-52 p-3">
      <div className="text-xs font-medium text-muted-foreground mb-1">📌 Notiz</div>
      <p className="text-sm whitespace-pre-wrap">
        {data.content || 'Leere Notiz'}
      </p>
    </BaseNodeWrapper>
  );
}

2.3 Image Node

// components/canvas/nodes/image-node.tsx
'use client';

import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
import BaseNodeWrapper from './base-node-wrapper';

export type ImageNodeData = {
  storageId?: string;
  url?: string;
  filename?: string;
};

export type ImageNode = Node<ImageNodeData, 'image'>;

export default function ImageNode({ data, selected }: NodeProps<ImageNode>) {
  return (
    <BaseNodeWrapper selected={selected} className="p-2">
      <div className="text-xs font-medium text-muted-foreground mb-1">🖼️ Bild</div>
      {data.url ? (
        <img
          src={data.url}
          alt={data.filename ?? 'Bild'}
          className="rounded-lg object-cover max-w-[280px]"
          draggable={false}
        />
      ) : (
        <div className="flex h-36 w-56 items-center justify-center rounded-lg border-2 border-dashed text-sm text-muted-foreground">
          Bild hochladen oder URL einfügen
        </div>
      )}
      <Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-primary !border-2 !border-background" />
    </BaseNodeWrapper>
  );
}

2.4 Text Node

// components/canvas/nodes/text-node.tsx
'use client';

import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
import BaseNodeWrapper from './base-node-wrapper';

export type TextNodeData = {
  content?: string;
};

export type TextNode = Node<TextNodeData, 'text'>;

export default function TextNode({ data, selected }: NodeProps<TextNode>) {
  return (
    <BaseNodeWrapper selected={selected} className="w-64 p-3">
      <div className="text-xs font-medium text-muted-foreground mb-1">📝 Text</div>
      <p className="text-sm whitespace-pre-wrap min-h-[2rem]">
        {data.content || 'Text eingeben…'}
      </p>
      <Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-primary !border-2 !border-background" />
    </BaseNodeWrapper>
  );
}

2.5 Prompt Node

// components/canvas/nodes/prompt-node.tsx
'use client';

import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
import BaseNodeWrapper from './base-node-wrapper';

export type PromptNodeData = {
  prompt?: string;
  model?: string;
};

export type PromptNode = Node<PromptNodeData, 'prompt'>;

export default function PromptNode({ data, selected }: NodeProps<PromptNode>) {
  return (
    <BaseNodeWrapper selected={selected} className="w-72 p-3 border-purple-500/30">
      <div className="text-xs font-medium text-purple-500 mb-1"> Prompt</div>
      <p className="text-sm whitespace-pre-wrap min-h-[2rem]">
        {data.prompt || 'Prompt eingeben…'}
      </p>
      {data.model && (
        <div className="mt-2 text-xs text-muted-foreground">
          Modell: {data.model}
        </div>
      )}
      {/* Nur Source — verbindet sich ausschließlich mit KI-Nodes */}
      <Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500 !border-2 !border-background" />
    </BaseNodeWrapper>
  );
}

2.6 AI Image Node

// components/canvas/nodes/ai-image-node.tsx
'use client';

import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
import BaseNodeWrapper from './base-node-wrapper';

export type AiImageNodeData = {
  url?: string;
  prompt?: string;
  model?: string;
  status?: 'idle' | 'executing' | 'done' | 'error';
  error?: string;
};

export type AiImageNode = Node<AiImageNodeData, 'ai-image'>;

export default function AiImageNode({ data, selected }: NodeProps<AiImageNode>) {
  const status = data.status ?? 'idle';

  return (
    <BaseNodeWrapper selected={selected} status={status} className="p-2">
      <div className="text-xs font-medium text-emerald-500 mb-1">🤖 KI-Bild</div>

      {status === 'executing' && (
        <div className="flex h-36 w-56 items-center justify-center rounded-lg bg-muted">
          <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
        </div>
      )}

      {status === 'done' && data.url && (
        <img
          src={data.url}
          alt={data.prompt ?? 'KI-generiertes Bild'}
          className="rounded-lg object-cover max-w-[280px]"
          draggable={false}
        />
      )}

      {status === 'error' && (
        <div className="flex h-36 w-56 items-center justify-center rounded-lg bg-red-50 dark:bg-red-950/20 text-sm text-red-600">
          {data.error ?? 'Fehler bei der Generierung'}
        </div>
      )}

      {status === 'idle' && (
        <div className="flex h-36 w-56 items-center justify-center rounded-lg border-2 border-dashed text-sm text-muted-foreground">
          Prompt verbinden
        </div>
      )}

      {data.prompt && status === 'done' && (
        <p className="mt-1 text-xs text-muted-foreground truncate max-w-[280px]">
          {data.prompt}
        </p>
      )}

      {/* Target: Empfängt Input von Prompt/Bild */}
      <Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-emerald-500 !border-2 !border-background" />
      {/* Source: Output weitergeben (an Compare, Frame, etc.) */}
      <Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-primary !border-2 !border-background" />
    </BaseNodeWrapper>
  );
}

2.7 Platzhalter für Group, Frame, Compare

Diese sind komplexer (Group braucht expandParent, Frame braucht Resize, Compare braucht Slider). Für Schritt 13 reichen einfache Platzhalter:

// components/canvas/nodes/group-node.tsx
'use client';
import { type NodeProps, type Node } from '@xyflow/react';
import BaseNodeWrapper from './base-node-wrapper';

export type GroupNode = Node<{ label?: string }, 'group'>;

export default function GroupNode({ data, selected }: NodeProps<GroupNode>) {
  return (
    <BaseNodeWrapper selected={selected} className="min-w-[200px] min-h-[150px] p-3 border-dashed">
      <div className="text-xs font-medium text-muted-foreground">📁 {data.label || 'Gruppe'}</div>
    </BaseNodeWrapper>
  );
}
// components/canvas/nodes/frame-node.tsx
'use client';
import { type NodeProps, type Node } from '@xyflow/react';
import BaseNodeWrapper from './base-node-wrapper';

export type FrameNode = Node<{ label?: string; resolution?: string }, 'frame'>;

export default function FrameNode({ data, selected }: NodeProps<FrameNode>) {
  return (
    <BaseNodeWrapper selected={selected} className="min-w-[300px] min-h-[200px] p-3 border-blue-500/30">
      <div className="text-xs font-medium text-blue-500">
        🖥️ {data.label || 'Frame'} {data.resolution && `(${data.resolution})`}
      </div>
    </BaseNodeWrapper>
  );
}
// components/canvas/nodes/compare-node.tsx
'use client';
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
import BaseNodeWrapper from './base-node-wrapper';

export type CompareNode = Node<{ leftUrl?: string; rightUrl?: string }, 'compare'>;

export default function CompareNode({ data, selected }: NodeProps<CompareNode>) {
  return (
    <BaseNodeWrapper selected={selected} className="w-[500px] p-2">
      <div className="text-xs font-medium text-muted-foreground mb-1">🔀 Vergleich</div>
      <div className="flex gap-2 h-40">
        <div className="flex-1 rounded bg-muted flex items-center justify-center text-xs text-muted-foreground">
          {data.leftUrl ? <img src={data.leftUrl} className="rounded object-cover h-full w-full" /> : 'Bild A'}
        </div>
        <div className="flex-1 rounded bg-muted flex items-center justify-center text-xs text-muted-foreground">
          {data.rightUrl ? <img src={data.rightUrl} className="rounded object-cover h-full w-full" /> : 'Bild B'}
        </div>
      </div>
      <Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-primary !border-2 !border-background" style={{ top: '40%' }} />
      <Handle type="target" position={Position.Left} id="right" className="!h-3 !w-3 !bg-primary !border-2 !border-background" style={{ top: '60%' }} />
    </BaseNodeWrapper>
  );
}

Schritt 3 — nodeTypes registrieren

Kritisch: Diese Map muss AUSSERHALB jeder React-Komponente definiert werden. Wenn sie innerhalb einer Komponente liegt, erstellt React bei jedem Render ein neues Objekt → React Flow re-rendert alle Nodes.

// components/canvas/node-types.ts
import ImageNode from './nodes/image-node';
import TextNode from './nodes/text-node';
import PromptNode from './nodes/prompt-node';
import AiImageNode from './nodes/ai-image-node';
import GroupNode from './nodes/group-node';
import FrameNode from './nodes/frame-node';
import NoteNode from './nodes/note-node';
import CompareNode from './nodes/compare-node';

export const nodeTypes = {
  image: ImageNode,
  text: TextNode,
  prompt: PromptNode,
  'ai-image': AiImageNode,
  group: GroupNode,
  frame: FrameNode,
  note: NoteNode,
  compare: CompareNode,
} as const;

Schritt 4 — Convex ↔ React Flow Mapping

Das Herzstück: Convex-Daten in React Flow-Format transformieren und Änderungen zurückschreiben.

// lib/canvas-utils.ts
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
import type { Doc } from '@/convex/_generated/dataModel';

/**
 * Transformiert einen Convex-Node in das React Flow-Format.
 */
export function convexNodeToRF(node: Doc<'nodes'>): RFNode {
  return {
    id: node._id,
    type: node.type,
    position: node.position,
    data: node.data ?? {},
    // parentId: node.parentNodeId ?? undefined,  // ← für Group-Nodes, aktivieren wenn nötig
    style: node.size
      ? { width: node.size.width, height: node.size.height }
      : undefined,
  };
}

/**
 * Transformiert einen Convex-Edge in das React Flow-Format.
 */
export function convexEdgeToRF(edge: Doc<'edges'>): RFEdge {
  return {
    id: edge._id,
    source: edge.sourceNodeId,
    target: edge.targetNodeId,
    // sourceHandle und targetHandle können später ergänzt werden
  };
}

Schritt 5 — Haupt-Canvas-Komponente

Die zentrale Architekturentscheidung: Lokaler State für flüssiges Interagieren, Convex als Sync-Layer.

React Flow braucht onNodesChange für jede Interaktion (Drag, Select, Remove). Wenn wir jede Drag-Bewegung direkt an Convex senden würden, wäre das zu viel Traffic und der Canvas würde laggen. Stattdessen:

  1. Convex-Daten kommen rein → werden in lokalen State geschrieben
  2. Lokaler State wird von React Flow gesteuert (Drag, Select, etc.)
  3. Bei onNodeDragStop wird die finale Position an Convex committed
  4. Convex-Subscription aktualisiert den lokalen State bei Remote-Änderungen
// components/canvas/canvas.tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  applyNodeChanges,
  applyEdgeChanges,
  type Node as RFNode,
  type Edge as RFEdge,
  type NodeChange,
  type EdgeChange,
  type Connection,
  type ReactFlowInstance,
  BackgroundVariant,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import { useMutation, useQuery } from 'convex/react';
import { api } from '@/convex/_generated/api';
import type { Id } from '@/convex/_generated/dataModel';

import { nodeTypes } from './node-types';
import { convexNodeToRF, convexEdgeToRF } from '@/lib/canvas-utils';

interface CanvasProps {
  canvasId: Id<'canvases'>;
}

export default function Canvas({ canvasId }: CanvasProps) {
  // ─── Convex Realtime Queries ───
  const convexNodes = useQuery(api.nodes.list, { canvasId });
  const convexEdges = useQuery(api.edges.list, { canvasId });

  // ─── Convex Mutations ───
  const moveNode = useMutation(api.nodes.move);
  const createNode = useMutation(api.nodes.create);
  const removeNode = useMutation(api.nodes.remove);
  const createEdge = useMutation(api.edges.create);
  const removeEdge = useMutation(api.edges.remove);

  // ─── Lokaler State (für flüssiges Dragging) ───
  const [nodes, setNodes] = useState<RFNode[]>([]);
  const [edges, setEdges] = useState<RFEdge[]>([]);

  // Track ob gerade gedraggt wird — dann kein Convex-Override
  const isDragging = useRef(false);

  // React Flow Instance Ref (für screenToFlowPosition, etc.)
  const [rfInstance, setRfInstance] = useState<ReactFlowInstance | null>(null);

  // ─── Convex → Lokaler State Sync ───
  useEffect(() => {
    if (!convexNodes) return;
    // Nur aktualisieren wenn NICHT gerade gedraggt wird
    if (!isDragging.current) {
      setNodes(convexNodes.map(convexNodeToRF));
    }
  }, [convexNodes]);

  useEffect(() => {
    if (!convexEdges) return;
    setEdges(convexEdges.map(convexEdgeToRF));
  }, [convexEdges]);

  // ─── Node Changes (Drag, Select, Remove) ───
  const onNodesChange = useCallback((changes: NodeChange[]) => {
    setNodes((nds) => applyNodeChanges(changes, nds));
  }, []);

  const onEdgesChange = useCallback((changes: EdgeChange[]) => {
    setEdges((eds) => applyEdgeChanges(changes, eds));
  }, []);

  // ─── Drag Start → Lock Convex-Sync ───
  const onNodeDragStart = useCallback(() => {
    isDragging.current = true;
  }, []);

  // ─── Drag Stop → Commit zu Convex ───
  const onNodeDragStop = useCallback(
    (_: React.MouseEvent, node: RFNode) => {
      isDragging.current = false;
      moveNode({
        nodeId: node.id as Id<'nodes'>,
        position: { x: node.position.x, y: node.position.y },
      });
    },
    [moveNode]
  );

  // ─── Neue Verbindung → Convex Edge ───
  const onConnect = useCallback(
    (connection: Connection) => {
      if (connection.source && connection.target) {
        createEdge({
          canvasId,
          sourceNodeId: connection.source as Id<'nodes'>,
          targetNodeId: connection.target as Id<'nodes'>,
        });
      }
    },
    [createEdge, canvasId]
  );

  // ─── Node löschen → Convex ───
  const onNodesDelete = useCallback(
    (deletedNodes: RFNode[]) => {
      for (const node of deletedNodes) {
        removeNode({ nodeId: node.id as Id<'nodes'> });
      }
    },
    [removeNode]
  );

  // ─── Edge löschen → Convex ───
  const onEdgesDelete = useCallback(
    (deletedEdges: RFEdge[]) => {
      for (const edge of deletedEdges) {
        removeEdge({ edgeId: edge.id as Id<'edges'> });
      }
    },
    [removeEdge]
  );

  // ─── Loading State ───
  if (convexNodes === undefined || convexEdges === undefined) {
    return (
      <div className="flex h-full w-full items-center justify-center bg-background">
        <div className="flex flex-col items-center gap-3">
          <div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
          <span className="text-sm text-muted-foreground">Canvas lädt</span>
        </div>
      </div>
    );
  }

  return (
    <div className="h-full w-full">
      <ReactFlow
        nodes={nodes}
        edges={edges}
        nodeTypes={nodeTypes}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onNodeDragStart={onNodeDragStart}
        onNodeDragStop={onNodeDragStop}
        onConnect={onConnect}
        onNodesDelete={onNodesDelete}
        onEdgesDelete={onEdgesDelete}
        onInit={setRfInstance}
        fitView
        snapToGrid
        snapGrid={[16, 16]}
        deleteKeyCode={['Backspace', 'Delete']}
        multiSelectionKeyCode="Shift"
        proOptions={{ hideAttribution: true }}
        className="bg-background"
      >
        <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
        <Controls className="!bg-card !border !shadow-sm !rounded-lg" />
        <MiniMap
          className="!bg-card !border !shadow-sm !rounded-lg"
          nodeColor="#6366f1"
          maskColor="rgba(0, 0, 0, 0.1)"
        />
      </ReactFlow>
    </div>
  );
}

Schritt 6 — Canvas Toolbar

Eine einfache Toolbar zum Anlegen neuer Nodes. In Phase 1 ist das der einfachste Weg, Nodes zu erstellen (Sidebar + Drag kommt danach).

// components/canvas/canvas-toolbar.tsx
'use client';

import { useMutation } from 'convex/react';
import { api } from '@/convex/_generated/api';
import type { Id } from '@/convex/_generated/dataModel';

const nodeTemplates = [
  { type: 'image', label: '🖼️ Bild', defaultData: {} },
  { type: 'text', label: '📝 Text', defaultData: { content: '' } },
  { type: 'prompt', label: '✨ Prompt', defaultData: { prompt: '' } },
  { type: 'note', label: '📌 Notiz', defaultData: { content: '' } },
  { type: 'frame', label: '🖥️ Frame', defaultData: { label: 'Untitled', resolution: '1080x1080' } },
] as const;

interface CanvasToolbarProps {
  canvasId: Id<'canvases'>;
}

export default function CanvasToolbar({ canvasId }: CanvasToolbarProps) {
  const createNode = useMutation(api.nodes.create);

  const handleAddNode = async (type: string, data: Record<string, any>) => {
    // Platziere neue Nodes leicht versetzt, damit sie nicht übereinander liegen
    const offset = Math.random() * 200;
    await createNode({
      canvasId,
      type,
      position: { x: 100 + offset, y: 100 + offset },
      data,
    });
  };

  return (
    <div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 rounded-xl border bg-card/90 p-1.5 shadow-lg backdrop-blur-sm">
      {nodeTemplates.map((t) => (
        <button
          key={t.type}
          onClick={() => handleAddNode(t.type, t.defaultData)}
          className="rounded-lg px-3 py-1.5 text-sm hover:bg-accent transition-colors"
          title={`${t.label} hinzufügen`}
        >
          {t.label}
        </button>
      ))}
    </div>
  );
}

Schritt 7 — Canvas Page (Next.js App Router)

// app/(app)/canvas/[canvasId]/page.tsx
import Canvas from '@/components/canvas/canvas';
import CanvasToolbar from '@/components/canvas/canvas-toolbar';
import type { Id } from '@/convex/_generated/dataModel';

export default async function CanvasPage({
  params,
}: {
  params: Promise<{ canvasId: string }>;
}) {
  const { canvasId } = await params;
  const typedCanvasId = canvasId as Id<'canvases'>;

  return (
    <div className="relative h-screen w-screen overflow-hidden">
      <CanvasToolbar canvasId={typedCanvasId} />
      <Canvas canvasId={typedCanvasId} />
    </div>
  );
}

Schritt 8 — CSS Import nicht vergessen!

@xyflow/react braucht sein eigenes CSS. Importiere es entweder in der Canvas-Komponente (wie oben) oder global in app/globals.css:

/* app/globals.css — NACH den Tailwind-Imports */
@import '@xyflow/react/dist/style.css';

Tailwind v4 Hinweis: Falls die React Flow Styles von Tailwinds Reset überschrieben werden, importiere sie NACH dem Tailwind-Import.


Testing-Reihenfolge

Nachdem du alle Dateien erstellt hast, teste in dieser Reihenfolge:

Test 1: Canvas rendert

  • Navigiere zu /canvas/<eine-canvas-id> (du brauchst eine existierende Canvas-ID aus Convex)
  • Erwartung: Leerer Canvas mit Dot-Background, Controls unten links, MiniMap unten rechts
  • Falls 404: Prüfe ob die Route app/(app)/canvas/[canvasId]/page.tsx korrekt liegt

Test 2: Node hinzufügen

  • Klicke auf "📌 Notiz" in der Toolbar
  • Erwartung: Note-Node erscheint auf dem Canvas
  • Prüfe im Convex Dashboard: neuer Eintrag in der nodes-Tabelle

Test 3: Node verschieben

  • Ziehe den Node an eine neue Position, lasse los
  • Erwartung: Node bleibt an der neuen Position
  • Prüfe im Convex Dashboard: position.x und position.y haben sich aktualisiert

Test 4: Verbindung erstellen

  • Erstelle einen Prompt-Node und einen (leeren) AI-Image-Node
  • Ziehe vom Source-Handle (rechts am Prompt) zum Target-Handle (links am AI-Image)
  • Erwartung: Edge erscheint als Linie zwischen den Nodes
  • Prüfe im Convex Dashboard: neuer Eintrag in der edges-Tabelle

Test 5: Node löschen

  • Selektiere einen Node (Klick), drücke Delete oder Backspace
  • Erwartung: Node verschwindet, zugehörige Edges werden ebenfalls entfernt
  • Prüfe im Convex Dashboard: Node und Edges sind gelöscht

Bekannte Fallstricke

1. nodeTypes innerhalb der Komponente definiert

→ React Flow re-rendert ALLE Nodes bei jedem State-Update. Die Map MUSS in einer eigenen Datei liegen.

2. React Flow CSS fehlt

→ Nodes sind unsichtbar oder falsch positioniert. Import von @xyflow/react/dist/style.css ist Pflicht.

3. Convex-Sync während Drag

→ Wenn Convex einen neuen Wert pusht während der User draggt, springt der Node zur alten Position zurück. Die isDragging-Ref verhindert das.

4. Handle-Styling

→ Die Standard-Handles von React Flow sind winzig und dunkel. Die !-Klassen in Tailwind erzwingen Custom-Styling über die React Flow Defaults.

5. Batch-Drag (mehrere Nodes selektiert)

onNodeDragStop feuert nur für den primär gedraggten Node. Für Batch-Moves nutze onSelectionDragStop oder batchMove Mutation.


Nächste Schritte (nach Schritt 13)

  • Schritt 4: Sidebar mit Node-Palette + dnd-kit (Drag von Sidebar auf Canvas)
  • Schritt 5: Inline-Editing (Text direkt im Node bearbeiten → updateData Mutation)
  • Schritt 6: Bild-Upload (Convex File Storage + Image-Node)
  • Schritt 7: OpenRouter-Integration (Prompt → KI-Bild-Generierung)
  • Schritt 8: Node-Status-Modell visuell ausbauen (Shimmer, Progress, Error)