diff --git a/components/canvas/__tests__/use-canvas-node-interactions.test.tsx b/components/canvas/__tests__/use-canvas-node-interactions.test.tsx index 9fa3bcf..0fa5a52 100644 --- a/components/canvas/__tests__/use-canvas-node-interactions.test.tsx +++ b/components/canvas/__tests__/use-canvas-node-interactions.test.tsx @@ -32,6 +32,7 @@ type HarnessProps = { runSplitEdgeAtExistingNodeMutation: ReturnType; onInvalidConnection: ReturnType void>>; syncPendingMoveForClientRequest: ReturnType; + resolvedRealIdEntries?: Array<[string, Id<"nodes">]>; }; const latestHandlersRef: { @@ -48,7 +49,9 @@ function HookHarness(props: HarnessProps) { const pendingLocalPositionUntilConvexMatchesRef = useRef(new Map()); const preferLocalPositionNodeIdsRef = useRef(new Set()); const pendingMoveAfterCreateRef = useRef(new Map()); - const resolvedRealIdByClientRequestRef = useRef(new Map()); + const resolvedRealIdByClientRequestRef = useRef( + new Map(props.resolvedRealIdEntries ?? []), + ); const pendingEdgeSplitByClientRequestRef = useRef(new Map()); const handlers = useCanvasNodeInteractions({ @@ -159,4 +162,133 @@ describe("useCanvasNodeInteractions", () => { positionY: 180, }); }); + + it("does not split an edge that already touches a resolved optimistic node", async () => { + const runMoveNodeMutation = vi.fn(async () => undefined); + const runBatchMoveNodesMutation = vi.fn(async () => undefined); + const runResizeNodeMutation = vi.fn(async () => undefined); + const runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined); + const onInvalidConnection = vi.fn<(reason: CanvasConnectionValidationReason) => void>(); + const syncPendingMoveForClientRequest = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + const draggedNode: RFNode = { + id: "optimistic_req-1", + type: "image", + position: { x: 320, y: 180 }, + data: {}, + }; + + await act(async () => { + root?.render( + ]]} + />, + ); + }); + + await act(async () => { + latestHandlersRef.current?.onNodeDrag({} as React.MouseEvent, draggedNode); + latestHandlersRef.current?.onNodeDragStop( + {} as React.MouseEvent, + draggedNode, + [draggedNode], + ); + }); + + expect(runSplitEdgeAtExistingNodeMutation).not.toHaveBeenCalled(); + expect(onInvalidConnection).not.toHaveBeenCalled(); + expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1"); + expect(runMoveNodeMutation).not.toHaveBeenCalled(); + }); + + it("still splits a valid edge with the resolved optimistic node id", async () => { + const runMoveNodeMutation = vi.fn(async () => undefined); + const runBatchMoveNodesMutation = vi.fn(async () => undefined); + const runResizeNodeMutation = vi.fn(async () => undefined); + const runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined); + const onInvalidConnection = vi.fn<(reason: CanvasConnectionValidationReason) => void>(); + const syncPendingMoveForClientRequest = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + const draggedNode: RFNode = { + id: "optimistic_req-2", + type: "video", + position: { x: 320, y: 180 }, + data: {}, + }; + + await act(async () => { + root?.render( + ]]} + />, + ); + }); + + await act(async () => { + latestHandlersRef.current?.onNodeDrag({} as React.MouseEvent, draggedNode); + latestHandlersRef.current?.onNodeDragStop( + {} as React.MouseEvent, + draggedNode, + [draggedNode], + ); + }); + + expect(runSplitEdgeAtExistingNodeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + splitEdgeId: "edge-image-curves", + middleNodeId: "node-real-middle", + splitSourceHandle: undefined, + splitTargetHandle: undefined, + newNodeSourceHandle: undefined, + newNodeTargetHandle: undefined, + positionX: 320, + positionY: 180, + }); + expect(onInvalidConnection).not.toHaveBeenCalled(); + }); }); diff --git a/components/canvas/use-canvas-node-interactions.ts b/components/canvas/use-canvas-node-interactions.ts index c4677db..7275282 100644 --- a/components/canvas/use-canvas-node-interactions.ts +++ b/components/canvas/use-canvas-node-interactions.ts @@ -190,6 +190,27 @@ export function useCanvasNodeInteractions(args: { setHighlightedIntersectionEdge(null); }, [setHighlightedIntersectionEdge]); + const getEffectiveSplitMiddleNode = useCallback( + (node: RFNode): RFNode => { + const clientRequestId = clientRequestIdFromOptimisticNodeId(node.id); + if (!clientRequestId) { + return node; + } + + const resolvedRealId = + resolvedRealIdByClientRequestRef.current.get(clientRequestId); + if (!resolvedRealId) { + return node; + } + + return { + ...node, + id: resolvedRealId, + }; + }, + [resolvedRealIdByClientRequestRef], + ); + const onNodesChange = useCallback( (changes: NodeChange[]) => { for (const change of changes) { @@ -285,7 +306,12 @@ export function useCanvasNodeInteractions(args: { return; } - if (intersectedEdge.source === node.id || intersectedEdge.target === node.id) { + const effectiveMiddleNode = getEffectiveSplitMiddleNode(node); + + if ( + intersectedEdge.source === effectiveMiddleNode.id || + intersectedEdge.target === effectiveMiddleNode.id + ) { clearHighlightedIntersectionEdge(); return; } @@ -299,7 +325,12 @@ export function useCanvasNodeInteractions(args: { overlappedEdgeRef.current = intersectedEdge.id; setHighlightedIntersectionEdge(intersectedEdge.id); }, - [clearHighlightedIntersectionEdge, edges, setHighlightedIntersectionEdge], + [ + clearHighlightedIntersectionEdge, + edges, + getEffectiveSplitMiddleNode, + setHighlightedIntersectionEdge, + ], ); const onNodeDragStop = useCallback( @@ -315,6 +346,7 @@ export function useCanvasNodeInteractions(args: { } try { + const effectivePrimaryNode = getEffectiveSplitMiddleNode(primaryNode); const intersectedEdge = intersectedEdgeId ? edges.find( (edge) => @@ -328,20 +360,20 @@ export function useCanvasNodeInteractions(args: { const splitEligible = intersectedEdge !== undefined && splitHandles !== undefined && - intersectedEdge.source !== primaryNode.id && - intersectedEdge.target !== primaryNode.id && + intersectedEdge.source !== effectivePrimaryNode.id && + intersectedEdge.target !== effectivePrimaryNode.id && hasHandleKey(splitHandles, "source") && hasHandleKey(splitHandles, "target"); const splitValidationError = splitEligible && intersectedEdge - ? validateCanvasEdgeSplit({ - nodes, - edges, - splitEdge: intersectedEdge, - middleNode: primaryNode, - }) - : null; + ? validateCanvasEdgeSplit({ + nodes, + edges, + splitEdge: intersectedEdge, + middleNode: effectivePrimaryNode, + }) + : null; if (splitValidationError) { onInvalidConnection(splitValidationError); @@ -383,7 +415,7 @@ export function useCanvasNodeInteractions(args: { const multiClientRequestId = clientRequestIdFromOptimisticNodeId( primaryNode.id, ); - let middleId = primaryNode.id as Id<"nodes">; + let middleId = effectivePrimaryNode.id as Id<"nodes">; if (multiClientRequestId) { const resolvedId = resolvedRealIdByClientRequestRef.current.get(multiClientRequestId); @@ -518,6 +550,7 @@ export function useCanvasNodeInteractions(args: { onInvalidConnection, pendingEdgeSplitByClientRequestRef, pendingMoveAfterCreateRef, + getEffectiveSplitMiddleNode, resolvedRealIdByClientRequestRef, runBatchMoveNodesMutation, runMoveNodeMutation,