diff --git a/.changeset/mighty-nails-cover.md b/.changeset/mighty-nails-cover.md new file mode 100644 index 00000000..10904ab2 --- /dev/null +++ b/.changeset/mighty-nails-cover.md @@ -0,0 +1,6 @@ +--- +'@xyflow/react': patch +'@xyflow/system': patch +--- + +Replaced algorithm used for searching close handles while connecting diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 6dffe3b0..28c9e8cc 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -53,6 +53,7 @@ import UseHandleConnections from '../examples/UseHandleConnections'; import AddNodeOnEdgeDrop from '../examples/AddNodeOnEdgeDrop'; import DevTools from '../examples/DevTools'; import Redux from '../examples/Redux'; +import MovingHandles from '../examples/MovingHandles'; export interface IRoute { name: string; @@ -206,6 +207,11 @@ const routes: IRoute[] = [ path: 'multi-setnodes', component: MultiSetNodes, }, + { + name: 'Moving Handles', + path: 'moving-handles', + component: MovingHandles, + }, { name: 'Multi Flows', path: 'multiflows', diff --git a/examples/react/src/examples/EasyConnect/CustomNode.tsx b/examples/react/src/examples/EasyConnect/CustomNode.tsx index 03726da2..803f7b9e 100644 --- a/examples/react/src/examples/EasyConnect/CustomNode.tsx +++ b/examples/react/src/examples/EasyConnect/CustomNode.tsx @@ -2,9 +2,7 @@ import { Handle, NodeProps, Position, useConnection } from '@xyflow/react'; export default function CustomNode({ id }: NodeProps) { const connection = useConnection(); - const isTarget = connection.inProgress && connection.fromNode.id !== id; - const label = isTarget ? 'Drop here' : 'Drag to connect'; return ( @@ -17,12 +15,12 @@ export default function CustomNode({ id }: NodeProps) { }} > {/* If handles are conditionally rendered and not present initially, you need to update the node internals https://reactflow.dev/docs/api/hooks/use-update-node-internals/ */} - {/* In this case we don't need to use useUpdateNodeInternals, since !connection.inProgress is true at the beginning and all handles are rendered initially. */} - {!connection.inProgress && } - + {/* In this case we don't need to use useUpdateNodeInternals, since !isConnecting is true at the beginning and all handles are rendered initially. */} + {!connection.inProgress && } {/* We want to disable the target handle, if the connection was started from this node */} - {/* {(!connection.inProgress || isTarget) && ( */} - + {(!connection.inProgress || isTarget) && ( + + )} {label} diff --git a/examples/react/src/examples/MovingHandles/MovingHandleNode.tsx b/examples/react/src/examples/MovingHandles/MovingHandleNode.tsx new file mode 100644 index 00000000..bef0bec9 --- /dev/null +++ b/examples/react/src/examples/MovingHandles/MovingHandleNode.tsx @@ -0,0 +1,57 @@ +import React, { memo, CSSProperties } from 'react'; +import { Handle, Position, NodeProps, useConnection } from '@xyflow/react'; + +import type { MovingHandleNode } from '.'; + +const sourceHandleStyle: CSSProperties = { + position: 'relative', + transform: 'translate(-50%, 0)', + top: 0, + transition: 'transform 0.5s', +}; + +function MovingHandleNode({}: NodeProps) { + const connection = useConnection(); + + return ( + <> +
+ + +
+
+
moving handles
+ + +
+ + ); +} + +export default memo(MovingHandleNode); diff --git a/examples/react/src/examples/MovingHandles/index.tsx b/examples/react/src/examples/MovingHandles/index.tsx new file mode 100644 index 00000000..8e254eb2 --- /dev/null +++ b/examples/react/src/examples/MovingHandles/index.tsx @@ -0,0 +1,116 @@ +import { useState, useCallback, useEffect } from 'react'; +import { + ReactFlow, + Controls, + addEdge, + Node, + Position, + useEdgesState, + Background, + applyNodeChanges, + OnNodesChange, + OnConnect, + BuiltInNode, + BuiltInEdge, + NodeTypes, + ReactFlowProvider, + useConnection, + useReactFlow, + useUpdateNodeInternals, +} from '@xyflow/react'; + +import MovingHandleNode from './MovingHandleNode'; + +export type MovingHandleNode = Node<{}, 'movingHandle'>; +export type MyNode = BuiltInNode | MovingHandleNode; +export type MyEdge = BuiltInEdge; + +const nodeTypes: NodeTypes = { + movingHandle: MovingHandleNode, +}; + +const initNodes: MyNode[] = [ + { + id: 'input', + type: 'input', + data: { label: 'input' }, + position: { x: -300, y: 0 }, + sourcePosition: Position.Right, + }, +]; + +for (let i = 0; i < 10; i++) { + initNodes.push({ + id: `${i}`, + type: 'movingHandle', + position: { x: 0, y: i * 60 }, + data: {}, + }); +} + +const initEdges: MyEdge[] = []; + +const CustomNodeFlow = () => { + const [nodes, setNodes] = useState(initNodes); + + const onNodesChange: OnNodesChange = useCallback( + (changes) => + setNodes((nds) => { + const nextNodes = applyNodeChanges(changes, nds); + return nextNodes; + }), + [setNodes] + ); + + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect: OnConnect = useCallback( + (connection) => setEdges((eds) => addEdge({ ...connection, animated: true }, eds)), + [setEdges] + ); + + return ( + + + + + + ); +}; + +function NodeUpdater() { + const connection = useConnection(); + const { getNodes } = useReactFlow(); + const updateNodeInternals = useUpdateNodeInternals(); + + useEffect(() => { + const startTime = Date.now(); + const nodeIds = getNodes().map((n) => n.id); + + function update() { + if (Date.now() - startTime < 500) { + updateNodeInternals(nodeIds); + requestAnimationFrame(update); + } + } + + update(); + }, [connection.inProgress]); + + return null; +} + +export default () => ( + + + +); diff --git a/packages/react/src/components/Handle/index.tsx b/packages/react/src/components/Handle/index.tsx index fbc258c2..3ea98530 100644 --- a/packages/react/src/components/Handle/index.tsx +++ b/packages/react/src/components/Handle/index.tsx @@ -161,6 +161,7 @@ function HandleComponent( isValidConnection: isValidConnectionStore, lib, rfId: flowId, + nodeLookup, connection: connectionState, } = store.getState(); @@ -190,6 +191,7 @@ function HandleComponent( flowId, doc, lib, + nodeLookup, }); if (isValid && connection) { diff --git a/packages/system/src/xyhandle/XYHandle.ts b/packages/system/src/xyhandle/XYHandle.ts index 335c1673..53d64a18 100644 --- a/packages/system/src/xyhandle/XYHandle.ts +++ b/packages/system/src/xyhandle/XYHandle.ts @@ -15,7 +15,7 @@ import { type Connection, } from '../types'; -import { getClosestHandle, isConnectionValid, getHandleLookup, getHandleType } from './utils'; +import { getClosestHandle, isConnectionValid, getHandleType, getHandle } from './utils'; import { IsValidParams, OnPointerDownParams, Result, XYHandleInstance } from './types'; const alwaysValid = () => true; @@ -61,19 +61,17 @@ function onPointerDown( return; } + const fromHandleInternal = getHandle(nodeId, handleType, handleId, nodeLookup); + if (!fromHandleInternal) { + return; + } + let position = getEventPosition(event, containerBounds); let autoPanStarted = false; let connection: Connection | null = null; let isValid: boolean | null = false; let handleDomNode: Element | null = null; - const [handleLookup, fromHandleInternal] = getHandleLookup({ - nodeLookup, - nodeId, - handleId, - handleType, - }); - // when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas function autoPan(): void { if (!autoPanOnConnect || !containerBounds) { @@ -128,7 +126,8 @@ function onPointerDown( closestHandle = getClosestHandle( pointToRendererPoint(position, transform, false, [1, 1]), connectionRadius, - handleLookup + nodeLookup, + fromHandle ); if (!autoPanStarted) { @@ -146,7 +145,7 @@ function onPointerDown( doc, lib, flowId, - handleLookup, + nodeLookup, }); handleDomNode = result.handleDomNode; @@ -238,7 +237,7 @@ function isValidHandle( lib, flowId, isValidConnection = alwaysValid, - handleLookup, + nodeLookup, }: IsValidParams ) { const isTarget = fromType === 'target'; @@ -266,7 +265,7 @@ function isValidHandle( const connectable = handleToCheck.classList.contains('connectable'); const connectableEnd = handleToCheck.classList.contains('connectableend'); - if (!handleNodeId) { + if (!handleNodeId || !handleType) { return result; } @@ -289,13 +288,7 @@ function isValidHandle( result.isValid = isValid && isValidConnection(connection); - const toHandle = handleLookup?.get(`${handleNodeId}-${handleType}-${handleId}`); - - if (toHandle) { - result.toHandle = { - ...toHandle, - }; - } + result.toHandle = getHandle(handleNodeId, handleType, handleId, nodeLookup, false); } return result; diff --git a/packages/system/src/xyhandle/types.ts b/packages/system/src/xyhandle/types.ts index ac1b6e23..799f68c3 100644 --- a/packages/system/src/xyhandle/types.ts +++ b/packages/system/src/xyhandle/types.ts @@ -48,7 +48,7 @@ export type IsValidParams = { doc: Document | ShadowRoot; lib: string; flowId: string | null; - handleLookup?: Map; + nodeLookup: NodeLookup; }; export type XYHandleInstance = { diff --git a/packages/system/src/xyhandle/utils.ts b/packages/system/src/xyhandle/utils.ts index 04099bcf..2a0ff911 100644 --- a/packages/system/src/xyhandle/utils.ts +++ b/packages/system/src/xyhandle/utils.ts @@ -1,109 +1,96 @@ -import { getHandlePosition } from '../utils'; -import { - type HandleType, - type NodeHandleBounds, - type XYPosition, - type Handle, - InternalNodeBase, - NodeLookup, -} from '../types'; +import { getHandlePosition, getOverlappingArea, nodeToRect } from '../utils'; +import type { HandleType, XYPosition, Handle, InternalNodeBase, NodeLookup } from '../types'; -// this functions collects all handles and adds an absolute position -// so that we can later find the closest handle to the mouse position -function getHandles( - node: InternalNodeBase, - handleBounds: NodeHandleBounds, - type: HandleType, - currentHandle: { nodeId: string; handleId: string | null; handleType: HandleType } -): [Handle[], Handle | null] { - let excludedHandle = null; - const handles = (handleBounds[type] || []).reduce((res, handle) => { - if (node.id === currentHandle.nodeId && type === currentHandle.handleType && handle.id === currentHandle.handleId) { - excludedHandle = handle; - } else { - const handleXY = getHandlePosition(node, handle, handle.position, true); - res.push({ ...handle, ...handleXY }); +function getNodesWithinDistance(position: XYPosition, nodeLookup: NodeLookup, distance: number): InternalNodeBase[] { + const nodes: InternalNodeBase[] = []; + const rect = { + x: position.x - distance, + y: position.y - distance, + width: distance * 2, + height: distance * 2, + }; + + for (const node of nodeLookup.values()) { + if (getOverlappingArea(rect, nodeToRect(node)) > 0) { + nodes.push(node); } - return res; - }, []); - return [handles, excludedHandle]; + } + + return nodes; } +// this distance is used for the area around the user pointer +// while doing a connection for finding the closest nodes +const ADDITIONAL_DISTANCE = 250; + export function getClosestHandle( - pos: XYPosition, + position: XYPosition, connectionRadius: number, - handleLookup: Map + nodeLookup: NodeLookup, + fromHandle: { nodeId: string; type: HandleType; id?: string | null } ): Handle | null { let closestHandles: Handle[] = []; let minDistance = Infinity; - for (const handle of handleLookup.values()) { - const distance = Math.sqrt(Math.pow(handle.x - pos.x, 2) + Math.pow(handle.y - pos.y, 2)); - if (distance <= connectionRadius) { + const closeNodes = getNodesWithinDistance(position, nodeLookup, connectionRadius + ADDITIONAL_DISTANCE); + + for (const node of closeNodes) { + const allHandles = [...(node.internals.handleBounds?.source ?? []), ...(node.internals.handleBounds?.target ?? [])]; + + for (const handle of allHandles) { + // if the handle is the same as the fromHandle we skip it + if (fromHandle.nodeId === handle.nodeId && fromHandle.type === handle.type && fromHandle.id === handle.id) { + continue; + } + + // determine absolute position of the handle + const { x, y } = getHandlePosition(node, handle, handle.position, true); + + const distance = Math.sqrt(Math.pow(x - position.x, 2) + Math.pow(y - position.y, 2)); + if (distance > connectionRadius) { + continue; + } + if (distance < minDistance) { - closestHandles = [handle]; + closestHandles = [{ ...handle, x, y }]; + minDistance = distance; } else if (distance === minDistance) { // when multiple handles are on the same distance we collect all of them - closestHandles.push(handle); + closestHandles.push({ ...handle, x, y }); } - minDistance = distance; } } if (!closestHandles.length) { return null; } + // when multiple handles overlay each other we prefer the opposite handle + if (closestHandles.length > 1) { + const oppositeHandleType = fromHandle.type === 'source' ? 'target' : 'source'; + return closestHandles.find((handle) => handle.type === oppositeHandleType) ?? closestHandles[0]; + } - return closestHandles.length === 1 - ? closestHandles[0] - : // if multiple handles are layouted on top of each other we take the one with type = target because it's more likely that the user wants to connect to this one - closestHandles.find((handle) => handle.type === 'target') || closestHandles[0]; + return closestHandles[0]; } -type GetHandleLookupParams = { - nodeLookup: NodeLookup; - nodeId: string; - handleId: string | null; - handleType: HandleType; -}; - -export function getHandleLookup({ - nodeLookup, - nodeId, - handleId, - handleType, -}: GetHandleLookupParams): [Map, Handle] { - const connectionHandles: Map = new Map(); - const currentHandle = { nodeId, handleId, handleType }; - let matchingHandle: Handle | null = null; - - for (const node of nodeLookup.values()) { - if (node.internals.handleBounds) { - const [sourceHandles, excludedSource] = getHandles(node, node.internals.handleBounds, 'source', currentHandle); - const [targetHandles, excludedTarget] = getHandles(node, node.internals.handleBounds, 'target', currentHandle); - - matchingHandle = matchingHandle ? matchingHandle : excludedSource ?? excludedTarget; - - [...sourceHandles, ...targetHandles].forEach((handle) => - connectionHandles.set(`${handle.nodeId}-${handle.type}-${handle.id}`, handle) - ); - } +export function getHandle( + nodeId: string, + handleType: HandleType, + handleId: string | null, + nodeLookup: NodeLookup, + withAbsolutePosition = false +): Handle | null { + const node = nodeLookup.get(nodeId); + if (!node) { + return null; } - // if the user only works with handles that are type="source" + connectionMode="loose" - // it happens that we can't find a matching handle. The reason for this is, that the - // edge don't know about the handles and always assumes that there is source and a target. - // In this case we need to find the matching handle by switching the handleType - if (!matchingHandle) { - const node = nodeLookup.get(nodeId); - if (node?.internals.handleBounds) { - currentHandle.handleType = handleType === 'source' ? 'target' : 'source'; - const [, excluded] = getHandles(node, node.internals.handleBounds, currentHandle.handleType, currentHandle); - matchingHandle = excluded; - } - } + const handles = node.internals.handleBounds?.[handleType]; + const handle = (handleId ? handles?.find((h) => h.id === handleId) : handles?.[0]) ?? null; - return [connectionHandles, matchingHandle!]; + return handle && withAbsolutePosition + ? { ...handle, ...getHandlePosition(node, handle, handle.position, true) } + : handle; } export function getHandleType(