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:
@@ -11,8 +11,6 @@ import {
|
|||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
useStoreApi,
|
|
||||||
useNodesInitialized,
|
|
||||||
reconnectEdge,
|
reconnectEdge,
|
||||||
type Node as RFNode,
|
type Node as RFNode,
|
||||||
type Edge as RFEdge,
|
type Edge as RFEdge,
|
||||||
@@ -29,7 +27,7 @@ import type { Id } from "@/convex/_generated/dataModel";
|
|||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
import { nodeTypes } from "./node-types";
|
import { nodeTypes } from "./node-types";
|
||||||
import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS } from "@/lib/canvas-utils";
|
||||||
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
||||||
|
|
||||||
interface CanvasInnerProps {
|
interface CanvasInnerProps {
|
||||||
@@ -99,12 +97,8 @@ function getMiniMapNodeStrokeColor(node: RFNode): string {
|
|||||||
return node.type === "frame" ? "transparent" : "#4f46e5";
|
return node.type === "frame" ? "transparent" : "#4f46e5";
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_DISTANCE = 150;
|
|
||||||
|
|
||||||
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||||
const { screenToFlowPosition, getInternalNode } = useReactFlow();
|
const { screenToFlowPosition } = useReactFlow();
|
||||||
const store = useStoreApi();
|
|
||||||
const nodesInitialized = useNodesInitialized();
|
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
||||||
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
|
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
|
||||||
@@ -181,7 +175,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
// Delete Edge on Drop
|
// Delete Edge on Drop
|
||||||
const edgeReconnectSuccessful = useRef(true);
|
const edgeReconnectSuccessful = useRef(true);
|
||||||
const uninitializedDragNodeIds = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// ─── Convex → Lokaler State Sync ──────────────────────────────
|
// ─── Convex → Lokaler State Sync ──────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -256,175 +249,26 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
(_: MouseEvent | TouchEvent, edge: RFEdge) => {
|
(_: MouseEvent | TouchEvent, edge: RFEdge) => {
|
||||||
if (!edgeReconnectSuccessful.current) {
|
if (!edgeReconnectSuccessful.current) {
|
||||||
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
|
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
|
||||||
removeEdge({ edgeId: edge.id as Id<"edges"> });
|
if (edge.className === "temp") {
|
||||||
|
edgeReconnectSuccessful.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
||||||
|
console.error("[Canvas edge remove failed] reconnect end", {
|
||||||
|
edgeId: edge.id,
|
||||||
|
edgeClassName: edge.className ?? null,
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
edgeReconnectSuccessful.current = true;
|
edgeReconnectSuccessful.current = true;
|
||||||
},
|
},
|
||||||
[removeEdge],
|
[removeEdge],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Proximity Connect ────────────────────────────────────────
|
|
||||||
const getClosestEdge = useCallback(
|
|
||||||
(node: RFNode) => {
|
|
||||||
if (!nodesInitialized) {
|
|
||||||
if (!uninitializedDragNodeIds.current.has(node.id)) {
|
|
||||||
uninitializedDragNodeIds.current.add(node.id);
|
|
||||||
console.warn("[Canvas debug] proximity skipped: nodes not initialized", {
|
|
||||||
canvasId,
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: node.type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { nodeLookup } = store.getState();
|
|
||||||
const internalNode = getInternalNode(node.id);
|
|
||||||
if (!internalNode) {
|
|
||||||
if (!uninitializedDragNodeIds.current.has(node.id)) {
|
|
||||||
uninitializedDragNodeIds.current.add(node.id);
|
|
||||||
console.warn("[Canvas debug] proximity skipped: missing internal node", {
|
|
||||||
canvasId,
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: node.type,
|
|
||||||
nodeLookupSize: nodeLookup.size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getNodeSize = (n: {
|
|
||||||
measured?: { width?: number; height?: number };
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
internals?: { userNode?: { width?: number; height?: number } };
|
|
||||||
}) => {
|
|
||||||
const width =
|
|
||||||
n.measured?.width ?? n.width ?? n.internals?.userNode?.width ?? 0;
|
|
||||||
const height =
|
|
||||||
n.measured?.height ?? n.height ?? n.internals?.userNode?.height ?? 0;
|
|
||||||
return { width, height };
|
|
||||||
};
|
|
||||||
|
|
||||||
const rectDistance = (
|
|
||||||
a: { x: number; y: number; width: number; height: number },
|
|
||||||
b: { x: number; y: number; width: number; height: number },
|
|
||||||
) => {
|
|
||||||
const dx = Math.max(a.x - (b.x + b.width), b.x - (a.x + a.width), 0);
|
|
||||||
const dy = Math.max(a.y - (b.y + b.height), b.y - (a.y + a.height), 0);
|
|
||||||
return Math.sqrt(dx * dx + dy * dy);
|
|
||||||
};
|
|
||||||
|
|
||||||
const thisSize = getNodeSize(internalNode);
|
|
||||||
const thisRect = {
|
|
||||||
x: internalNode.internals.positionAbsolute.x,
|
|
||||||
y: internalNode.internals.positionAbsolute.y,
|
|
||||||
width: thisSize.width,
|
|
||||||
height: thisSize.height,
|
|
||||||
};
|
|
||||||
|
|
||||||
let minDist = Number.MAX_VALUE;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let closestN: any = null;
|
|
||||||
|
|
||||||
for (const n of nodeLookup.values()) {
|
|
||||||
if (n.id !== internalNode.id) {
|
|
||||||
const nSize = getNodeSize(n);
|
|
||||||
const nRect = {
|
|
||||||
x: n.internals.positionAbsolute.x,
|
|
||||||
y: n.internals.positionAbsolute.y,
|
|
||||||
width: nSize.width,
|
|
||||||
height: nSize.height,
|
|
||||||
};
|
|
||||||
const d = rectDistance(thisRect, nRect);
|
|
||||||
if (d < minDist) {
|
|
||||||
minDist = d;
|
|
||||||
closestN = n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!closestN || minDist >= MIN_DISTANCE) {
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
console.info("[Canvas proximity debug] skipped: distance", {
|
|
||||||
canvasId,
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: node.type,
|
|
||||||
closestNodeId: closestN?.id ?? null,
|
|
||||||
closestNodeType: closestN?.type ?? null,
|
|
||||||
minDist,
|
|
||||||
minDistanceThreshold: MIN_DISTANCE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeNodeIsSource =
|
|
||||||
closestN.internals.positionAbsolute.x <
|
|
||||||
internalNode.internals.positionAbsolute.x;
|
|
||||||
|
|
||||||
const sourceNode = closeNodeIsSource ? closestN : internalNode;
|
|
||||||
const targetNode = closeNodeIsSource ? internalNode : closestN;
|
|
||||||
|
|
||||||
const srcHandles = NODE_HANDLE_MAP[sourceNode.type ?? ""] ?? {};
|
|
||||||
const tgtHandles = NODE_HANDLE_MAP[targetNode.type ?? ""] ?? {};
|
|
||||||
|
|
||||||
if (!("source" in srcHandles) || !("target" in tgtHandles)) {
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
console.info("[Canvas proximity debug] skipped: handle map", {
|
|
||||||
canvasId,
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: node.type,
|
|
||||||
sourceNodeId: sourceNode.id,
|
|
||||||
sourceType: sourceNode.type,
|
|
||||||
targetNodeId: targetNode.id,
|
|
||||||
targetType: targetNode.type,
|
|
||||||
sourceHandles: srcHandles,
|
|
||||||
targetHandles: tgtHandles,
|
|
||||||
minDist,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7733/ingest/db1ec129-24cb-483b-98e2-3e7beef6d9cd',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'594b9f'},body:JSON.stringify({sessionId:'594b9f',runId:'run3',hypothesisId:'H2-fix',location:'canvas.tsx:getClosestEdge',message:'proximity match with handles',data:{sourceId:sourceNode.id,sourceType:sourceNode.type,targetId:targetNode.id,targetType:targetNode.type,sourceHandle:srcHandles.source,targetHandle:tgtHandles.target,minDist},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: closeNodeIsSource
|
|
||||||
? `${closestN.id}-${node.id}`
|
|
||||||
: `${node.id}-${closestN.id}`,
|
|
||||||
source: sourceNode.id,
|
|
||||||
target: targetNode.id,
|
|
||||||
sourceHandle: srcHandles.source,
|
|
||||||
targetHandle: tgtHandles.target,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[store, getInternalNode, nodesInitialized, canvasId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onNodeDrag = useCallback(
|
|
||||||
(_: React.MouseEvent, node: RFNode) => {
|
|
||||||
const closeEdge = getClosestEdge(node);
|
|
||||||
|
|
||||||
setEdges((es) => {
|
|
||||||
const nextEdges = es.filter((e) => e.className !== "temp");
|
|
||||||
if (
|
|
||||||
closeEdge &&
|
|
||||||
!nextEdges.find(
|
|
||||||
(ne) =>
|
|
||||||
ne.source === closeEdge.source && ne.target === closeEdge.target,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
nextEdges.push({ ...closeEdge, className: "temp" });
|
|
||||||
}
|
|
||||||
return nextEdges;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[getClosestEdge],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Drag Start → Lock ────────────────────────────────────────
|
// ─── Drag Start → Lock ────────────────────────────────────────
|
||||||
const onNodeDragStart = useCallback(() => {
|
const onNodeDragStart = useCallback(() => {
|
||||||
isDragging.current = true;
|
isDragging.current = true;
|
||||||
@@ -433,62 +277,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
// ─── Drag Stop → Commit zu Convex ─────────────────────────────
|
// ─── Drag Stop → Commit zu Convex ─────────────────────────────
|
||||||
const onNodeDragStop = useCallback(
|
const onNodeDragStop = useCallback(
|
||||||
(_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => {
|
(_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => {
|
||||||
// Proximity Connect: closeEdge bestimmen bevor isDragging zurückgesetzt wird
|
|
||||||
const closeEdge = getClosestEdge(node);
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
console.info("[Canvas proximity debug] drag stop decision", {
|
|
||||||
canvasId,
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: node.type,
|
|
||||||
draggedCount: draggedNodes.length,
|
|
||||||
closeEdge,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proximity Connect: temporäre Edge entfernen, ggf. echte Edge anlegen
|
|
||||||
setEdges((es) => {
|
|
||||||
const nextEdges = es.filter((e) => e.className !== "temp");
|
|
||||||
if (
|
|
||||||
closeEdge &&
|
|
||||||
!nextEdges.find(
|
|
||||||
(ne) =>
|
|
||||||
ne.source === closeEdge.source && ne.target === closeEdge.target,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
void createEdge({
|
|
||||||
canvasId,
|
|
||||||
sourceNodeId: closeEdge.source as Id<"nodes">,
|
|
||||||
targetNodeId: closeEdge.target as Id<"nodes">,
|
|
||||||
sourceHandle: closeEdge.sourceHandle ?? undefined,
|
|
||||||
targetHandle: closeEdge.targetHandle ?? undefined,
|
|
||||||
})
|
|
||||||
.then((edgeId) => {
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
console.info("[Canvas proximity debug] edge created", {
|
|
||||||
canvasId,
|
|
||||||
edgeId,
|
|
||||||
sourceNodeId: closeEdge.source,
|
|
||||||
targetNodeId: closeEdge.target,
|
|
||||||
sourceHandle: closeEdge.sourceHandle ?? null,
|
|
||||||
targetHandle: closeEdge.targetHandle ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Canvas proximity debug] edge create failed", {
|
|
||||||
canvasId,
|
|
||||||
sourceNodeId: closeEdge.source,
|
|
||||||
targetNodeId: closeEdge.target,
|
|
||||||
sourceHandle: closeEdge.sourceHandle ?? null,
|
|
||||||
targetHandle: closeEdge.targetHandle ?? null,
|
|
||||||
error: String(error),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return nextEdges;
|
|
||||||
});
|
|
||||||
|
|
||||||
// isDragging bleibt true bis die Mutation resolved ist → kein Convex-Override möglich
|
// isDragging bleibt true bis die Mutation resolved ist → kein Convex-Override möglich
|
||||||
if (draggedNodes.length > 1) {
|
if (draggedNodes.length > 1) {
|
||||||
void batchMoveNodes({
|
void batchMoveNodes({
|
||||||
@@ -510,7 +298,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[moveNode, batchMoveNodes, getClosestEdge, createEdge, canvasId],
|
[moveNode, batchMoveNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Neue Verbindung → Convex Edge ────────────────────────────
|
// ─── Neue Verbindung → Convex Edge ────────────────────────────
|
||||||
@@ -560,7 +348,19 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const onEdgesDelete = useCallback(
|
const onEdgesDelete = useCallback(
|
||||||
(deletedEdges: RFEdge[]) => {
|
(deletedEdges: RFEdge[]) => {
|
||||||
for (const edge of deletedEdges) {
|
for (const edge of deletedEdges) {
|
||||||
removeEdge({ edgeId: edge.id as Id<"edges"> });
|
if (edge.className === "temp") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => {
|
||||||
|
console.error("[Canvas edge remove failed] edge delete", {
|
||||||
|
edgeId: edge.id,
|
||||||
|
edgeClassName: edge.className ?? null,
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[removeEdge],
|
[removeEdge],
|
||||||
@@ -627,7 +427,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onNodeDrag={onNodeDrag}
|
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onNodeDragStop={onNodeDragStop}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
|||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="left"
|
id="left"
|
||||||
style={{ top: "40%" }}
|
style={{ top: "35%" }}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-blue-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-blue-500"
|
||||||
/>
|
/>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Right}
|
position={Position.Left}
|
||||||
id="right"
|
id="right"
|
||||||
style={{ top: "40%" }}
|
style={{ top: "55%" }}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react";
|
import {
|
||||||
|
Handle,
|
||||||
|
Position,
|
||||||
|
useReactFlow,
|
||||||
|
useStore,
|
||||||
|
type NodeProps,
|
||||||
|
type Node,
|
||||||
|
} from "@xyflow/react";
|
||||||
import { useMutation, useAction } from "convex/react";
|
import { useMutation, useAction } from "convex/react";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
@@ -52,6 +59,8 @@ export default function PromptNode({
|
|||||||
);
|
);
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const edges = useStore((store) => store.edges);
|
||||||
|
const nodes = useStore((store) => store.nodes);
|
||||||
|
|
||||||
const promptRef = useRef(prompt);
|
const promptRef = useRef(prompt);
|
||||||
const aspectRatioRef = useRef(aspectRatio);
|
const aspectRatioRef = useRef(aspectRatio);
|
||||||
@@ -66,6 +75,31 @@ export default function PromptNode({
|
|||||||
setAspectRatio(nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO);
|
setAspectRatio(nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO);
|
||||||
}, [nodeData.aspectRatio]);
|
}, [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);
|
const dataRef = useRef(data);
|
||||||
dataRef.current = data;
|
dataRef.current = data;
|
||||||
|
|
||||||
@@ -107,29 +141,38 @@ export default function PromptNode({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
const handleGenerate = useCallback(async () => {
|
||||||
if (!prompt.trim() || isGenerating) return;
|
if (!effectivePrompt.trim() || isGenerating) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const canvasId = nodeData.canvasId as Id<"canvases">;
|
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 currentEdges = getEdges();
|
||||||
const incomingEdges = edges.filter((e) => e.target === id);
|
const incomingEdges = currentEdges.filter((e) => e.target === id);
|
||||||
|
let connectedTextPrompt: string | undefined;
|
||||||
let referenceStorageId: Id<"_storage"> | undefined;
|
let referenceStorageId: Id<"_storage"> | undefined;
|
||||||
|
|
||||||
for (const edge of incomingEdges) {
|
for (const edge of incomingEdges) {
|
||||||
const sourceNode = getNode(edge.source);
|
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") {
|
if (sourceNode?.type === "image") {
|
||||||
const srcData = sourceNode.data as { storageId?: string };
|
const srcData = sourceNode.data as { storageId?: string };
|
||||||
if (srcData.storageId) {
|
if (srcData.storageId) {
|
||||||
referenceStorageId = srcData.storageId as Id<"_storage">;
|
referenceStorageId = srcData.storageId as Id<"_storage">;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promptToUse = (connectedTextPrompt ?? prompt).trim();
|
||||||
|
if (!promptToUse) return;
|
||||||
|
|
||||||
const currentNode = getNode(id);
|
const currentNode = getNode(id);
|
||||||
const offsetX = (currentNode?.measured?.width ?? 280) + 32;
|
const offsetX = (currentNode?.measured?.width ?? 280) + 32;
|
||||||
const posX = (currentNode?.position?.x ?? 0) + offsetX;
|
const posX = (currentNode?.position?.x ?? 0) + offsetX;
|
||||||
@@ -146,7 +189,7 @@ export default function PromptNode({
|
|||||||
width: outer.width,
|
width: outer.width,
|
||||||
height: outer.height,
|
height: outer.height,
|
||||||
data: {
|
data: {
|
||||||
prompt,
|
prompt: promptToUse,
|
||||||
model: DEFAULT_MODEL_ID,
|
model: DEFAULT_MODEL_ID,
|
||||||
modelTier: "standard",
|
modelTier: "standard",
|
||||||
canvasId,
|
canvasId,
|
||||||
@@ -167,18 +210,19 @@ export default function PromptNode({
|
|||||||
await generateImage({
|
await generateImage({
|
||||||
canvasId,
|
canvasId,
|
||||||
nodeId: aiNodeId,
|
nodeId: aiNodeId,
|
||||||
prompt,
|
prompt: promptToUse,
|
||||||
referenceStorageId,
|
referenceStorageId,
|
||||||
model: DEFAULT_MODEL_ID,
|
model: DEFAULT_MODEL_ID,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Generation failed");
|
setError(err instanceof Error ? err.message : "Bildgenerierung fehlgeschlagen");
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
prompt,
|
prompt,
|
||||||
|
effectivePrompt,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
nodeData.canvasId,
|
nodeData.canvasId,
|
||||||
@@ -206,15 +250,26 @@ export default function PromptNode({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2 p-3">
|
<div className="flex flex-col gap-2 p-3">
|
||||||
<div className="text-xs font-medium text-violet-600 dark:text-violet-400">
|
<div className="text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||||
✨ Prompt
|
✨ Eingabe
|
||||||
</div>
|
</div>
|
||||||
|
{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
|
<textarea
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={handlePromptChange}
|
onChange={handlePromptChange}
|
||||||
placeholder="Describe what you want to generate…"
|
placeholder="Beschreibe, was du generieren willst…"
|
||||||
rows={4}
|
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"
|
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">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label
|
<Label
|
||||||
@@ -262,18 +317,18 @@ export default function PromptNode({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleGenerate()}
|
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"
|
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 ? (
|
{isGenerating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Generating…
|
Generiere…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
Generate Image
|
Bild generieren
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
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 { useMutation } from "convex/react";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
@@ -17,6 +23,7 @@ type TextNodeData = {
|
|||||||
export type TextNode = Node<TextNodeData, "text">;
|
export type TextNode = Node<TextNodeData, "text">;
|
||||||
|
|
||||||
export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
||||||
|
const { setNodes } = useReactFlow();
|
||||||
const updateData = useMutation(api.nodes.updateData);
|
const updateData = useMutation(api.nodes.updateData);
|
||||||
const [content, setContent] = useState(data.content ?? "");
|
const [content, setContent] = useState(data.content ?? "");
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
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
|
// Sync von außen (Convex-Update) wenn nicht gerade editiert wird
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setContent(data.content ?? "");
|
setContent(data.content ?? "");
|
||||||
}
|
}
|
||||||
}, [data.content, isEditing]);
|
}, [data.content, isEditing]);
|
||||||
@@ -48,9 +56,22 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
|||||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const newContent = e.target.value;
|
const newContent = e.target.value;
|
||||||
setContent(newContent);
|
setContent(newContent);
|
||||||
|
setNodes((nodes) =>
|
||||||
|
nodes.map((node) =>
|
||||||
|
node.id === id
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
content: newContent,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
);
|
||||||
saveContent(newContent);
|
saveContent(newContent);
|
||||||
},
|
},
|
||||||
[saveContent],
|
[id, saveContent, setNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -82,15 +82,38 @@ export const remove = mutation({
|
|||||||
args: { edgeId: v.id("edges") },
|
args: { edgeId: v.id("edges") },
|
||||||
handler: async (ctx, { edgeId }) => {
|
handler: async (ctx, { edgeId }) => {
|
||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
|
console.info("[edges.remove] request", {
|
||||||
|
edgeId,
|
||||||
|
userId: user.userId,
|
||||||
|
});
|
||||||
|
|
||||||
const edge = await ctx.db.get(edgeId);
|
const edge = await ctx.db.get(edgeId);
|
||||||
if (!edge) throw new Error("Edge not found");
|
if (!edge) {
|
||||||
|
console.info("[edges.remove] edge already removed (idempotent no-op)", {
|
||||||
|
edgeId,
|
||||||
|
userId: user.userId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = await ctx.db.get(edge.canvasId);
|
const canvas = await ctx.db.get(edge.canvasId);
|
||||||
if (!canvas || canvas.ownerId !== user.userId) {
|
if (!canvas || canvas.ownerId !== user.userId) {
|
||||||
|
console.warn("[edges.remove] unauthorized canvas access", {
|
||||||
|
edgeId,
|
||||||
|
canvasId: edge.canvasId,
|
||||||
|
userId: user.userId,
|
||||||
|
hasCanvas: Boolean(canvas),
|
||||||
|
});
|
||||||
throw new Error("Canvas not found");
|
throw new Error("Canvas not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.delete(edgeId);
|
await ctx.db.delete(edgeId);
|
||||||
await ctx.db.patch(edge.canvasId, { updatedAt: Date.now() });
|
await ctx.db.patch(edge.canvasId, { updatedAt: Date.now() });
|
||||||
|
|
||||||
|
console.info("[edges.remove] success", {
|
||||||
|
edgeId,
|
||||||
|
canvasId: edge.canvasId,
|
||||||
|
userId: user.userId,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ export const IMAGE_FORMAT_GROUP_LABELS: Record<ImageFormatGroup, string> = {
|
|||||||
export const IMAGE_FORMAT_PRESETS: ImageFormatPreset[] = [
|
export const IMAGE_FORMAT_PRESETS: ImageFormatPreset[] = [
|
||||||
{ label: "1:1 · Quadrat", aspectRatio: "1:1", group: "square" },
|
{ label: "1:1 · Quadrat", aspectRatio: "1:1", group: "square" },
|
||||||
{ label: "16:9 · Breitbild", aspectRatio: "16:9", group: "landscape" },
|
{ label: "16:9 · Breitbild", aspectRatio: "16:9", group: "landscape" },
|
||||||
{ label: "21:9 · Cinematic", aspectRatio: "21:9", group: "landscape" },
|
{ label: "21:9 · Kino", aspectRatio: "21:9", group: "landscape" },
|
||||||
{ label: "4:3 · Klassisch", aspectRatio: "4:3", group: "landscape" },
|
{ label: "4:3 · Klassisch", aspectRatio: "4:3", group: "landscape" },
|
||||||
{ label: "3:2 · Foto (quer)", aspectRatio: "3:2", group: "landscape" },
|
{ label: "3:2 · Foto (quer)", aspectRatio: "3:2", group: "landscape" },
|
||||||
{ label: "5:4 · leicht quer", aspectRatio: "5:4", group: "landscape" },
|
{ label: "5:4 · leicht quer", aspectRatio: "5:4", group: "landscape" },
|
||||||
{ label: "9:16 · Stories", aspectRatio: "9:16", group: "portrait" },
|
{ label: "9:16 · Story", aspectRatio: "9:16", group: "portrait" },
|
||||||
{ label: "3:4 · Porträt", aspectRatio: "3:4", group: "portrait" },
|
{ label: "3:4 · Porträt", aspectRatio: "3:4", group: "portrait" },
|
||||||
{ label: "2:3 · Foto (hoch)", aspectRatio: "2:3", group: "portrait" },
|
{ label: "2:3 · Foto (hoch)", aspectRatio: "2:3", group: "portrait" },
|
||||||
{ label: "4:5 · Social hoch", aspectRatio: "4:5", group: "portrait" },
|
{ label: "4:5 · Social hoch", aspectRatio: "4:5", group: "portrait" },
|
||||||
|
|||||||
Reference in New Issue
Block a user