From 8daa4a91fb37d68c6bc1b53a0506299748f6e5c6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 26 Mar 2026 18:22:57 +0100 Subject: [PATCH] feat: enhance canvas and node components with improved edge handling and new node features - Refactored canvas toolbar to utilize new canvas placement context for node creation. - Updated node components (compare, group, image, note, prompt, text) to include source and target handles for better edge management. - Improved edge intersection handling during node drag operations for enhanced user experience. - Added utility functions for edge identification and node positioning to streamline interactions. --- .../canvas/canvas-placement-context.tsx | 204 ++++++++++ components/canvas/canvas-toolbar.tsx | 16 +- components/canvas/canvas.tsx | 355 +++++++++++++++--- components/canvas/nodes/compare-node.tsx | 6 + components/canvas/nodes/group-node.tsx | 15 +- components/canvas/nodes/image-node.tsx | 6 + components/canvas/nodes/note-node.tsx | 15 +- components/canvas/nodes/prompt-node.tsx | 11 +- components/canvas/nodes/text-node.tsx | 8 +- lib/canvas-utils.ts | 8 +- 10 files changed, 562 insertions(+), 82 deletions(-) create mode 100644 components/canvas/canvas-placement-context.tsx diff --git a/components/canvas/canvas-placement-context.tsx b/components/canvas/canvas-placement-context.tsx new file mode 100644 index 0000000..77be94c --- /dev/null +++ b/components/canvas/canvas-placement-context.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + type ReactNode, +} from "react"; +import { useMutation } from "convex/react"; +import { useReactFlow, useStore, type Edge as RFEdge } from "@xyflow/react"; + +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; + +type FlowPoint = { x: number; y: number }; + +type CreateNodeWithIntersectionInput = { + type: string; + position: FlowPoint; + width?: number; + height?: number; + data?: Record; + clientPosition?: FlowPoint; +}; + +type CanvasPlacementContextValue = { + createNodeWithIntersection: ( + input: CreateNodeWithIntersectionInput, + ) => Promise>; +}; + +const CanvasPlacementContext = createContext( + null, +); + +function getEdgeIdFromInteractionElement(element: Element): string | null { + const edgeContainer = element.closest(".react-flow__edge"); + if (!edgeContainer) return null; + + const dataId = edgeContainer.getAttribute("data-id"); + if (dataId) return dataId; + + const domId = edgeContainer.getAttribute("id"); + if (domId?.startsWith("reactflow__edge-")) { + return domId.slice("reactflow__edge-".length); + } + + return null; +} + +function getIntersectedPersistedEdge( + point: FlowPoint, + edges: RFEdge[], +): RFEdge | undefined { + const elements = document.elementsFromPoint(point.x, point.y); + const interactionElement = elements.find( + (element) => element.classList.contains("react-flow__edge-interaction"), + ); + + if (!interactionElement) { + return undefined; + } + + const edgeId = getEdgeIdFromInteractionElement(interactionElement); + if (!edgeId) return undefined; + + const edge = edges.find((candidate) => candidate.id === edgeId); + if (!edge || edge.className === "temp") return undefined; + + return edge; +} + +function hasHandleKey( + handles: { source?: string; target?: string } | undefined, + key: "source" | "target", +): boolean { + if (!handles) return false; + return Object.prototype.hasOwnProperty.call(handles, key); +} + +function normalizeHandle(handle: string | null | undefined): string | undefined { + return handle ?? undefined; +} + +interface CanvasPlacementProviderProps { + canvasId: Id<"canvases">; + children: ReactNode; +} + +export function CanvasPlacementProvider({ + canvasId, + children, +}: CanvasPlacementProviderProps) { + const { flowToScreenPosition } = useReactFlow(); + const edges = useStore((store) => store.edges); + const createNode = useMutation(api.nodes.create); + const createEdge = useMutation(api.edges.create); + const removeEdge = useMutation(api.edges.remove); + + const createNodeWithIntersection = useCallback( + async ({ + type, + position, + width, + height, + data, + clientPosition, + }: CreateNodeWithIntersectionInput) => { + const defaults = NODE_DEFAULTS[type] ?? { + width: 200, + height: 100, + data: {}, + }; + + const effectiveWidth = width ?? defaults.width; + const effectiveHeight = height ?? defaults.height; + const centerClientPosition = flowToScreenPosition({ + x: position.x + effectiveWidth / 2, + y: position.y + effectiveHeight / 2, + }); + + const hitEdgeFromClientPosition = clientPosition + ? getIntersectedPersistedEdge(clientPosition, edges) + : undefined; + const hitEdge = + hitEdgeFromClientPosition ?? + getIntersectedPersistedEdge(centerClientPosition, edges); + + const nodeId = await createNode({ + canvasId, + type, + positionX: position.x, + positionY: position.y, + width: effectiveWidth, + height: effectiveHeight, + data: { + ...defaults.data, + ...(data ?? {}), + canvasId, + }, + }); + + if (!hitEdge) { + return nodeId; + } + + const handles = NODE_HANDLE_MAP[type]; + if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) { + return nodeId; + } + + try { + await createEdge({ + canvasId, + sourceNodeId: hitEdge.source as Id<"nodes">, + targetNodeId: nodeId, + sourceHandle: normalizeHandle(hitEdge.sourceHandle), + targetHandle: normalizeHandle(handles.target), + }); + + await createEdge({ + canvasId, + sourceNodeId: nodeId, + targetNodeId: hitEdge.target as Id<"nodes">, + sourceHandle: normalizeHandle(handles.source), + targetHandle: normalizeHandle(hitEdge.targetHandle), + }); + + await removeEdge({ edgeId: hitEdge.id as Id<"edges"> }); + } catch (error) { + console.error("[Canvas placement] edge split failed", { + edgeId: hitEdge.id, + nodeId, + type, + error: String(error), + }); + } + + return nodeId; + }, + [canvasId, createEdge, createNode, edges, flowToScreenPosition, removeEdge], + ); + + const value = useMemo( + () => ({ createNodeWithIntersection }), + [createNodeWithIntersection], + ); + + return ( + + {children} + + ); +} + +export function useCanvasPlacement() { + const context = useContext(CanvasPlacementContext); + if (!context) { + throw new Error("useCanvasPlacement must be used within CanvasPlacementProvider"); + } + return context; +} diff --git a/components/canvas/canvas-toolbar.tsx b/components/canvas/canvas-toolbar.tsx index 6620a4e..5b48a7f 100644 --- a/components/canvas/canvas-toolbar.tsx +++ b/components/canvas/canvas-toolbar.tsx @@ -1,11 +1,9 @@ "use client"; -import { useMutation } from "convex/react"; import { useRef } from "react"; -import { api } from "@/convex/_generated/api"; -import type { Id } from "@/convex/_generated/dataModel"; import { ExportButton } from "@/components/canvas/export-button"; +import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; const nodeTemplates = [ { @@ -53,15 +51,13 @@ const nodeTemplates = [ ] as const; interface CanvasToolbarProps { - canvasId: Id<"canvases">; canvasName?: string; } export default function CanvasToolbar({ - canvasId, canvasName, }: CanvasToolbarProps) { - const createNode = useMutation(api.nodes.create); + const { createNodeWithIntersection } = useCanvasPlacement(); const nodeCountRef = useRef(0); const handleAddNode = async ( @@ -72,14 +68,12 @@ export default function CanvasToolbar({ ) => { const offset = (nodeCountRef.current % 8) * 24; nodeCountRef.current += 1; - await createNode({ - canvasId, + await createNodeWithIntersection({ type, - positionX: 100 + offset, - positionY: 100 + offset, + position: { x: 100 + offset, y: 100 + offset }, width, height, - data: { ...data, canvasId }, + data, }); }; diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index c0d6b0f..14d2237 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -17,6 +17,7 @@ import { type NodeChange, type EdgeChange, type Connection, + type DefaultEdgeOptions, BackgroundVariant, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; @@ -27,8 +28,14 @@ import type { Id } from "@/convex/_generated/dataModel"; import { authClient } from "@/lib/auth-client"; import { nodeTypes } from "./node-types"; -import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS } from "@/lib/canvas-utils"; +import { + convexNodeToRF, + convexEdgeToRF, + NODE_DEFAULTS, + NODE_HANDLE_MAP, +} from "@/lib/canvas-utils"; import CanvasToolbar from "@/components/canvas/canvas-toolbar"; +import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context"; interface CanvasInnerProps { canvasId: Id<"canvases">; @@ -97,6 +104,68 @@ function getMiniMapNodeStrokeColor(node: RFNode): string { return node.type === "frame" ? "transparent" : "#4f46e5"; } +const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = { + interactionWidth: 75, +}; + +const EDGE_INTERSECTION_HIGHLIGHT_STYLE: NonNullable = { + stroke: "hsl(var(--foreground))", + strokeWidth: 2, +}; + +function getEdgeIdFromInteractionElement(element: Element): string | null { + const edgeContainer = element.closest(".react-flow__edge"); + if (!edgeContainer) return null; + + const dataId = edgeContainer.getAttribute("data-id"); + if (dataId) return dataId; + + const domId = edgeContainer.getAttribute("id"); + if (domId?.startsWith("reactflow__edge-")) { + return domId.slice("reactflow__edge-".length); + } + + return null; +} + +function getNodeCenterClientPosition(nodeId: string): { x: number; y: number } | null { + const nodeElement = Array.from( + document.querySelectorAll(".react-flow__node"), + ).find((element) => element.dataset.id === nodeId); + + if (!nodeElement) return null; + + const rect = nodeElement.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; +} + +function getIntersectedEdgeId(point: { x: number; y: number }): string | null { + const interactionElement = document + .elementsFromPoint(point.x, point.y) + .find((element) => element.classList.contains("react-flow__edge-interaction")); + + if (!interactionElement) { + return null; + } + + return getEdgeIdFromInteractionElement(interactionElement); +} + +function hasHandleKey( + handles: { source?: string; target?: string } | undefined, + key: "source" | "target", +): boolean { + if (!handles) return false; + return Object.prototype.hasOwnProperty.call(handles, key); +} + +function normalizeHandle(handle: string | null | undefined): string | undefined { + return handle ?? undefined; +} + function CanvasInner({ canvasId }: CanvasInnerProps) { const { screenToFlowPosition } = useReactFlow(); const { resolvedTheme } = useTheme(); @@ -175,6 +244,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // Delete Edge on Drop const edgeReconnectSuccessful = useRef(true); + const overlappedEdgeRef = useRef(null); + const highlightedEdgeRef = useRef(null); + const highlightedEdgeOriginalStyleRef = useRef( + undefined, + ); // ─── Convex → Lokaler State Sync ────────────────────────────── useEffect(() => { @@ -269,36 +343,195 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { [removeEdge], ); + const setHighlightedIntersectionEdge = useCallback((edgeId: string | null) => { + const previousHighlightedEdgeId = highlightedEdgeRef.current; + if (previousHighlightedEdgeId === edgeId) { + return; + } + + setEdges((currentEdges) => { + let nextEdges = currentEdges; + + if (previousHighlightedEdgeId) { + nextEdges = nextEdges.map((edge) => + edge.id === previousHighlightedEdgeId + ? { + ...edge, + style: highlightedEdgeOriginalStyleRef.current, + } + : edge, + ); + } + + if (!edgeId) { + highlightedEdgeOriginalStyleRef.current = undefined; + return nextEdges; + } + + const edgeToHighlight = nextEdges.find((edge) => edge.id === edgeId); + if (!edgeToHighlight || edgeToHighlight.className === "temp") { + highlightedEdgeOriginalStyleRef.current = undefined; + return nextEdges; + } + + highlightedEdgeOriginalStyleRef.current = edgeToHighlight.style; + + return nextEdges.map((edge) => + edge.id === edgeId + ? { + ...edge, + style: { + ...(edge.style ?? {}), + ...EDGE_INTERSECTION_HIGHLIGHT_STYLE, + }, + } + : edge, + ); + }); + + highlightedEdgeRef.current = edgeId; + }, []); + + const onNodeDrag = useCallback( + (_event: React.MouseEvent, node: RFNode) => { + const nodeCenter = getNodeCenterClientPosition(node.id); + if (!nodeCenter) { + overlappedEdgeRef.current = null; + setHighlightedIntersectionEdge(null); + return; + } + + const intersectedEdgeId = getIntersectedEdgeId(nodeCenter); + if (!intersectedEdgeId) { + overlappedEdgeRef.current = null; + setHighlightedIntersectionEdge(null); + return; + } + + const intersectedEdge = edges.find( + (edge) => edge.id === intersectedEdgeId && edge.className !== "temp", + ); + if (!intersectedEdge) { + overlappedEdgeRef.current = null; + setHighlightedIntersectionEdge(null); + return; + } + + if ( + intersectedEdge.source === node.id || + intersectedEdge.target === node.id + ) { + overlappedEdgeRef.current = null; + setHighlightedIntersectionEdge(null); + return; + } + + const handles = NODE_HANDLE_MAP[node.type ?? ""]; + if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) { + overlappedEdgeRef.current = null; + setHighlightedIntersectionEdge(null); + return; + } + + overlappedEdgeRef.current = intersectedEdge.id; + setHighlightedIntersectionEdge(intersectedEdge.id); + }, + [edges, setHighlightedIntersectionEdge], + ); + // ─── Drag Start → Lock ──────────────────────────────────────── const onNodeDragStart = useCallback(() => { isDragging.current = true; - }, []); + overlappedEdgeRef.current = null; + setHighlightedIntersectionEdge(null); + }, [setHighlightedIntersectionEdge]); // ─── Drag Stop → Commit zu Convex ───────────────────────────── const onNodeDragStop = useCallback( (_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => { - // isDragging bleibt true bis die Mutation resolved ist → kein Convex-Override möglich - if (draggedNodes.length > 1) { - void batchMoveNodes({ - moves: draggedNodes.map((n) => ({ - nodeId: n.id as Id<"nodes">, - positionX: n.position.x, - positionY: n.position.y, - })), - }).then(() => { + const intersectedEdgeId = overlappedEdgeRef.current; + + void (async () => { + try { + // isDragging bleibt true bis alle Mutations resolved sind + if (draggedNodes.length > 1) { + await batchMoveNodes({ + moves: draggedNodes.map((n) => ({ + nodeId: n.id as Id<"nodes">, + positionX: n.position.x, + positionY: n.position.y, + })), + }); + } else { + await moveNode({ + nodeId: node.id as Id<"nodes">, + positionX: node.position.x, + positionY: node.position.y, + }); + } + + if (!intersectedEdgeId) { + return; + } + + const intersectedEdge = edges.find((edge) => edge.id === intersectedEdgeId); + if (!intersectedEdge || intersectedEdge.className === "temp") { + return; + } + + if ( + intersectedEdge.source === node.id || + intersectedEdge.target === node.id + ) { + return; + } + + const handles = NODE_HANDLE_MAP[node.type ?? ""]; + if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) { + return; + } + + await createEdge({ + canvasId, + sourceNodeId: intersectedEdge.source as Id<"nodes">, + targetNodeId: node.id as Id<"nodes">, + sourceHandle: normalizeHandle(intersectedEdge.sourceHandle), + targetHandle: normalizeHandle(handles.target), + }); + + await createEdge({ + canvasId, + sourceNodeId: node.id as Id<"nodes">, + targetNodeId: intersectedEdge.target as Id<"nodes">, + sourceHandle: normalizeHandle(handles.source), + targetHandle: normalizeHandle(intersectedEdge.targetHandle), + }); + + await removeEdge({ edgeId: intersectedEdge.id as Id<"edges"> }); + } catch (error) { + console.error("[Canvas edge intersection split failed]", { + canvasId, + nodeId: node.id, + nodeType: node.type, + intersectedEdgeId, + error: String(error), + }); + } finally { + overlappedEdgeRef.current = null; + setHighlightedIntersectionEdge(null); isDragging.current = false; - }); - } else { - void moveNode({ - nodeId: node.id as Id<"nodes">, - positionX: node.position.x, - positionY: node.position.y, - }).then(() => { - isDragging.current = false; - }); - } + } + })(); }, - [moveNode, batchMoveNodes], + [ + batchMoveNodes, + canvasId, + createEdge, + edges, + moveNode, + removeEdge, + setHighlightedIntersectionEdge, + ], ); // ─── Neue Verbindung → Convex Edge ──────────────────────────── @@ -419,43 +652,47 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } return ( -
- - - - - - -
+ +
+ + + + + + +
+
); } diff --git a/components/canvas/nodes/compare-node.tsx b/components/canvas/nodes/compare-node.tsx index 745f33c..cfadceb 100644 --- a/components/canvas/nodes/compare-node.tsx +++ b/components/canvas/nodes/compare-node.tsx @@ -80,6 +80,12 @@ export default function CompareNode({ data, selected }: NodeProps) { style={{ top: "55%" }} className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500" /> +
) useEffect(() => { if (!isEditing) { + // eslint-disable-next-line react-hooks/set-state-in-effect setLabel(data.label ?? "Gruppe"); } }, [data.label, isEditing]); @@ -46,6 +47,12 @@ export default function GroupNode({ id, data, selected }: NodeProps) selected={selected} className="min-w-[200px] min-h-[150px] p-3 border-dashed" > + + {isEditing ? ( ) 📁 {label}
)} + + ); } diff --git a/components/canvas/nodes/image-node.tsx b/components/canvas/nodes/image-node.tsx index 9f42060..7a5ebbf 100644 --- a/components/canvas/nodes/image-node.tsx +++ b/components/canvas/nodes/image-node.tsx @@ -117,6 +117,12 @@ export default function ImageNode({ id, data, selected }: NodeProps) return ( + +
🖼️ Bild
diff --git a/components/canvas/nodes/note-node.tsx b/components/canvas/nodes/note-node.tsx index f4e1006..401d8bd 100644 --- a/components/canvas/nodes/note-node.tsx +++ b/components/canvas/nodes/note-node.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useCallback, useEffect } from "react"; -import { type NodeProps, type Node } from "@xyflow/react"; +import { Handle, Position, 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"; @@ -23,6 +23,7 @@ export default function NoteNode({ id, data, selected }: NodeProps) { useEffect(() => { if (!isEditing) { + // eslint-disable-next-line react-hooks/set-state-in-effect setContent(data.content ?? ""); } }, [data.content, isEditing]); @@ -53,6 +54,12 @@ export default function NoteNode({ id, data, selected }: NodeProps) { return ( + +
📌 Notiz
@@ -78,6 +85,12 @@ export default function NoteNode({ id, data, selected }: NodeProps) { )}
)} + + ); } diff --git a/components/canvas/nodes/prompt-node.tsx b/components/canvas/nodes/prompt-node.tsx index 9904f3b..4f39dae 100644 --- a/components/canvas/nodes/prompt-node.tsx +++ b/components/canvas/nodes/prompt-node.tsx @@ -13,6 +13,7 @@ import { useMutation, useAction } from "convex/react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import BaseNodeWrapper from "./base-node-wrapper"; +import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { DEFAULT_MODEL_ID } from "@/lib/ai-models"; import { @@ -104,9 +105,9 @@ export default function PromptNode({ dataRef.current = data; const updateData = useMutation(api.nodes.updateData); - const createNode = useMutation(api.nodes.create); const createEdge = useMutation(api.edges.create); const generateImage = useAction(api.ai.generateImage); + const { createNodeWithIntersection } = useCanvasPlacement(); const debouncedSave = useDebouncedCallback(() => { const raw = dataRef.current as Record; @@ -181,11 +182,9 @@ export default function PromptNode({ const viewport = getImageViewportSize(aspectRatio); const outer = getAiImageNodeOuterSize(viewport); - const aiNodeId = await createNode({ - canvasId, + const aiNodeId = await createNodeWithIntersection({ type: "ai-image", - positionX: posX, - positionY: posY, + position: { x: posX, y: posY }, width: outer.width, height: outer.height, data: { @@ -229,7 +228,7 @@ export default function PromptNode({ id, getEdges, getNode, - createNode, + createNodeWithIntersection, createEdge, generateImage, ]); diff --git a/components/canvas/nodes/text-node.tsx b/components/canvas/nodes/text-node.tsx index a76b55d..d4c76ae 100644 --- a/components/canvas/nodes/text-node.tsx +++ b/components/canvas/nodes/text-node.tsx @@ -75,7 +75,13 @@ export default function TextNode({ id, data, selected }: NodeProps) { ); return ( - + + +
📝 Text diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index ff2a91d..095eeea 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -55,12 +55,14 @@ export const NODE_HANDLE_MAP: Record< string, { source?: string; target?: string } > = { - image: { source: undefined }, - text: { source: undefined }, + image: { source: undefined, target: undefined }, + text: { source: undefined, target: undefined }, prompt: { source: "prompt-out", target: "image-in" }, "ai-image": { source: "image-out", target: "prompt-in" }, + group: { source: undefined, target: undefined }, frame: { source: "frame-out", target: "frame-in" }, - compare: { target: "left" }, + note: { source: undefined, target: undefined }, + compare: { source: "compare-out", target: "left" }, }; /**