From c3e7d61cb485e7bb154c6604b227ab87d56c6604 Mon Sep 17 00:00:00 2001 From: peterkogo Date: Tue, 13 Aug 2024 17:30:35 +0200 Subject: [PATCH 1/5] replaced handle lookup with ad-hoc way of searching for closest handles --- .../react/src/components/Handle/index.tsx | 2 + packages/system/src/xyhandle/XYHandle.ts | 31 ++-- packages/system/src/xyhandle/types.ts | 2 +- packages/system/src/xyhandle/utils.ts | 136 ++++++++---------- 4 files changed, 75 insertions(+), 96 deletions(-) diff --git a/packages/react/src/components/Handle/index.tsx b/packages/react/src/components/Handle/index.tsx index 10f34731..2d3a8738 100644 --- a/packages/react/src/components/Handle/index.tsx +++ b/packages/react/src/components/Handle/index.tsx @@ -159,6 +159,7 @@ function HandleComponent( isValidConnection: isValidConnectionStore, lib, rfId: flowId, + nodeLookup, } = store.getState(); if (!nodeId || (!connectionClickStartHandle && !isConnectableStart)) { @@ -187,6 +188,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 0136479d..c7baa728 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; @@ -233,7 +232,7 @@ function isValidHandle( lib, flowId, isValidConnection = alwaysValid, - handleLookup, + nodeLookup, }: IsValidParams ) { const isTarget = fromType === 'target'; @@ -261,7 +260,7 @@ function isValidHandle( const connectable = handleToCheck.classList.contains('connectable'); const connectableEnd = handleToCheck.classList.contains('connectableend'); - if (!handleNodeId) { + if (!handleNodeId || !handleType) { return result; } @@ -284,13 +283,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..285d8fc3 100644 --- a/packages/system/src/xyhandle/utils.ts +++ b/packages/system/src/xyhandle/utils.ts @@ -1,52 +1,62 @@ -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[] = []; + + for (const node of nodeLookup.values()) { + const rect = { + x: position.x - distance, + y: position.y - distance, + width: distance * 2, + height: distance * 2, + }; + + const overlappingArea = getOverlappingArea(rect, nodeToRect(node)); + + if (overlappingArea > 0) { + nodes.push(node); } - return res; - }, []); - return [handles, excludedHandle]; + } + + return 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; } } @@ -60,50 +70,24 @@ export function getClosestHandle( closestHandles.find((handle) => handle.type === 'target') || 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( From 692e6440b10e75cb31f3f3172aede9ed4d7f05d2 Mon Sep 17 00:00:00 2001 From: peterkogo Date: Tue, 13 Aug 2024 17:32:59 +0200 Subject: [PATCH 2/5] added changeset --- .changeset/mighty-nails-cover.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/mighty-nails-cover.md 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 From e3aa98b84e8e94449a4c8ca9769fcbd264c53a1f Mon Sep 17 00:00:00 2001 From: peterkogo Date: Wed, 14 Aug 2024 09:50:06 +0200 Subject: [PATCH 3/5] connection snapping prioritizes opposite handle type instead of target --- packages/system/src/xyhandle/utils.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/system/src/xyhandle/utils.ts b/packages/system/src/xyhandle/utils.ts index 285d8fc3..bd7c4026 100644 --- a/packages/system/src/xyhandle/utils.ts +++ b/packages/system/src/xyhandle/utils.ts @@ -63,11 +63,13 @@ export function getClosestHandle( 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]; } export function getHandle( From babaa782ea28b435121ce37921dca1161df8d2bd Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 14 Aug 2024 16:30:01 +0200 Subject: [PATCH 4/5] chore(examples): add moving handle example --- examples/react/src/App/routes.ts | 6 + .../src/examples/EasyConnect/CustomNode.tsx | 12 +- .../MovingHandles/MovingHandleNode.tsx | 57 +++++++++ .../src/examples/MovingHandles/index.tsx | 116 ++++++++++++++++++ 4 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 examples/react/src/examples/MovingHandles/MovingHandleNode.tsx create mode 100644 examples/react/src/examples/MovingHandles/index.tsx 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 () => ( + + + +); From e0cfdc52cdbc867e8f0d94fff5e18e3fc4a420ac Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 14 Aug 2024 16:30:37 +0200 Subject: [PATCH 5/5] refactor(xyhandle): cleanup --- packages/system/src/xyhandle/utils.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/system/src/xyhandle/utils.ts b/packages/system/src/xyhandle/utils.ts index bd7c4026..2a0ff911 100644 --- a/packages/system/src/xyhandle/utils.ts +++ b/packages/system/src/xyhandle/utils.ts @@ -3,18 +3,15 @@ import type { HandleType, XYPosition, Handle, InternalNodeBase, NodeLookup } fro 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()) { - const rect = { - x: position.x - distance, - y: position.y - distance, - width: distance * 2, - height: distance * 2, - }; - - const overlappingArea = getOverlappingArea(rect, nodeToRect(node)); - - if (overlappingArea > 0) { + if (getOverlappingArea(rect, nodeToRect(node)) > 0) { nodes.push(node); } } @@ -22,7 +19,10 @@ function getNodesWithinDistance(position: XYPosition, nodeLookup: NodeLookup, di 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( position: XYPosition, connectionRadius: number, @@ -33,6 +33,7 @@ export function getClosestHandle( let minDistance = Infinity; const closeNodes = getNodesWithinDistance(position, nodeLookup, connectionRadius + ADDITIONAL_DISTANCE); + for (const node of closeNodes) { const allHandles = [...(node.internals.handleBounds?.source ?? []), ...(node.internals.handleBounds?.target ?? [])];