feat: refactor canvas and node components for improved functionality and styling

- Removed unused hooks and optimized edge handling in the canvas component.
- Adjusted positioning of handles in the compare node for better alignment.
- Enhanced prompt node to utilize incoming edges for dynamic prompt generation and improved user feedback.
- Updated text node to synchronize content changes with the React Flow state.
- Improved logging in edge removal to handle idempotent operations gracefully.
This commit is contained in:
Matthias
2026-03-26 17:35:25 +01:00
parent 824939307c
commit a5cde14573
6 changed files with 158 additions and 260 deletions

View File

@@ -70,14 +70,14 @@ export default function CompareNode({ data, selected }: NodeProps) {
type="target"
position={Position.Left}
id="left"
style={{ top: "40%" }}
style={{ top: "35%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-blue-500"
/>
<Handle
type="target"
position={Position.Right}
position={Position.Left}
id="right"
style={{ top: "40%" }}
style={{ top: "55%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
/>

View File

@@ -1,7 +1,14 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Handle,
Position,
useReactFlow,
useStore,
type NodeProps,
type Node,
} from "@xyflow/react";
import { useMutation, useAction } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
@@ -52,6 +59,8 @@ export default function PromptNode({
);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const edges = useStore((store) => store.edges);
const nodes = useStore((store) => store.nodes);
const promptRef = useRef(prompt);
const aspectRatioRef = useRef(aspectRatio);
@@ -66,6 +75,31 @@ export default function PromptNode({
setAspectRatio(nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO);
}, [nodeData.aspectRatio]);
const inputMeta = useMemo(() => {
const incomingEdges = edges.filter((edge) => edge.target === id);
let textPrompt: string | undefined;
let hasTextInput = false;
for (const edge of incomingEdges) {
const sourceNode = nodes.find((node) => node.id === edge.source);
if (sourceNode?.type !== "text") continue;
hasTextInput = true;
const sourceData = sourceNode.data as { content?: string };
if (typeof sourceData.content === "string") {
textPrompt = sourceData.content;
break;
}
}
return {
hasTextInput,
textPrompt: textPrompt ?? "",
};
}, [edges, id, nodes]);
const effectivePrompt = inputMeta.hasTextInput ? inputMeta.textPrompt : prompt;
const dataRef = useRef(data);
dataRef.current = data;
@@ -107,29 +141,38 @@ export default function PromptNode({
);
const handleGenerate = useCallback(async () => {
if (!prompt.trim() || isGenerating) return;
if (!effectivePrompt.trim() || isGenerating) return;
setError(null);
setIsGenerating(true);
try {
const canvasId = nodeData.canvasId as Id<"canvases">;
if (!canvasId) throw new Error("Missing canvasId on node");
if (!canvasId) throw new Error("Canvas-ID fehlt in der Node");
const edges = getEdges();
const incomingEdges = edges.filter((e) => e.target === id);
const currentEdges = getEdges();
const incomingEdges = currentEdges.filter((e) => e.target === id);
let connectedTextPrompt: string | undefined;
let referenceStorageId: Id<"_storage"> | undefined;
for (const edge of incomingEdges) {
const sourceNode = getNode(edge.source);
if (sourceNode?.type === "text") {
const srcData = sourceNode.data as { content?: string };
if (typeof srcData.content === "string") {
connectedTextPrompt = srcData.content;
}
}
if (sourceNode?.type === "image") {
const srcData = sourceNode.data as { storageId?: string };
if (srcData.storageId) {
referenceStorageId = srcData.storageId as Id<"_storage">;
break;
}
}
}
const promptToUse = (connectedTextPrompt ?? prompt).trim();
if (!promptToUse) return;
const currentNode = getNode(id);
const offsetX = (currentNode?.measured?.width ?? 280) + 32;
const posX = (currentNode?.position?.x ?? 0) + offsetX;
@@ -146,7 +189,7 @@ export default function PromptNode({
width: outer.width,
height: outer.height,
data: {
prompt,
prompt: promptToUse,
model: DEFAULT_MODEL_ID,
modelTier: "standard",
canvasId,
@@ -167,18 +210,19 @@ export default function PromptNode({
await generateImage({
canvasId,
nodeId: aiNodeId,
prompt,
prompt: promptToUse,
referenceStorageId,
model: DEFAULT_MODEL_ID,
aspectRatio,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Generation failed");
setError(err instanceof Error ? err.message : "Bildgenerierung fehlgeschlagen");
} finally {
setIsGenerating(false);
}
}, [
prompt,
effectivePrompt,
aspectRatio,
isGenerating,
nodeData.canvasId,
@@ -206,15 +250,26 @@ export default function PromptNode({
<div className="flex flex-col gap-2 p-3">
<div className="text-xs font-medium text-violet-600 dark:text-violet-400">
Prompt
Eingabe
</div>
<textarea
value={prompt}
onChange={handlePromptChange}
placeholder="Describe what you want to generate…"
rows={4}
className="nodrag nowheel w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
{inputMeta.hasTextInput ? (
<div className="rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2">
<p className="text-[11px] font-medium text-violet-700 dark:text-violet-300">
Prompt aus verbundener Text-Node
</p>
<p className="mt-1 whitespace-pre-wrap text-sm text-foreground">
{inputMeta.textPrompt.trim() || "(Verbundene Text-Node ist leer)"}
</p>
</div>
) : (
<textarea
value={prompt}
onChange={handlePromptChange}
placeholder="Beschreibe, was du generieren willst…"
rows={4}
className="nodrag nowheel w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
)}
<div className="flex flex-col gap-1.5">
<Label
@@ -262,18 +317,18 @@ export default function PromptNode({
<button
type="button"
onClick={() => void handleGenerate()}
disabled={!prompt.trim() || isGenerating}
disabled={!effectivePrompt.trim() || isGenerating}
className="nodrag flex items-center justify-center gap-2 rounded-md bg-violet-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generating
Generiere
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Generate Image
Bild generieren
</>
)}
</button>

View File

@@ -1,7 +1,13 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import {
Handle,
Position,
useReactFlow,
type NodeProps,
type Node,
} from "@xyflow/react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
@@ -17,6 +23,7 @@ type TextNodeData = {
export type TextNode = Node<TextNodeData, "text">;
export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
const { setNodes } = useReactFlow();
const updateData = useMutation(api.nodes.updateData);
const [content, setContent] = useState(data.content ?? "");
const [isEditing, setIsEditing] = useState(false);
@@ -24,6 +31,7 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
// Sync von außen (Convex-Update) wenn nicht gerade editiert wird
useEffect(() => {
if (!isEditing) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setContent(data.content ?? "");
}
}, [data.content, isEditing]);
@@ -48,9 +56,22 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value;
setContent(newContent);
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
content: newContent,
},
}
: node,
),
);
saveContent(newContent);
},
[saveContent],
[id, saveContent, setNodes],
);
return (