feat: implement Convex-synced canvas foundation

This commit is contained in:
Matthias
2026-03-25 14:21:19 +01:00
parent 66c4455033
commit 4d17936570
21 changed files with 2347 additions and 35 deletions

View File

@@ -0,0 +1,3 @@
export default function CanvasSidebar() {
return null;
}

View File

@@ -0,0 +1,96 @@
"use client";
import { useMutation } from "convex/react";
import { useRef } from "react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
const nodeTemplates = [
{
type: "image",
label: "Bild",
width: 280,
height: 180,
defaultData: {},
},
{
type: "text",
label: "Text",
width: 256,
height: 120,
defaultData: { content: "" },
},
{
type: "prompt",
label: "Prompt",
width: 320,
height: 140,
defaultData: { content: "", model: "" },
},
{
type: "note",
label: "Notiz",
width: 220,
height: 120,
defaultData: { content: "" },
},
{
type: "frame",
label: "Frame",
width: 360,
height: 240,
defaultData: { label: "Untitled", exportWidth: 1080, exportHeight: 1080 },
},
] as const;
interface CanvasToolbarProps {
canvasId: Id<"canvases">;
}
export default function CanvasToolbar({ canvasId }: CanvasToolbarProps) {
const createNode = useMutation(api.nodes.create);
const nodeCountRef = useRef(0);
const handleAddNode = async (
type: (typeof nodeTemplates)[number]["type"],
data: (typeof nodeTemplates)[number]["defaultData"],
width: number,
height: number,
) => {
const offset = (nodeCountRef.current % 8) * 24;
nodeCountRef.current += 1;
await createNode({
canvasId,
type,
positionX: 100 + offset,
positionY: 100 + offset,
width,
height,
data,
});
};
return (
<div className="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-xl border bg-card/90 p-1.5 shadow-lg backdrop-blur-sm">
{nodeTemplates.map((template) => (
<button
key={template.type}
onClick={() =>
void handleAddNode(
template.type,
template.defaultData,
template.width,
template.height,
)
}
className="rounded-lg px-3 py-1.5 text-sm transition-colors hover:bg-accent"
title={`${template.label} hinzufuegen`}
type="button"
>
{template.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
applyNodeChanges,
applyEdgeChanges,
type Connection,
type Edge as RFEdge,
type EdgeChange,
type Node as RFNode,
type NodeChange,
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 { convexEdgeToRF, convexNodeToRF } from "@/lib/canvas-utils";
import { nodeTypes } from "./node-types";
interface CanvasProps {
canvasId: Id<"canvases">;
}
export default function Canvas({ canvasId }: CanvasProps) {
const convexNodes = useQuery(api.nodes.list, { canvasId });
const convexEdges = useQuery(api.edges.list, { canvasId });
const moveNode = useMutation(api.nodes.move);
const createEdge = useMutation(api.edges.create);
const removeNode = useMutation(api.nodes.remove);
const removeEdge = useMutation(api.edges.remove);
const [nodes, setNodes] = useState<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]);
const isDragging = useRef(false);
useEffect(() => {
if (!convexNodes) return;
if (!isDragging.current) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setNodes(convexNodes.map(convexNodeToRF));
}
}, [convexNodes]);
useEffect(() => {
if (!convexEdges) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setEdges(convexEdges.map(convexEdgeToRF));
}, [convexEdges]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((current) => applyNodeChanges(changes, current));
}, []);
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
setEdges((current) => applyEdgeChanges(changes, current));
}, []);
const onNodeDragStart = useCallback(() => {
isDragging.current = true;
}, []);
const onNodeDragStop = useCallback(
(_event: React.MouseEvent, node: RFNode) => {
isDragging.current = false;
void moveNode({
nodeId: node.id as Id<"nodes">,
positionX: node.position.x,
positionY: node.position.y,
});
},
[moveNode],
);
const onConnect = useCallback(
(connection: Connection) => {
if (!connection.source || !connection.target) return;
void createEdge({
canvasId,
sourceNodeId: connection.source as Id<"nodes">,
targetNodeId: connection.target as Id<"nodes">,
sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined,
});
},
[canvasId, createEdge],
);
const onNodesDelete = useCallback(
(deletedNodes: RFNode[]) => {
for (const node of deletedNodes) {
void removeNode({ nodeId: node.id as Id<"nodes"> });
}
},
[removeNode],
);
const onEdgesDelete = useCallback(
(deletedEdges: RFEdge[]) => {
for (const edge of deletedEdges) {
void removeEdge({ edgeId: edge.id as Id<"edges"> });
}
},
[removeEdge],
);
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 laedt...</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}
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="!rounded-lg !border !bg-card !shadow-sm" />
<MiniMap
className="!rounded-lg !border !bg-card !shadow-sm"
nodeColor="#6366f1"
maskColor="rgba(0, 0, 0, 0.1)"
/>
</ReactFlow>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export default function DefaultEdge() {
return null;
}

View File

@@ -0,0 +1,21 @@
import type { NodeTypes } from "@xyflow/react";
import AiImageNode from "./nodes/ai-image-node";
import CompareNode from "./nodes/compare-node";
import FrameNode from "./nodes/frame-node";
import GroupNode from "./nodes/group-node";
import ImageNode from "./nodes/image-node";
import NoteNode from "./nodes/note-node";
import PromptNode from "./nodes/prompt-node";
import TextNode from "./nodes/text-node";
export const nodeTypes: NodeTypes = {
image: ImageNode,
text: TextNode,
prompt: PromptNode,
"ai-image": AiImageNode,
group: GroupNode,
frame: FrameNode,
note: NoteNode,
compare: CompareNode,
};

