diff --git a/app/globals.css b/app/globals.css index 573fb0e..0cb9fe4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -138,6 +138,23 @@ } } +/* Temporäre XYFlow-Verbindungslinie (custom connectionLineComponent) */ +@keyframes ls-connection-dash-offset { + to { + stroke-dashoffset: -18; + } +} + +.ls-connection-line-marching { + animation: ls-connection-dash-offset 0.4s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .ls-connection-line-marching { + animation: none; + } +} + @layer utilities { .animate-shimmer { animation: shimmer 1.5s ease-in-out infinite; diff --git a/components/canvas/canvas-placement-context.tsx b/components/canvas/canvas-placement-context.tsx index 64aa8cc..b22001d 100644 --- a/components/canvas/canvas-placement-context.tsx +++ b/components/canvas/canvas-placement-context.tsx @@ -9,7 +9,7 @@ import { } from "react"; import type { ReactMutation } from "convex/react"; import type { FunctionReference } from "convex/server"; -import { useReactFlow, useStore, type Edge as RFEdge } from "@xyflow/react"; +import { useStore, type Edge as RFEdge } from "@xyflow/react"; import type { Id } from "@/convex/_generated/dataModel"; import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; @@ -89,6 +89,10 @@ type CreateNodeWithIntersectionInput = { width?: number; height?: number; data?: Record; + /** + * Optionaler Bildschirmpunkt für Hit-Test auf eine Kante. Nur wenn gesetzt, + * kann eine bestehende Kante gesplittet werden — ohne dieses Feld niemals. + */ clientPosition?: FlowPoint; zIndex?: number; /** Correlate optimistic node id with server id after create (see canvas move flush). */ @@ -183,7 +187,6 @@ export function CanvasPlacementProvider({ onCreateNodeSettled, children, }: CanvasPlacementProviderProps) { - const { flowToScreenPosition } = useReactFlow(); const edges = useStore((store) => store.edges); const createNodeWithIntersection = useCallback( @@ -205,17 +208,10 @@ export function CanvasPlacementProvider({ 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 + const hitEdge = clientPosition ? getIntersectedPersistedEdge(clientPosition, edges) : undefined; - const hitEdge = - hitEdgeFromClientPosition ?? - getIntersectedPersistedEdge(centerClientPosition, edges); const baseNodePayload = { canvasId, @@ -279,7 +275,6 @@ export function CanvasPlacementProvider({ createNode, createNodeWithEdgeSplit, edges, - flowToScreenPosition, onCreateNodeSettled, ], ); diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index dff4847..5126e18 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -31,6 +31,7 @@ import { authClient } from "@/lib/auth-client"; import { nodeTypes } from "./node-types"; import { + computeBridgeCreatesForDeletedNodes, convexNodeDocWithMergedStorageUrl, convexNodeToRF, convexEdgeToRF, @@ -48,6 +49,7 @@ import { import CanvasToolbar from "@/components/canvas/canvas-toolbar"; import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette"; import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context"; +import CustomConnectionLine from "@/components/canvas/custom-connection-line"; interface CanvasInnerProps { canvasId: Id<"canvases">; @@ -66,6 +68,17 @@ function clientRequestIdFromOptimisticNodeId(id: string): string | null { return suffix.length > 0 ? suffix : null; } +/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */ +type PendingEdgeSplit = { + intersectedEdgeId: Id<"edges">; + sourceNodeId: Id<"nodes">; + targetNodeId: Id<"nodes">; + intersectedSourceHandle?: string; + intersectedTargetHandle?: string; + middleSourceHandle?: string; + middleTargetHandle?: string; +}; + function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] { const persistedEdges = edges.filter((edge) => edge.className !== "temp"); let hasNodeUpdates = false; @@ -380,40 +393,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { new Map(), ); const resolvedRealIdByClientRequestRef = useRef(new Map>()); - - /** Pairing: create kann vor oder nach Drag-Ende fertig sein — was zuerst kommt, speichert; das andere triggert moveNode. */ - const syncPendingMoveForClientRequest = useCallback( - (clientRequestId: string | undefined, realId?: Id<"nodes">) => { - if (!clientRequestId) return; - - if (realId !== undefined) { - const pending = pendingMoveAfterCreateRef.current.get(clientRequestId); - if (pending) { - pendingMoveAfterCreateRef.current.delete(clientRequestId); - resolvedRealIdByClientRequestRef.current.delete(clientRequestId); - void moveNode({ - nodeId: realId, - positionX: pending.positionX, - positionY: pending.positionY, - }); - return; - } - resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); - return; - } - - const r = resolvedRealIdByClientRequestRef.current.get(clientRequestId); - const p = pendingMoveAfterCreateRef.current.get(clientRequestId); - if (!r || !p) return; - pendingMoveAfterCreateRef.current.delete(clientRequestId); - resolvedRealIdByClientRequestRef.current.delete(clientRequestId); - void moveNode({ - nodeId: r, - positionX: p.positionX, - positionY: p.positionY, - }); - }, - [moveNode], + const pendingEdgeSplitByClientRequestRef = useRef( + new Map(), ); const createNode = useMutation(api.nodes.create).withOptimisticUpdate( @@ -513,9 +494,165 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit); - const batchRemoveNodes = useMutation(api.nodes.batchRemove); - const createEdge = useMutation(api.edges.create); - const removeEdge = useMutation(api.edges.remove); + + const batchRemoveNodes = useMutation(api.nodes.batchRemove).withOptimisticUpdate( + (localStore, args) => { + const nodeList = localStore.getQuery(api.nodes.list, { canvasId }); + const edgeList = localStore.getQuery(api.edges.list, { canvasId }); + if (nodeList === undefined || edgeList === undefined) return; + + const removeSet = new Set(args.nodeIds.map((id) => id as string)); + localStore.setQuery( + api.nodes.list, + { canvasId }, + nodeList.filter((n) => !removeSet.has(n._id)), + ); + localStore.setQuery( + api.edges.list, + { canvasId }, + edgeList.filter( + (e) => + !removeSet.has(e.sourceNodeId) && !removeSet.has(e.targetNodeId), + ), + ); + }, + ); + + const createEdge = useMutation(api.edges.create).withOptimisticUpdate( + (localStore, args) => { + const edgeList = localStore.getQuery(api.edges.list, { + canvasId: args.canvasId, + }); + if (edgeList === undefined) return; + + const tempId = `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` as Id<"edges">; + const synthetic: Doc<"edges"> = { + _id: tempId, + _creationTime: Date.now(), + canvasId: args.canvasId, + sourceNodeId: args.sourceNodeId, + targetNodeId: args.targetNodeId, + sourceHandle: args.sourceHandle, + targetHandle: args.targetHandle, + }; + localStore.setQuery( + api.edges.list, + { canvasId: args.canvasId }, + [...edgeList, synthetic], + ); + }, + ); + + const removeEdge = useMutation(api.edges.remove).withOptimisticUpdate( + (localStore, args) => { + const edgeList = localStore.getQuery(api.edges.list, { canvasId }); + if (edgeList === undefined) return; + localStore.setQuery( + api.edges.list, + { canvasId }, + edgeList.filter((e) => e._id !== args.edgeId), + ); + }, + ); + + const commitEdgeIntersectionSplit = useCallback( + async ( + middleNodeId: Id<"nodes">, + intersectedEdge: RFEdge, + handles: NonNullable<(typeof NODE_HANDLE_MAP)[string]>, + ) => { + await Promise.all([ + createEdge({ + canvasId, + sourceNodeId: intersectedEdge.source as Id<"nodes">, + targetNodeId: middleNodeId, + sourceHandle: normalizeHandle(intersectedEdge.sourceHandle), + targetHandle: normalizeHandle(handles.target), + }), + createEdge({ + canvasId, + sourceNodeId: middleNodeId, + targetNodeId: intersectedEdge.target as Id<"nodes">, + sourceHandle: normalizeHandle(handles.source), + targetHandle: normalizeHandle(intersectedEdge.targetHandle), + }), + removeEdge({ edgeId: intersectedEdge.id as Id<"edges"> }), + ]); + }, + [canvasId, createEdge, removeEdge], + ); + + const flushPendingEdgeSplit = useCallback( + (clientRequestId: string, realMiddleNodeId: Id<"nodes">) => { + const pending = pendingEdgeSplitByClientRequestRef.current.get( + clientRequestId, + ); + if (!pending) return; + pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); + void Promise.all([ + createEdge({ + canvasId, + sourceNodeId: pending.sourceNodeId, + targetNodeId: realMiddleNodeId, + sourceHandle: pending.intersectedSourceHandle, + targetHandle: pending.middleTargetHandle, + }), + createEdge({ + canvasId, + sourceNodeId: realMiddleNodeId, + targetNodeId: pending.targetNodeId, + sourceHandle: pending.middleSourceHandle, + targetHandle: pending.intersectedTargetHandle, + }), + removeEdge({ edgeId: pending.intersectedEdgeId }), + ]).catch((error: unknown) => { + console.error("[Canvas pending edge split failed]", { + clientRequestId, + realMiddleNodeId, + error: String(error), + }); + }); + }, + [canvasId, createEdge, removeEdge], + ); + + /** Pairing: create kann vor oder nach Drag-Ende fertig sein — was zuerst kommt, speichert; das andere triggert moveNode. Zusätzlich: Kanten-Split erst mit echter Node-ID (nach create). */ + const syncPendingMoveForClientRequest = useCallback( + (clientRequestId: string | undefined, realId?: Id<"nodes">) => { + if (!clientRequestId) return; + + if (realId !== undefined) { + const pending = pendingMoveAfterCreateRef.current.get(clientRequestId); + if (pending) { + pendingMoveAfterCreateRef.current.delete(clientRequestId); + resolvedRealIdByClientRequestRef.current.delete(clientRequestId); + void moveNode({ + nodeId: realId, + positionX: pending.positionX, + positionY: pending.positionY, + }); + flushPendingEdgeSplit(clientRequestId, realId); + return; + } + resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); + flushPendingEdgeSplit(clientRequestId, realId); + return; + } + + const r = resolvedRealIdByClientRequestRef.current.get(clientRequestId); + const p = pendingMoveAfterCreateRef.current.get(clientRequestId); + if (!r || !p) return; + pendingMoveAfterCreateRef.current.delete(clientRequestId); + resolvedRealIdByClientRequestRef.current.delete(clientRequestId); + void moveNode({ + nodeId: r, + positionX: p.positionX, + positionY: p.positionY, + }); + flushPendingEdgeSplit(clientRequestId, r); + }, + [moveNode, flushPendingEdgeSplit], + ); // ─── Lokaler State (für flüssiges Dragging) ─────────────────── const [nodes, setNodes] = useState([]); @@ -1075,23 +1212,36 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { return; } - await createEdge({ - canvasId, - sourceNodeId: intersectedEdge.source as Id<"nodes">, - targetNodeId: node.id as Id<"nodes">, - sourceHandle: normalizeHandle(intersectedEdge.sourceHandle), - targetHandle: normalizeHandle(handles.target), - }); + const optimisticCid = clientRequestIdFromOptimisticNodeId(node.id); + let middleNodeId = node.id as Id<"nodes">; + if (optimisticCid) { + const resolvedMiddle = + resolvedRealIdByClientRequestRef.current.get(optimisticCid); + if (resolvedMiddle) { + middleNodeId = resolvedMiddle; + } else { + pendingEdgeSplitByClientRequestRef.current.set(optimisticCid, { + intersectedEdgeId: intersectedEdge.id as Id<"edges">, + sourceNodeId: intersectedEdge.source as Id<"nodes">, + targetNodeId: intersectedEdge.target as Id<"nodes">, + intersectedSourceHandle: normalizeHandle( + intersectedEdge.sourceHandle, + ), + intersectedTargetHandle: normalizeHandle( + intersectedEdge.targetHandle, + ), + middleSourceHandle: normalizeHandle(handles.source), + middleTargetHandle: normalizeHandle(handles.target), + }); + return; + } + } - 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"> }); + await commitEdgeIntersectionSplit( + middleNodeId, + intersectedEdge, + handles, + ); } catch (error) { console.error("[Canvas edge intersection split failed]", { canvasId, @@ -1110,10 +1260,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { [ batchMoveNodes, canvasId, - createEdge, + commitEdgeIntersectionSplit, edges, moveNode, - removeEdge, setHighlightedIntersectionEdge, syncPendingMoveForClientRequest, ], @@ -1147,28 +1296,20 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { deletingNodeIds.current.add(id); } - // Auto-Reconnect: Für jeden gelöschten Node eingehende und ausgehende Edges verbinden - const edgePromises: Promise[] = []; - for (const node of deletedNodes) { - const incomingEdges = edges.filter((e) => e.target === node.id); - const outgoingEdges = edges.filter((e) => e.source === node.id); - - if (incomingEdges.length > 0 && outgoingEdges.length > 0) { - for (const incoming of incomingEdges) { - for (const outgoing of outgoingEdges) { - edgePromises.push( - createEdge({ - canvasId, - sourceNodeId: incoming.source as Id<"nodes">, - targetNodeId: outgoing.target as Id<"nodes">, - sourceHandle: incoming.sourceHandle ?? undefined, - targetHandle: outgoing.targetHandle ?? undefined, - }), - ); - } - } - } - } + const bridgeCreates = computeBridgeCreatesForDeletedNodes( + deletedNodes, + nodes, + edges, + ); + const edgePromises = bridgeCreates.map((b) => + createEdge({ + canvasId, + sourceNodeId: b.sourceNodeId, + targetNodeId: b.targetNodeId, + sourceHandle: b.sourceHandle, + targetHandle: b.targetHandle, + }), + ); // Batch-Delete + Auto-Reconnect parallel, dann deletingNodeIds aufräumen void Promise.all([ @@ -1195,7 +1336,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { toast.info(title); } }, - [edges, batchRemoveNodes, createEdge, canvasId], + [nodes, edges, batchRemoveNodes, createEdge, canvasId], ); // ─── Edge löschen → Convex ──────────────────────────────────── @@ -1294,6 +1435,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { edges={edges} onlyRenderVisibleElements defaultEdgeOptions={DEFAULT_EDGE_OPTIONS} + connectionLineComponent={CustomConnectionLine} nodeTypes={nodeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} diff --git a/components/canvas/custom-connection-line.tsx b/components/canvas/custom-connection-line.tsx new file mode 100644 index 0000000..ca049a4 --- /dev/null +++ b/components/canvas/custom-connection-line.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { + ConnectionLineType, + getBezierPath, + getSimpleBezierPath, + getSmoothStepPath, + getStraightPath, + type ConnectionLineComponentProps, +} from "@xyflow/react"; +import { connectionLineAccentRgb } from "@/lib/canvas-utils"; + +export default function CustomConnectionLine({ + connectionLineType, + fromNode, + fromHandle, + fromX, + fromY, + toX, + toY, + fromPosition, + toPosition, + connectionStatus, +}: ConnectionLineComponentProps) { + const pathParams = { + sourceX: fromX, + sourceY: fromY, + sourcePosition: fromPosition, + targetX: toX, + targetY: toY, + targetPosition: toPosition, + }; + + let path = ""; + switch (connectionLineType) { + case ConnectionLineType.Bezier: + [path] = getBezierPath(pathParams); + break; + case ConnectionLineType.SimpleBezier: + [path] = getSimpleBezierPath(pathParams); + break; + case ConnectionLineType.Step: + [path] = getSmoothStepPath({ + ...pathParams, + borderRadius: 0, + }); + break; + case ConnectionLineType.SmoothStep: + [path] = getSmoothStepPath(pathParams); + break; + default: + [path] = getStraightPath(pathParams); + } + + const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandle.id); + const opacity = connectionStatus === "invalid" ? 0.45 : 1; + + return ( + + ); +} diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index 3dedb84..2faa8ca 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -1,5 +1,11 @@ -import type { Node as RFNode, Edge as RFEdge } from "@xyflow/react"; -import type { Doc } from "@/convex/_generated/dataModel"; +import { + getConnectedEdges, + getIncomers, + getOutgoers, + type Node as RFNode, + type Edge as RFEdge, +} from "@xyflow/react"; +import type { Doc, Id } from "@/convex/_generated/dataModel"; /** * Convex Node → React Flow Node @@ -100,6 +106,35 @@ const SOURCE_NODE_GLOW_RGB: Record = compare: [100, 116, 139], }; +/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */ +const COMPARE_HANDLE_CONNECTION_RGB: Record< + string, + readonly [number, number, number] +> = { + left: [59, 130, 246], + right: [16, 185, 129], + "compare-out": [100, 116, 139], +}; + +const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [ + 13, 148, 136, +]; + +/** + * RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect). + */ +export function connectionLineAccentRgb( + nodeType: string | undefined, + handleId: string | null | undefined, +): readonly [number, number, number] { + if (nodeType === "compare" && handleId) { + const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId]; + if (byHandle) return byHandle; + } + if (!nodeType) return CONNECTION_LINE_FALLBACK_RGB; + return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB; +} + export type EdgeGlowColorMode = "light" | "dark"; function sourceGlowFilterForNodeType( @@ -253,3 +288,76 @@ export function computeMediaNodeSize( aspectRatio, }; } + +function reconnectEdgeKey(edge: RFEdge): string { + return `${edge.source}\0${edge.target}\0${edge.sourceHandle ?? ""}\0${edge.targetHandle ?? ""}`; +} + +export type BridgeCreatePayload = { + sourceNodeId: Id<"nodes">; + targetNodeId: Id<"nodes">; + sourceHandle?: string; + targetHandle?: string; +}; + +/** + * Nach Löschen mittlerer Knoten: Kanten wie im React-Flow-Beispiel + * „Delete Middle Node“ fortschreiben; nur Kanten zurückgeben, die neu + * angelegt werden müssen (nicht bereits vor dem Löschen vorhanden). + */ +export function computeBridgeCreatesForDeletedNodes( + deletedNodes: RFNode[], + allNodes: RFNode[], + allEdges: RFEdge[], +): BridgeCreatePayload[] { + if (deletedNodes.length === 0) return []; + + const initialPersisted = allEdges.filter((e) => e.className !== "temp"); + const initialKeys = new Set(initialPersisted.map(reconnectEdgeKey)); + + let remainingNodes = [...allNodes]; + let acc = [...initialPersisted]; + + for (const node of deletedNodes) { + const incomers = getIncomers(node, remainingNodes, acc); + const outgoers = getOutgoers(node, remainingNodes, acc); + const connectedEdges = getConnectedEdges([node], acc); + const remainingEdges = acc.filter((e) => !connectedEdges.includes(e)); + + const createdEdges: RFEdge[] = []; + for (const inc of incomers) { + for (const out of outgoers) { + const inEdge = connectedEdges.find( + (e) => e.source === inc.id && e.target === node.id, + ); + const outEdge = connectedEdges.find( + (e) => e.source === node.id && e.target === out.id, + ); + if (!inEdge || !outEdge || inc.id === out.id) continue; + createdEdges.push({ + id: `reconnect-${inc.id}-${out.id}-${node.id}-${createdEdges.length}`, + source: inc.id, + target: out.id, + sourceHandle: inEdge.sourceHandle, + targetHandle: outEdge.targetHandle, + }); + } + } + + acc = [...remainingEdges, ...createdEdges]; + remainingNodes = remainingNodes.filter((rn) => rn.id !== node.id); + } + + const result: BridgeCreatePayload[] = []; + for (const e of acc) { + if (!initialKeys.has(reconnectEdgeKey(e))) { + result.push({ + sourceNodeId: e.source as Id<"nodes">, + targetNodeId: e.target as Id<"nodes">, + sourceHandle: e.sourceHandle ?? undefined, + targetHandle: e.targetHandle ?? undefined, + }); + } + } + return result; +}