From dee10405d2fb51387aca6b732a1a3f5db4ef6aa3 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 3 Apr 2026 22:18:42 +0200 Subject: [PATCH] refactor(canvas): extract node interaction hook --- .../use-canvas-node-interactions.test.tsx | 361 ++++++++++++ components/canvas/canvas.tsx | 404 +------------- .../canvas/use-canvas-node-interactions.ts | 513 ++++++++++++++++++ vitest.config.ts | 1 + 4 files changed, 900 insertions(+), 379 deletions(-) create mode 100644 components/canvas/__tests__/use-canvas-node-interactions.test.tsx create mode 100644 components/canvas/use-canvas-node-interactions.ts diff --git a/components/canvas/__tests__/use-canvas-node-interactions.test.tsx b/components/canvas/__tests__/use-canvas-node-interactions.test.tsx new file mode 100644 index 0000000..ae6ae8a --- /dev/null +++ b/components/canvas/__tests__/use-canvas-node-interactions.test.tsx @@ -0,0 +1,361 @@ +// @vitest-environment jsdom + +import React, { act, useEffect, useRef, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Id } from "@/convex/_generated/dataModel"; +import { useCanvasNodeInteractions } from "@/components/canvas/use-canvas-node-interactions"; + +const { + getNodeCenterClientPositionMock, + getIntersectedEdgeIdMock, +} = vi.hoisted(() => ({ + getNodeCenterClientPositionMock: vi.fn(), + getIntersectedEdgeIdMock: vi.fn(), +})); + +vi.mock("@/components/canvas/canvas-helpers", async () => { + const actual = await vi.importActual( + "@/components/canvas/canvas-helpers", + ); + + return { + ...actual, + getNodeCenterClientPosition: getNodeCenterClientPositionMock, + getIntersectedEdgeId: getIntersectedEdgeIdMock, + }; +}); + +const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">; +const asEdgeId = (id: string): Id<"edges"> => id as Id<"edges">; + +type PendingEdgeSplitState = { + intersectedEdgeId: Id<"edges">; + sourceNodeId: Id<"nodes">; + targetNodeId: Id<"nodes">; + intersectedSourceHandle?: string; + intersectedTargetHandle?: string; + middleSourceHandle?: string; + middleTargetHandle?: string; + positionX: number; + positionY: number; +}; + +type HarnessProps = { + initialNodes: RFNode[]; + initialEdges: RFEdge[]; + isDraggingRef?: { current: boolean }; + isResizingRef?: { current: boolean }; + pendingLocalPositionUntilConvexMatchesRef?: { + current: Map; + }; + preferLocalPositionNodeIdsRef?: { current: Set }; + pendingMoveAfterCreateRef?: { + current: Map; + }; + resolvedRealIdByClientRequestRef?: { + current: Map>; + }; + pendingEdgeSplitByClientRequestRef?: { + current: Map; + }; + runResizeNodeMutation?: ReturnType; + runMoveNodeMutation?: ReturnType; + runBatchMoveNodesMutation?: ReturnType; + runSplitEdgeAtExistingNodeMutation?: ReturnType; + syncPendingMoveForClientRequest?: ReturnType; +}; + +const latestHarnessRef: { + current: + | { + nodes: RFNode[]; + edges: RFEdge[]; + onNodesChange: ReturnType["onNodesChange"]; + onNodeDragStart: ReturnType["onNodeDragStart"]; + onNodeDrag: ReturnType["onNodeDrag"]; + onNodeDragStop: ReturnType["onNodeDragStop"]; + clearHighlightedIntersectionEdge: () => void; + } + | null; +} = { current: null }; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function HookHarness(props: HarnessProps) { + const [nodes, setNodes] = useState(props.initialNodes); + const [edges, setEdges] = useState(props.initialEdges); + const pendingLocalPositionUntilConvexMatchesRef = + props.pendingLocalPositionUntilConvexMatchesRef ?? { + current: new Map(), + }; + const preferLocalPositionNodeIdsRef = props.preferLocalPositionNodeIdsRef ?? { + current: new Set(), + }; + const pendingMoveAfterCreateRef = props.pendingMoveAfterCreateRef ?? { + current: new Map(), + }; + const resolvedRealIdByClientRequestRef = + props.resolvedRealIdByClientRequestRef ?? { + current: new Map>(), + }; + const pendingEdgeSplitByClientRequestRef = + props.pendingEdgeSplitByClientRequestRef ?? { + current: new Map(), + }; + const isDraggingRef = props.isDraggingRef ?? { current: false }; + const isResizingRef = props.isResizingRef ?? { current: false }; + const nodesRef = useRef(nodes); + + useEffect(() => { + nodesRef.current = nodes; + }, [nodes]); + + const runResizeNodeMutation = props.runResizeNodeMutation ?? vi.fn(() => Promise.resolve()); + const runMoveNodeMutation = props.runMoveNodeMutation ?? vi.fn(() => Promise.resolve()); + const runBatchMoveNodesMutation = + props.runBatchMoveNodesMutation ?? vi.fn(() => Promise.resolve()); + const runSplitEdgeAtExistingNodeMutation = + props.runSplitEdgeAtExistingNodeMutation ?? vi.fn(() => Promise.resolve()); + const syncPendingMoveForClientRequest = + props.syncPendingMoveForClientRequest ?? vi.fn(() => Promise.resolve()); + + const interactions = useCanvasNodeInteractions({ + canvasId: asCanvasId("canvas-1"), + edges, + setNodes, + setEdges, + refs: { + isDragging: isDraggingRef, + isResizing: isResizingRef, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + pendingMoveAfterCreateRef, + resolvedRealIdByClientRequestRef, + pendingEdgeSplitByClientRequestRef, + }, + runResizeNodeMutation, + runMoveNodeMutation, + runBatchMoveNodesMutation, + runSplitEdgeAtExistingNodeMutation, + syncPendingMoveForClientRequest, + }); + + useEffect(() => { + latestHarnessRef.current = { + nodes, + edges, + onNodesChange: interactions.onNodesChange, + onNodeDragStart: interactions.onNodeDragStart, + onNodeDrag: interactions.onNodeDrag, + onNodeDragStop: interactions.onNodeDragStop, + clearHighlightedIntersectionEdge: interactions.clearHighlightedIntersectionEdge, + }; + }, [edges, interactions, nodes]); + + return null; +} + +describe("useCanvasNodeInteractions", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(async () => { + latestHarnessRef.current = null; + getNodeCenterClientPositionMock.mockReset(); + getIntersectedEdgeIdMock.mockReset(); + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + }); + + it("queues resize persistence on completed dimension changes", async () => { + const isResizingRef = { current: false }; + const pendingLocalPositionUntilConvexMatchesRef = { + current: new Map([["node-1", { x: 10, y: 20 }]]), + }; + const preferLocalPositionNodeIdsRef = { current: new Set() }; + const runResizeNodeMutation = vi.fn(() => Promise.resolve()); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement(HookHarness, { + initialNodes: [ + { + id: "node-1", + type: "text", + position: { x: 0, y: 0 }, + style: { width: 240, height: 100 }, + data: {}, + }, + ], + initialEdges: [], + isResizingRef, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + runResizeNodeMutation, + }), + ); + }); + + await act(async () => { + latestHarnessRef.current?.onNodesChange([ + { + id: "node-1", + type: "position", + position: { x: 40, y: 50 }, + dragging: false, + }, + { + id: "node-1", + type: "dimensions", + dimensions: { width: 320, height: 180 }, + resizing: false, + setAttributes: true, + }, + ]); + await Promise.resolve(); + }); + + expect(isResizingRef.current).toBe(false); + expect(pendingLocalPositionUntilConvexMatchesRef.current.has("node-1")).toBe(false); + expect(preferLocalPositionNodeIdsRef.current.has("node-1")).toBe(true); + expect(runResizeNodeMutation).toHaveBeenCalledWith({ + nodeId: "node-1", + width: 320, + height: 180, + }); + }); + + it("highlights intersected edges during drag and restores styles when cleared", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement(HookHarness, { + initialNodes: [ + { + id: "node-1", + type: "image", + position: { x: 100, y: 100 }, + data: {}, + }, + ], + initialEdges: [ + { + id: "edge-1", + source: "source-1", + target: "target-1", + style: { stroke: "#123456" }, + }, + ], + }), + ); + }); + + getNodeCenterClientPositionMock.mockReturnValue({ x: 200, y: 200 }); + getIntersectedEdgeIdMock.mockReturnValue("edge-1"); + + await act(async () => { + latestHarnessRef.current?.onNodeDrag( + new MouseEvent("mousemove") as unknown as React.MouseEvent, + latestHarnessRef.current?.nodes[0] as RFNode, + ); + }); + + expect(latestHarnessRef.current?.edges[0]?.style).toMatchObject({ + stroke: "var(--xy-edge-stroke)", + strokeWidth: 2, + }); + + await act(async () => { + latestHarnessRef.current?.clearHighlightedIntersectionEdge(); + }); + + expect(latestHarnessRef.current?.edges[0]?.style).toEqual({ stroke: "#123456" }); + }); + + it("splits the intersected edge when a draggable node is dropped onto it", async () => { + const isDraggingRef = { current: false }; + const runSplitEdgeAtExistingNodeMutation = vi.fn(() => Promise.resolve()); + const runMoveNodeMutation = vi.fn(() => Promise.resolve()); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement(HookHarness, { + initialNodes: [ + { + id: "node-middle", + type: "image", + position: { x: 280, y: 160 }, + data: {}, + }, + ], + initialEdges: [ + { + id: "edge-1", + source: "node-a", + target: "node-b", + }, + ], + isDraggingRef, + runSplitEdgeAtExistingNodeMutation, + runMoveNodeMutation, + }), + ); + }); + + getNodeCenterClientPositionMock.mockReturnValue({ x: 200, y: 200 }); + getIntersectedEdgeIdMock.mockReturnValue("edge-1"); + + await act(async () => { + latestHarnessRef.current?.onNodeDragStart( + new MouseEvent("mousedown") as unknown as React.MouseEvent, + latestHarnessRef.current?.nodes[0] as RFNode, + latestHarnessRef.current?.nodes ?? [], + ); + latestHarnessRef.current?.onNodeDrag( + new MouseEvent("mousemove") as unknown as React.MouseEvent, + latestHarnessRef.current?.nodes[0] as RFNode, + ); + latestHarnessRef.current?.onNodeDragStop( + new MouseEvent("mouseup") as unknown as React.MouseEvent, + latestHarnessRef.current?.nodes[0] as RFNode, + latestHarnessRef.current?.nodes ?? [], + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(runSplitEdgeAtExistingNodeMutation).toHaveBeenCalledWith({ + canvasId: asCanvasId("canvas-1"), + splitEdgeId: asEdgeId("edge-1"), + middleNodeId: "node-middle", + splitSourceHandle: undefined, + splitTargetHandle: undefined, + newNodeSourceHandle: undefined, + newNodeTargetHandle: undefined, + positionX: 280, + positionY: 160, + }); + expect(runMoveNodeMutation).not.toHaveBeenCalled(); + expect(isDraggingRef.current).toBe(false); + }); +}); diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 072d755..b164e7c 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -6,7 +6,6 @@ import { useMemo, useRef, useState, - type MouseEvent as ReactMouseEvent, } from "react"; import { useTheme } from "next-themes"; import { useTranslations } from "next-intl"; @@ -16,12 +15,10 @@ import { Background, Controls, MiniMap, - applyNodeChanges, applyEdgeChanges, useReactFlow, type Node as RFNode, type Edge as RFEdge, - type NodeChange, type EdgeChange, type Connection, type OnConnectEnd, @@ -69,27 +66,20 @@ import CustomConnectionLine from "@/components/canvas/custom-connection-line"; import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; import { CANVAS_MIN_ZOOM, - clientRequestIdFromOptimisticNodeId, DEFAULT_EDGE_OPTIONS, - EDGE_INTERSECTION_HIGHLIGHT_STYLE, getConnectEndClientPoint, getMiniMapNodeColor, getMiniMapNodeStrokeColor, - getNodeCenterClientPosition, - getIntersectedEdgeId, getPendingRemovedEdgeIdsFromLocalOps, getPendingMovePinsFromLocalOps, - hasHandleKey, isEditableKeyboardTarget, - isOptimisticEdgeId, isOptimisticNodeId, - normalizeHandle, withResolvedCompareData, } from "./canvas-helpers"; -import { adjustNodeDimensionChanges } from "./canvas-node-change-helpers"; import { useGenerationFailureWarnings } from "./canvas-generation-failures"; import { useCanvasDeleteHandlers } from "./canvas-delete-handlers"; import { getImageDimensions } from "./canvas-media-utils"; +import { useCanvasNodeInteractions } from "./use-canvas-node-interactions"; import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import { useCanvasScissors } from "./canvas-scissors"; import { CanvasSyncProvider } from "./canvas-sync-context"; @@ -315,11 +305,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // Delete Edge on Drop const edgeReconnectSuccessful = useRef(true); const isReconnectDragActiveRef = useRef(false); - const overlappedEdgeRef = useRef(null); - const highlightedEdgeRef = useRef(null); - const highlightedEdgeOriginalStyleRef = useRef( - undefined, - ); useGenerationFailureWarnings(t, convexNodes); const { onEdgeClickScissors, onScissorsFlowPointerDownCapture } = useCanvasScissors({ @@ -386,64 +371,31 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { setNodes((nds) => withResolvedCompareData(nds, edges)); }, [edges]); - // ─── Future hook seam: node interactions ────────────────────── - const onNodesChange = useCallback( - (changes: NodeChange[]) => { - for (const c of changes) { - if (c.type === "dimensions") { - if (c.resizing === true) { - isResizing.current = true; - } else if (c.resizing === false) { - isResizing.current = false; - } - } - } - - const removedIds = new Set(); - for (const c of changes) { - if (c.type === "remove") { - removedIds.add(c.id); - } - } - - setNodes((nds) => { - for (const c of changes) { - if (c.type === "position" && "id" in c) { - pendingLocalPositionUntilConvexMatchesRef.current.delete(c.id); - preferLocalPositionNodeIdsRef.current.add(c.id); - } - } - - const adjustedChanges = adjustNodeDimensionChanges(changes, nds); - - const nextNodes = applyNodeChanges(adjustedChanges, nds); - - for (const change of adjustedChanges) { - if (change.type !== "dimensions") continue; - if (!change.dimensions) continue; - if (removedIds.has(change.id)) continue; - const prevNode = nds.find((node) => node.id === change.id); - const nextNode = nextNodes.find((node) => node.id === change.id); - void prevNode; - void nextNode; - if (change.resizing !== false) continue; - - void runResizeNodeMutation({ - nodeId: change.id as Id<"nodes">, - width: change.dimensions.width, - height: change.dimensions.height, - }).catch((error: unknown) => { - if (process.env.NODE_ENV !== "production") { - console.warn("[Canvas] resizeNode failed", error); - } - }); - } - - return nextNodes; - }); + const { + onNodesChange, + onNodeDragStart, + onNodeDrag, + onNodeDragStop, + } = useCanvasNodeInteractions({ + canvasId, + edges, + setNodes, + setEdges, + refs: { + isDragging, + isResizing, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + pendingMoveAfterCreateRef, + resolvedRealIdByClientRequestRef, + pendingEdgeSplitByClientRequestRef, }, - [pendingLocalPositionUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, runResizeNodeMutation], - ); + runResizeNodeMutation, + runMoveNodeMutation, + runBatchMoveNodesMutation, + runSplitEdgeAtExistingNodeMutation, + syncPendingMoveForClientRequest, + }); const onEdgesChange = useCallback((changes: EdgeChange[]) => { setEdges((eds) => applyEdgeChanges(changes, eds)); @@ -454,312 +406,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { console.error("[ReactFlow error]", { canvasId, id, error }); }, [canvasId]); - 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" && - !isOptimisticEdgeId(edge.id), - ); - 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 / drag / drag stop stay together for the future node interaction hook. - const onNodeDragStart = useCallback( - (_event: ReactMouseEvent, _node: RFNode, draggedNodes: RFNode[]) => { - isDragging.current = true; - overlappedEdgeRef.current = null; - setHighlightedIntersectionEdge(null); - for (const n of draggedNodes) { - pendingLocalPositionUntilConvexMatchesRef.current.delete(n.id); - } - }, - [pendingLocalPositionUntilConvexMatchesRef, setHighlightedIntersectionEdge], - ); - - const onNodeDragStop = useCallback( - (_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => { - const primaryNode = (node as RFNode | undefined) ?? draggedNodes[0]; - const intersectedEdgeId = overlappedEdgeRef.current; - - void (async () => { - if (!primaryNode) { - overlappedEdgeRef.current = null; - setHighlightedIntersectionEdge(null); - isDragging.current = false; - return; - } - try { - const intersectedEdge = intersectedEdgeId - ? edges.find( - (edge) => - edge.id === intersectedEdgeId && - edge.className !== "temp" && - !isOptimisticEdgeId(edge.id), - ) - : undefined; - - const splitHandles = NODE_HANDLE_MAP[primaryNode.type ?? ""]; - const splitEligible = - intersectedEdge !== undefined && - splitHandles !== undefined && - intersectedEdge.source !== primaryNode.id && - intersectedEdge.target !== primaryNode.id && - hasHandleKey(splitHandles, "source") && - hasHandleKey(splitHandles, "target"); - - if (draggedNodes.length > 1) { - for (const n of draggedNodes) { - const cid = clientRequestIdFromOptimisticNodeId(n.id); - if (cid) { - pendingMoveAfterCreateRef.current.set(cid, { - positionX: n.position.x, - positionY: n.position.y, - }); - await syncPendingMoveForClientRequest(cid); - } - } - const realMoves = draggedNodes.filter((n) => !isOptimisticNodeId(n.id)); - if (realMoves.length > 0) { - await runBatchMoveNodesMutation({ - moves: realMoves.map((n) => ({ - nodeId: n.id as Id<"nodes">, - positionX: n.position.x, - positionY: n.position.y, - })), - }); - } - - if (!splitEligible || !intersectedEdge) { - return; - } - - const multiCid = clientRequestIdFromOptimisticNodeId(primaryNode.id); - let middleId = primaryNode.id as Id<"nodes">; - if (multiCid) { - const r = resolvedRealIdByClientRequestRef.current.get(multiCid); - if (!r) { - pendingEdgeSplitByClientRequestRef.current.set(multiCid, { - 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(splitHandles.source), - middleTargetHandle: normalizeHandle(splitHandles.target), - positionX: primaryNode.position.x, - positionY: primaryNode.position.y, - }); - return; - } - middleId = r; - } - - await runSplitEdgeAtExistingNodeMutation({ - canvasId, - splitEdgeId: intersectedEdge.id as Id<"edges">, - middleNodeId: middleId, - splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle), - splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle), - newNodeSourceHandle: normalizeHandle(splitHandles.source), - newNodeTargetHandle: normalizeHandle(splitHandles.target), - }); - return; - } - - if (!splitEligible || !intersectedEdge) { - const cidSingle = clientRequestIdFromOptimisticNodeId(primaryNode.id); - if (cidSingle) { - pendingMoveAfterCreateRef.current.set(cidSingle, { - positionX: primaryNode.position.x, - positionY: primaryNode.position.y, - }); - await syncPendingMoveForClientRequest(cidSingle); - } else { - await runMoveNodeMutation({ - nodeId: primaryNode.id as Id<"nodes">, - positionX: primaryNode.position.x, - positionY: primaryNode.position.y, - }); - } - return; - } - - const singleCid = clientRequestIdFromOptimisticNodeId(primaryNode.id); - if (singleCid) { - const resolvedSingle = - resolvedRealIdByClientRequestRef.current.get(singleCid); - if (!resolvedSingle) { - pendingMoveAfterCreateRef.current.set(singleCid, { - positionX: primaryNode.position.x, - positionY: primaryNode.position.y, - }); - pendingEdgeSplitByClientRequestRef.current.set(singleCid, { - 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(splitHandles.source), - middleTargetHandle: normalizeHandle(splitHandles.target), - positionX: primaryNode.position.x, - positionY: primaryNode.position.y, - }); - await syncPendingMoveForClientRequest(singleCid); - return; - } - await runSplitEdgeAtExistingNodeMutation({ - canvasId, - splitEdgeId: intersectedEdge.id as Id<"edges">, - middleNodeId: resolvedSingle, - splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle), - splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle), - newNodeSourceHandle: normalizeHandle(splitHandles.source), - newNodeTargetHandle: normalizeHandle(splitHandles.target), - positionX: primaryNode.position.x, - positionY: primaryNode.position.y, - }); - pendingMoveAfterCreateRef.current.delete(singleCid); - return; - } - - await runSplitEdgeAtExistingNodeMutation({ - canvasId, - splitEdgeId: intersectedEdge.id as Id<"edges">, - middleNodeId: primaryNode.id as Id<"nodes">, - splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle), - splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle), - newNodeSourceHandle: normalizeHandle(splitHandles.source), - newNodeTargetHandle: normalizeHandle(splitHandles.target), - positionX: primaryNode.position.x, - positionY: primaryNode.position.y, - }); - } catch (error) { - console.error("[Canvas edge intersection split failed]", { - canvasId, - nodeId: primaryNode?.id ?? null, - nodeType: primaryNode?.type ?? null, - intersectedEdgeId, - error: String(error), - }); - } finally { - overlappedEdgeRef.current = null; - setHighlightedIntersectionEdge(null); - isDragging.current = false; - } - })(); - }, - [ - canvasId, - edges, - runBatchMoveNodesMutation, - runMoveNodeMutation, - pendingEdgeSplitByClientRequestRef, - pendingMoveAfterCreateRef, - resolvedRealIdByClientRequestRef, - setHighlightedIntersectionEdge, - runSplitEdgeAtExistingNodeMutation, - syncPendingMoveForClientRequest, - ], - ); - // ─── Future hook seam: connections ──────────────────────────── const onConnect = useCallback( (connection: Connection) => { diff --git a/components/canvas/use-canvas-node-interactions.ts b/components/canvas/use-canvas-node-interactions.ts new file mode 100644 index 0000000..b8ded51 --- /dev/null +++ b/components/canvas/use-canvas-node-interactions.ts @@ -0,0 +1,513 @@ +import { + useCallback, + useRef, + type Dispatch, + type MutableRefObject, + type SetStateAction, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { + applyNodeChanges, + type Edge as RFEdge, + type Node as RFNode, + type NodeChange, +} from "@xyflow/react"; + +import type { Id } from "@/convex/_generated/dataModel"; +import { NODE_HANDLE_MAP } from "@/lib/canvas-utils"; +import { + clientRequestIdFromOptimisticNodeId, + EDGE_INTERSECTION_HIGHLIGHT_STYLE, + getIntersectedEdgeId, + getNodeCenterClientPosition, + hasHandleKey, + isOptimisticEdgeId, + isOptimisticNodeId, + normalizeHandle, +} from "./canvas-helpers"; +import { adjustNodeDimensionChanges } from "./canvas-node-change-helpers"; + +type PositionPin = { x: number; y: number }; +type MovePin = { positionX: number; positionY: number }; +type PendingEdgeSplit = { + intersectedEdgeId: Id<"edges">; + sourceNodeId: Id<"nodes">; + targetNodeId: Id<"nodes">; + intersectedSourceHandle?: string; + intersectedTargetHandle?: string; + middleSourceHandle?: string; + middleTargetHandle?: string; + positionX: number; + positionY: number; +}; + +type RunResizeNodeMutation = (args: { + nodeId: Id<"nodes">; + width: number; + height: number; +}) => Promise; + +type RunMoveNodeMutation = (args: { + nodeId: Id<"nodes">; + positionX: number; + positionY: number; +}) => Promise; + +type RunBatchMoveNodesMutation = (args: { + moves: { + nodeId: Id<"nodes">; + positionX: number; + positionY: number; + }[]; +}) => Promise; + +type RunSplitEdgeAtExistingNodeMutation = (args: { + canvasId: Id<"canvases">; + splitEdgeId: Id<"edges">; + middleNodeId: Id<"nodes">; + splitSourceHandle?: string; + splitTargetHandle?: string; + newNodeSourceHandle?: string; + newNodeTargetHandle?: string; + positionX?: number; + positionY?: number; +}) => Promise; + +type CanvasNodeInteractionRefs = { + isDragging: MutableRefObject; + isResizing: MutableRefObject; + pendingLocalPositionUntilConvexMatchesRef: MutableRefObject>; + preferLocalPositionNodeIdsRef: MutableRefObject>; + pendingMoveAfterCreateRef: MutableRefObject>; + resolvedRealIdByClientRequestRef: MutableRefObject>>; + pendingEdgeSplitByClientRequestRef: MutableRefObject< + Map + >; +}; + +export function useCanvasNodeInteractions(args: { + canvasId: Id<"canvases">; + edges: RFEdge[]; + setNodes: Dispatch>; + setEdges: Dispatch>; + refs: CanvasNodeInteractionRefs; + runResizeNodeMutation: RunResizeNodeMutation; + runMoveNodeMutation: RunMoveNodeMutation; + runBatchMoveNodesMutation: RunBatchMoveNodesMutation; + runSplitEdgeAtExistingNodeMutation: RunSplitEdgeAtExistingNodeMutation; + syncPendingMoveForClientRequest: ( + clientRequestId: string, + realId?: Id<"nodes">, + ) => Promise; +}) { + const { + canvasId, + edges, + setNodes, + setEdges, + runResizeNodeMutation, + runMoveNodeMutation, + runBatchMoveNodesMutation, + runSplitEdgeAtExistingNodeMutation, + syncPendingMoveForClientRequest, + } = args; + const { + isDragging, + isResizing, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + pendingMoveAfterCreateRef, + resolvedRealIdByClientRequestRef, + pendingEdgeSplitByClientRequestRef, + } = args.refs; + + const overlappedEdgeRef = useRef(null); + const highlightedEdgeRef = useRef(null); + const highlightedEdgeOriginalStyleRef = useRef( + undefined, + ); + + 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; + }, + [setEdges], + ); + + const clearHighlightedIntersectionEdge = useCallback(() => { + overlappedEdgeRef.current = null; + setHighlightedIntersectionEdge(null); + }, [setHighlightedIntersectionEdge]); + + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + for (const change of changes) { + if (change.type === "dimensions") { + if (change.resizing === true) { + isResizing.current = true; + } else if (change.resizing === false) { + isResizing.current = false; + } + } + } + + const removedIds = new Set(); + for (const change of changes) { + if (change.type === "remove") { + removedIds.add(change.id); + } + } + + setNodes((currentNodes) => { + for (const change of changes) { + if (change.type === "position" && "id" in change) { + pendingLocalPositionUntilConvexMatchesRef.current.delete(change.id); + preferLocalPositionNodeIdsRef.current.add(change.id); + } + } + + const adjustedChanges = adjustNodeDimensionChanges(changes, currentNodes); + const nextNodes = applyNodeChanges(adjustedChanges, currentNodes); + + for (const change of adjustedChanges) { + if (change.type !== "dimensions") continue; + if (!change.dimensions) continue; + if (removedIds.has(change.id)) continue; + if (change.resizing !== false) continue; + + void runResizeNodeMutation({ + nodeId: change.id as Id<"nodes">, + width: change.dimensions.width, + height: change.dimensions.height, + }).catch((error: unknown) => { + if (process.env.NODE_ENV !== "production") { + console.warn("[Canvas] resizeNode failed", error); + } + }); + } + + return nextNodes; + }); + }, + [ + isResizing, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + runResizeNodeMutation, + setNodes, + ], + ); + + const onNodeDragStart = useCallback( + (_event: ReactMouseEvent, _node: RFNode, draggedNodes: RFNode[]) => { + isDragging.current = true; + clearHighlightedIntersectionEdge(); + for (const draggedNode of draggedNodes) { + pendingLocalPositionUntilConvexMatchesRef.current.delete(draggedNode.id); + } + }, + [clearHighlightedIntersectionEdge, isDragging, pendingLocalPositionUntilConvexMatchesRef], + ); + + const onNodeDrag = useCallback( + (_event: ReactMouseEvent, node: RFNode) => { + const nodeCenter = getNodeCenterClientPosition(node.id); + if (!nodeCenter) { + clearHighlightedIntersectionEdge(); + return; + } + + const intersectedEdgeId = getIntersectedEdgeId(nodeCenter); + if (!intersectedEdgeId) { + clearHighlightedIntersectionEdge(); + return; + } + + const intersectedEdge = edges.find( + (edge) => + edge.id === intersectedEdgeId && + edge.className !== "temp" && + !isOptimisticEdgeId(edge.id), + ); + if (!intersectedEdge) { + clearHighlightedIntersectionEdge(); + return; + } + + if (intersectedEdge.source === node.id || intersectedEdge.target === node.id) { + clearHighlightedIntersectionEdge(); + return; + } + + const handles = NODE_HANDLE_MAP[node.type ?? ""]; + if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) { + clearHighlightedIntersectionEdge(); + return; + } + + overlappedEdgeRef.current = intersectedEdge.id; + setHighlightedIntersectionEdge(intersectedEdge.id); + }, + [clearHighlightedIntersectionEdge, edges, setHighlightedIntersectionEdge], + ); + + const onNodeDragStop = useCallback( + (_event: ReactMouseEvent, node: RFNode, draggedNodes: RFNode[]) => { + const primaryNode = (node as RFNode | undefined) ?? draggedNodes[0]; + const intersectedEdgeId = overlappedEdgeRef.current; + + void (async () => { + if (!primaryNode) { + clearHighlightedIntersectionEdge(); + isDragging.current = false; + return; + } + + try { + const intersectedEdge = intersectedEdgeId + ? edges.find( + (edge) => + edge.id === intersectedEdgeId && + edge.className !== "temp" && + !isOptimisticEdgeId(edge.id), + ) + : undefined; + + const splitHandles = NODE_HANDLE_MAP[primaryNode.type ?? ""]; + const splitEligible = + intersectedEdge !== undefined && + splitHandles !== undefined && + intersectedEdge.source !== primaryNode.id && + intersectedEdge.target !== primaryNode.id && + hasHandleKey(splitHandles, "source") && + hasHandleKey(splitHandles, "target"); + + if (draggedNodes.length > 1) { + for (const draggedNode of draggedNodes) { + const clientRequestId = clientRequestIdFromOptimisticNodeId( + draggedNode.id, + ); + if (clientRequestId) { + pendingMoveAfterCreateRef.current.set(clientRequestId, { + positionX: draggedNode.position.x, + positionY: draggedNode.position.y, + }); + await syncPendingMoveForClientRequest(clientRequestId); + } + } + + const realMoves = draggedNodes.filter( + (draggedNode) => !isOptimisticNodeId(draggedNode.id), + ); + if (realMoves.length > 0) { + await runBatchMoveNodesMutation({ + moves: realMoves.map((draggedNode) => ({ + nodeId: draggedNode.id as Id<"nodes">, + positionX: draggedNode.position.x, + positionY: draggedNode.position.y, + })), + }); + } + + if (!splitEligible || !intersectedEdge) { + return; + } + + const multiClientRequestId = clientRequestIdFromOptimisticNodeId( + primaryNode.id, + ); + let middleId = primaryNode.id as Id<"nodes">; + if (multiClientRequestId) { + const resolvedId = + resolvedRealIdByClientRequestRef.current.get(multiClientRequestId); + if (!resolvedId) { + pendingEdgeSplitByClientRequestRef.current.set( + multiClientRequestId, + { + 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(splitHandles.source), + middleTargetHandle: normalizeHandle(splitHandles.target), + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, + }, + ); + return; + } + middleId = resolvedId; + } + + await runSplitEdgeAtExistingNodeMutation({ + canvasId, + splitEdgeId: intersectedEdge.id as Id<"edges">, + middleNodeId: middleId, + splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle), + splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle), + newNodeSourceHandle: normalizeHandle(splitHandles.source), + newNodeTargetHandle: normalizeHandle(splitHandles.target), + }); + return; + } + + if (!splitEligible || !intersectedEdge) { + const singleClientRequestId = clientRequestIdFromOptimisticNodeId( + primaryNode.id, + ); + if (singleClientRequestId) { + pendingMoveAfterCreateRef.current.set(singleClientRequestId, { + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, + }); + await syncPendingMoveForClientRequest(singleClientRequestId); + } else { + await runMoveNodeMutation({ + nodeId: primaryNode.id as Id<"nodes">, + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, + }); + } + return; + } + + const singleClientRequestId = clientRequestIdFromOptimisticNodeId( + primaryNode.id, + ); + if (singleClientRequestId) { + const resolvedSingle = + resolvedRealIdByClientRequestRef.current.get(singleClientRequestId); + if (!resolvedSingle) { + pendingMoveAfterCreateRef.current.set(singleClientRequestId, { + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, + }); + pendingEdgeSplitByClientRequestRef.current.set(singleClientRequestId, { + 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(splitHandles.source), + middleTargetHandle: normalizeHandle(splitHandles.target), + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, + }); + await syncPendingMoveForClientRequest(singleClientRequestId); + return; + } + + await runSplitEdgeAtExistingNodeMutation({ + canvasId, + splitEdgeId: intersectedEdge.id as Id<"edges">, + middleNodeId: resolvedSingle, + splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle), + splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle), + newNodeSourceHandle: normalizeHandle(splitHandles.source), + newNodeTargetHandle: normalizeHandle(splitHandles.target), + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, + }); + pendingMoveAfterCreateRef.current.delete(singleClientRequestId); + return; + } + + await runSplitEdgeAtExistingNodeMutation({ + canvasId, + splitEdgeId: intersectedEdge.id as Id<"edges">, + middleNodeId: primaryNode.id as Id<"nodes">, + splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle), + splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle), + newNodeSourceHandle: normalizeHandle(splitHandles.source), + newNodeTargetHandle: normalizeHandle(splitHandles.target), + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, + }); + } catch (error) { + console.error("[Canvas edge intersection split failed]", { + canvasId, + nodeId: primaryNode?.id ?? null, + nodeType: primaryNode?.type ?? null, + intersectedEdgeId, + error: String(error), + }); + } finally { + clearHighlightedIntersectionEdge(); + isDragging.current = false; + } + })(); + }, + [ + canvasId, + clearHighlightedIntersectionEdge, + edges, + isDragging, + pendingEdgeSplitByClientRequestRef, + pendingMoveAfterCreateRef, + resolvedRealIdByClientRequestRef, + runBatchMoveNodesMutation, + runMoveNodeMutation, + runSplitEdgeAtExistingNodeMutation, + syncPendingMoveForClientRequest, + ], + ); + + return { + onNodesChange, + onNodeDragStart, + onNodeDrag, + onNodeDragStop, + setHighlightedIntersectionEdge, + clearHighlightedIntersectionEdge, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 94c909c..3600b04 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "tests/**/*.test.ts", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", "components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts", + "components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/use-canvas-sync-engine.test.ts", "components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx", ],