feat: implement Convex-synced canvas foundation
This commit is contained in:
3
components/canvas/canvas-sidebar.tsx
Normal file
3
components/canvas/canvas-sidebar.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function CanvasSidebar() {
|
||||
return null;
|
||||
}
|
||||
96
components/canvas/canvas-toolbar.tsx
Normal file
96
components/canvas/canvas-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
components/canvas/canvas.tsx
Normal file
158
components/canvas/canvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
components/canvas/edges/default-edge.tsx
Normal file
3
components/canvas/edges/default-edge.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function DefaultEdge() {
|
||||
return null;
|
||||
}
|
||||
21
components/canvas/node-types.ts
Normal file
21
components/canvas/node-types.ts
Normal 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,
|
||||
};
|
||||
67
components/canvas/nodes/ai-image-node.tsx
Normal file
67
components/canvas/nodes/ai-image-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
components/canvas/nodes/base-node-wrapper.tsx
Normal file
37
components/canvas/nodes/base-node-wrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
components/canvas/nodes/compare-node.tsx
Normal file
50
components/canvas/nodes/compare-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
components/canvas/nodes/frame-node.tsx
Normal file
28
components/canvas/nodes/frame-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
components/canvas/nodes/group-node.tsx
Normal file
15
components/canvas/nodes/group-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
components/canvas/nodes/image-node.tsx
Normal file
38
components/canvas/nodes/image-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
components/canvas/nodes/note-node.tsx
Normal file
20
components/canvas/nodes/note-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
components/canvas/nodes/prompt-node.tsx
Normal file
29
components/canvas/nodes/prompt-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
components/canvas/nodes/text-node.tsx
Normal file
25
components/canvas/nodes/text-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user