import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; import type { Doc, Id } from "@/convex/_generated/dataModel"; import { convexEdgeToRF, convexEdgeToRFWithSourceGlow, convexNodeDocWithMergedStorageUrl, convexNodeToRF, } from "@/lib/canvas-utils"; import { applyPinnedNodePositionsReadOnly, clientRequestIdFromOptimisticEdgeId, clientRequestIdFromOptimisticNodeId, isOptimisticEdgeId, isOptimisticNodeId, mergeNodesPreservingLocalState, OPTIMISTIC_NODE_PREFIX, positionsMatchPin, rfEdgeConnectionSignature, withResolvedCompareData, } from "./canvas-helpers"; type FlowConvexNodeRecord = Pick, "_id" | "type">; type FlowConvexEdgeRecord = Pick< Doc<"edges">, "_id" | "sourceNodeId" | "targetNodeId" | "sourceHandle" | "targetHandle" >; export function buildIncomingCanvasFlowNodes(args: { convexNodes: Doc<"nodes">[]; storageUrlsById: Record | undefined; previousNodes: RFNode[]; edges: RFEdge[]; }): RFNode[] { const previousDataById = new Map( args.previousNodes.map((node) => [node.id, node.data as Record]), ); const enrichedNodes = args.convexNodes.map((node) => convexNodeDocWithMergedStorageUrl(node, args.storageUrlsById, previousDataById), ); return withResolvedCompareData(enrichedNodes.map(convexNodeToRF), args.edges); } export function inferPendingConnectionNodeHandoff(args: { previousNodes: RFNode[]; incomingConvexNodes: FlowConvexNodeRecord[]; pendingConnectionCreateIds: ReadonlySet; resolvedRealIdByClientRequest: ReadonlyMap>; }): Map> { const nextResolvedRealIdByClientRequest = new Map(args.resolvedRealIdByClientRequest); const unresolvedClientRequestIds: string[] = []; for (const clientRequestId of args.pendingConnectionCreateIds) { if (nextResolvedRealIdByClientRequest.has(clientRequestId)) continue; const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`; const optimisticNodePresent = args.previousNodes.some( (node) => node.id === optimisticNodeId, ); if (optimisticNodePresent) { unresolvedClientRequestIds.push(clientRequestId); } } if (unresolvedClientRequestIds.length !== 1) { return nextResolvedRealIdByClientRequest; } const previousIds = new Set(args.previousNodes.map((node) => node.id)); const newlyAppearedIncomingRealNodeIds = args.incomingConvexNodes .map((node) => node._id as string) .filter((id) => !isOptimisticNodeId(id)) .filter((id) => !previousIds.has(id)); if (newlyAppearedIncomingRealNodeIds.length !== 1) { return nextResolvedRealIdByClientRequest; } nextResolvedRealIdByClientRequest.set( unresolvedClientRequestIds[0]!, newlyAppearedIncomingRealNodeIds[0] as Id<"nodes">, ); return nextResolvedRealIdByClientRequest; } export function reconcileCanvasFlowEdges(args: { previousEdges: RFEdge[]; convexEdges: FlowConvexEdgeRecord[]; convexNodes?: FlowConvexNodeRecord[]; previousConvexNodeIdsSnapshot: ReadonlySet; pendingRemovedEdgeIds: ReadonlySet; pendingConnectionCreateIds: ReadonlySet; resolvedRealIdByClientRequest: ReadonlyMap>; localNodeIds: ReadonlySet; isAnyNodeDragging: boolean; colorMode: "light" | "dark"; }): { edges: RFEdge[]; nextConvexNodeIdsSnapshot: Set; inferredRealIdByClientRequest: Map>; settledPendingConnectionCreateIds: string[]; } { const currentConvexIdList = args.convexNodes?.map((node) => node._id as string) ?? []; const currentConvexIdSet = new Set(currentConvexIdList); const newlyAppearedIds = currentConvexIdList.filter( (id) => !args.previousConvexNodeIdsSnapshot.has(id), ); const tempEdges = args.previousEdges.filter((edge) => edge.className === "temp"); const sourceTypeByNodeId = args.convexNodes ? new Map(args.convexNodes.map((node) => [node._id as string, node.type as string])) : undefined; const mapped = args.convexEdges .filter((edge) => !args.pendingRemovedEdgeIds.has(edge._id as string)) .map((edge) => sourceTypeByNodeId ? convexEdgeToRFWithSourceGlow( edge as Doc<"edges">, sourceTypeByNodeId.get(edge.sourceNodeId), args.colorMode, ) : convexEdgeToRF(edge as Doc<"edges">), ); const mappedSignatures = new Set(mapped.map(rfEdgeConnectionSignature)); const convexNodeIds = args.convexNodes ? new Set(args.convexNodes.map((node) => node._id as string)) : null; const inferredRealIdByClientRequest = new Map(args.resolvedRealIdByClientRequest); const resolveEndpoint = (nodeId: string): string => { if (!isOptimisticNodeId(nodeId)) return nodeId; const clientRequestId = clientRequestIdFromOptimisticNodeId(nodeId); if (!clientRequestId) return nodeId; if (args.isAnyNodeDragging && args.localNodeIds.has(nodeId)) { return nodeId; } const realId = inferredRealIdByClientRequest.get(clientRequestId); return realId !== undefined ? (realId as string) : nodeId; }; const resolveEndpointWithInference = (nodeId: string, edge: RFEdge): string => { const baseNodeId = resolveEndpoint(nodeId); if (!isOptimisticNodeId(baseNodeId)) return baseNodeId; if (args.isAnyNodeDragging) return baseNodeId; const nodeClientRequestId = clientRequestIdFromOptimisticNodeId(baseNodeId); if (nodeClientRequestId === null) return baseNodeId; const edgeClientRequestId = clientRequestIdFromOptimisticEdgeId(edge.id); if (edgeClientRequestId === null || edgeClientRequestId !== nodeClientRequestId) { return baseNodeId; } if (!args.pendingConnectionCreateIds.has(nodeClientRequestId)) { return baseNodeId; } if (newlyAppearedIds.length !== 1) { return baseNodeId; } const inferredRealId = newlyAppearedIds[0] as Id<"nodes">; inferredRealIdByClientRequest.set(nodeClientRequestId, inferredRealId); return inferredRealId; }; const endpointUsable = (nodeId: string): boolean => { if (args.isAnyNodeDragging && args.localNodeIds.has(nodeId)) { return true; } const resolvedNodeId = resolveEndpoint(nodeId); return Boolean(convexNodeIds?.has(resolvedNodeId) || convexNodeIds?.has(nodeId)); }; const optimisticEndpointHasPendingCreate = (nodeId: string): boolean => { if (!isOptimisticNodeId(nodeId)) return false; const clientRequestId = clientRequestIdFromOptimisticNodeId(nodeId); return clientRequestId !== null && args.pendingConnectionCreateIds.has(clientRequestId); }; const shouldCarryOptimisticEdge = (original: RFEdge, remapped: RFEdge): boolean => { if (mappedSignatures.has(rfEdgeConnectionSignature(remapped))) { return false; } const sourceOk = endpointUsable(remapped.source); const targetOk = endpointUsable(remapped.target); if (sourceOk && targetOk) return true; if (!args.pendingConnectionCreateIds.size) { return false; } if (sourceOk && optimisticEndpointHasPendingCreate(original.target)) { return true; } if (targetOk && optimisticEndpointHasPendingCreate(original.source)) { return true; } return false; }; const carriedOptimistic: RFEdge[] = []; for (const edge of args.previousEdges) { if (edge.className === "temp") continue; if (!isOptimisticEdgeId(edge.id)) continue; const remappedEdge: RFEdge = { ...edge, source: resolveEndpointWithInference(edge.source, edge), target: resolveEndpointWithInference(edge.target, edge), }; if (!shouldCarryOptimisticEdge(edge, remappedEdge)) continue; carriedOptimistic.push(remappedEdge); } const settledPendingConnectionCreateIds: string[] = []; for (const clientRequestId of args.pendingConnectionCreateIds) { const realId = inferredRealIdByClientRequest.get(clientRequestId); if (realId === undefined) continue; const nodePresent = args.convexNodes?.some((node) => node._id === realId) ?? false; const edgeTouchesNewNode = args.convexEdges.some( (edge) => edge.sourceNodeId === realId || edge.targetNodeId === realId, ); if (nodePresent && edgeTouchesNewNode) { settledPendingConnectionCreateIds.push(clientRequestId); } } return { edges: [...mapped, ...carriedOptimistic, ...tempEdges], nextConvexNodeIdsSnapshot: args.convexNodes ? currentConvexIdSet : new Set(args.previousConvexNodeIdsSnapshot), inferredRealIdByClientRequest, settledPendingConnectionCreateIds, }; } function applyLocalPositionPins(args: { nodes: RFNode[]; pendingLocalPositionPins: ReadonlyMap; }): { nodes: RFNode[]; nextPendingLocalPositionPins: Map; } { const nextPendingLocalPositionPins = new Map(args.pendingLocalPositionPins); const nodes = args.nodes.map((node) => { const pin = nextPendingLocalPositionPins.get(node.id); if (!pin) return node; if (positionsMatchPin(node.position, pin)) { nextPendingLocalPositionPins.delete(node.id); return node; } return { ...node, position: { x: pin.x, y: pin.y }, }; }); return { nodes, nextPendingLocalPositionPins, }; } export function reconcileCanvasFlowNodes(args: { previousNodes: RFNode[]; incomingNodes: RFNode[]; convexNodes: FlowConvexNodeRecord[]; deletingNodeIds: ReadonlySet; resolvedRealIdByClientRequest: ReadonlyMap>; pendingConnectionCreateIds: ReadonlySet; preferLocalPositionNodeIds: ReadonlySet; pendingLocalPositionPins: ReadonlyMap; pendingMovePins: ReadonlyMap; }): { nodes: RFNode[]; inferredRealIdByClientRequest: Map>; nextPendingLocalPositionPins: Map; clearedPreferLocalPositionNodeIds: string[]; } { const inferredRealIdByClientRequest = inferPendingConnectionNodeHandoff({ previousNodes: args.previousNodes, incomingConvexNodes: args.convexNodes, pendingConnectionCreateIds: args.pendingConnectionCreateIds, resolvedRealIdByClientRequest: args.resolvedRealIdByClientRequest, }); const filteredIncomingNodes = args.deletingNodeIds.size ? args.incomingNodes.filter((node) => !args.deletingNodeIds.has(node.id)) : args.incomingNodes; const mergedNodes = mergeNodesPreservingLocalState( args.previousNodes, filteredIncomingNodes, inferredRealIdByClientRequest, args.preferLocalPositionNodeIds, ); const pinnedNodes = applyLocalPositionPins({ nodes: mergedNodes, pendingLocalPositionPins: args.pendingLocalPositionPins, }); const nodes = applyPinnedNodePositionsReadOnly( pinnedNodes.nodes, args.pendingMovePins, ); const incomingById = new Map(filteredIncomingNodes.map((node) => [node.id, node])); const clearedPreferLocalPositionNodeIds: string[] = []; for (const node of nodes) { if (!args.preferLocalPositionNodeIds.has(node.id)) continue; const incomingNode = incomingById.get(node.id); if (!incomingNode) continue; if (positionsMatchPin(node.position, incomingNode.position)) { clearedPreferLocalPositionNodeIds.push(node.id); } } return { nodes, inferredRealIdByClientRequest, nextPendingLocalPositionPins: pinnedNodes.nextPendingLocalPositionPins, clearedPreferLocalPositionNodeIds, }; }