View File

@@ -0,0 +1,67 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type AiImageNodeData = {
url?: string;
prompt?: string;
model?: string;
status?: "idle" | "executing" | "done" | "error";
errorMessage?: 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="mb-1 text-xs font-medium text-emerald-500">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>
) : null}
{status === "done" && data.url ? (
<img
src={data.url}
alt={data.prompt ?? "KI-generiertes Bild"}
className="max-w-[280px] rounded-lg object-cover"
draggable={false}
/>
) : null}
{status === "error" ? (
<div className="flex h-36 w-56 items-center justify-center rounded-lg bg-red-50 text-sm text-red-600 dark:bg-red-950/20">
{data.errorMessage ?? "Fehler bei der Generierung"}
</div>
) : null}
{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>
) : null}
{data.prompt && status === "done" ? (
<p className="mt-1 max-w-[280px] truncate text-xs text-muted-foreground">{data.prompt}</p>
) : null}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
/>
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
/>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import type { ReactNode } from "react";
interface BaseNodeWrapperProps {
selected?: boolean;
status?: "idle" | "executing" | "done" | "error";
children: ReactNode;
className?: string;
}
const statusClassMap: Record<NonNullable<BaseNodeWrapperProps["status"]>, string> = {
idle: "",
executing: "animate-pulse border-yellow-400",
done: "border-green-500",
error: "border-red-500",
};
export default function BaseNodeWrapper({
selected,
status = "idle",
children,
className = "",
}: BaseNodeWrapperProps) {
return (
<div
className={[
"rounded-xl border bg-card shadow-sm transition-shadow",
selected ? "ring-2 ring-primary shadow-md" : "",
statusClassMap[status],
className,
].join(" ")}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type CompareNodeData = {
leftUrl?: string;
rightUrl?: string;
};
export type CompareNode = Node<CompareNodeData, "compare">;
export default function CompareNode({ data, selected }: NodeProps<CompareNode>) {
return (
<BaseNodeWrapper selected={selected} className="w-[500px] p-2">
<div className="mb-1 text-xs font-medium text-muted-foreground">Vergleich</div>
<div className="flex h-40 gap-2">
<div className="flex flex-1 items-center justify-center rounded bg-muted text-xs text-muted-foreground">
{data.leftUrl ? (
<img src={data.leftUrl} alt="Bild A" className="h-full w-full rounded object-cover" />
) : (
"Bild A"
)}
</div>
<div className="flex flex-1 items-center justify-center rounded bg-muted text-xs text-muted-foreground">
{data.rightUrl ? (
<img src={data.rightUrl} alt="Bild B" className="h-full w-full rounded object-cover" />
) : (
"Bild B"
)}
</div>
</div>
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
style={{ top: "40%" }}
/>
<Handle
type="target"
position={Position.Left}
id="right"
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
style={{ top: "60%" }}
/>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type FrameNodeData = {
label?: string;
exportWidth?: number;
exportHeight?: number;
};
export type FrameNode = Node<FrameNodeData, "frame">;
export default function FrameNode({ data, selected }: NodeProps<FrameNode>) {
const resolution =
data.exportWidth && data.exportHeight
? `${data.exportWidth}x${data.exportHeight}`
: undefined;
return (
<BaseNodeWrapper selected={selected} className="min-h-[200px] min-w-[300px] border-blue-500/30 p-3">
<div className="text-xs font-medium text-blue-500">
{data.label || "Frame"} {resolution ? `(${resolution})` : ""}
</div>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import { type Node, type NodeProps } 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-h-[150px] min-w-[200px] border-dashed p-3">
<div className="text-xs font-medium text-muted-foreground">{data.label || "Gruppe"}</div>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type ImageNodeData = {
storageId?: string;
url?: string;
originalFilename?: string;
};
export type ImageNode = Node<ImageNodeData, "image">;
export default function ImageNode({ data, selected }: NodeProps<ImageNode>) {
return (
<BaseNodeWrapper selected={selected} className="p-2">
<div className="mb-1 text-xs font-medium text-muted-foreground">Bild</div>
{data.url ? (
<img
src={data.url}
alt={data.originalFilename ?? "Bild"}
className="max-w-[280px] rounded-lg object-cover"
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 einfuegen
</div>
)}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
/>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { type Node, type NodeProps } 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="mb-1 text-xs font-medium text-muted-foreground">Notiz</div>
<p className="whitespace-pre-wrap text-sm">{data.content || "Leere Notiz"}</p>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
export type PromptNodeData = {
content?: string;
model?: string;
};
export type PromptNode = Node<PromptNodeData, "prompt">;
export default function PromptNode({ data, selected }: NodeProps<PromptNode>) {
return (
<BaseNodeWrapper selected={selected} className="w-72 border-purple-500/30 p-3">
<div className="mb-1 text-xs font-medium text-purple-500">Prompt</div>
<p className="min-h-[2rem] whitespace-pre-wrap text-sm">{data.content || "Prompt eingeben..."}</p>
{data.model ? (
<div className="mt-2 text-xs text-muted-foreground">Modell: {data.model}</div>
) : null}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-purple-500"
/>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { Handle, Position, type Node, type NodeProps } 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="mb-1 text-xs font-medium text-muted-foreground">Text</div>
<p className="min-h-[2rem] whitespace-pre-wrap text-sm">{data.content || "Text eingeben..."}</p>
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-primary"
/>
</BaseNodeWrapper>
);
}