diff --git a/packages/core/src/components/Handle/handler.ts b/packages/core/src/components/Handle/handler.ts index e5cafd21..51cfd32d 100644 --- a/packages/core/src/components/Handle/handler.ts +++ b/packages/core/src/components/Handle/handler.ts @@ -2,73 +2,9 @@ import type { MouseEvent as ReactMouseEvent } from 'react'; import { StoreApi } from 'zustand'; import { getHostForElement, calcAutoPanVelocity } from '../../utils'; -import { ConnectionMode } from '../../types'; -import type { OnConnect, Connection, HandleType, ReactFlowState, XYPosition } from '../../types'; - -type ValidConnectionFunc = (connection: Connection) => boolean; - -type Result = { - elementBelow: Element | null; - isValid: boolean; - connection: Connection; - isHoveringHandle: boolean; -}; - -// checks if element below mouse is a handle and returns connection in form of an object { source: 123, target: 312 } -export function checkElementBelowIsValid( - event: MouseEvent, - connectionMode: ConnectionMode, - isTarget: boolean, - nodeId: string, - handleId: string | null, - isValidConnection: ValidConnectionFunc, - doc: Document | ShadowRoot -) { - const elementBelow = doc.elementFromPoint(event.clientX, event.clientY); - const elementBelowIsTarget = elementBelow?.classList.contains('target') || false; - const elementBelowIsSource = elementBelow?.classList.contains('source') || false; - - const result: Result = { - elementBelow, - isValid: false, - connection: { source: null, target: null, sourceHandle: null, targetHandle: null }, - isHoveringHandle: false, - }; - - if (elementBelow && (elementBelowIsTarget || elementBelowIsSource)) { - result.isHoveringHandle = true; - - const elementBelowNodeId = elementBelow.getAttribute('data-nodeid'); - const elementBelowHandleId = elementBelow.getAttribute('data-handleid'); - const connection: Connection = isTarget - ? { - source: elementBelowNodeId, - sourceHandle: elementBelowHandleId, - target: nodeId, - targetHandle: handleId, - } - : { - source: nodeId, - sourceHandle: handleId, - target: elementBelowNodeId, - targetHandle: elementBelowHandleId, - }; - - result.connection = connection; - - // in strict mode we don't allow target to target or source to source connections - const isValid = - connectionMode === ConnectionMode.Strict - ? (isTarget && elementBelowIsSource) || (!isTarget && elementBelowIsTarget) - : elementBelowNodeId !== nodeId || elementBelowHandleId !== handleId; - - if (isValid) { - result.isValid = isValidConnection(connection); - } - } - - return result; -} +import type { OnConnect, HandleType, ReactFlowState } from '../../types'; +import { pointToRendererPoint, rendererPointToPoint } from '../../utils/graph'; +import { ConnectionHandle, getClosestHandle, getHandleLookup, isValidHandle, ValidConnectionFunc } from './utils'; function resetRecentHandle(hoveredHandle: Element): void { hoveredHandle?.classList.remove('react-flow__handle-valid'); @@ -100,44 +36,46 @@ export function handleMouseDown({ }): void { // when react-flow is used inside a shadow root we can't use document const doc = getHostForElement(event.target as HTMLElement); - const { onConnectStart, movePane, connectionMode, domNode, autoPanOnConnect } = getState(); - let connectionPosition: XYPosition = { x: 0, y: 0 }; + const { connectionMode, domNode, autoPanOnConnect, connectionRadius, onConnectStart, onConnectEnd, panBy, getNodes } = + getState(); let autoPanId = 0; + let prevClosestHandle: ConnectionHandle | undefined; + + const clickedElement = doc?.elementFromPoint(event.clientX, event.clientY); + const elementIsTarget = clickedElement?.classList.contains('target'); + const elementIsSource = clickedElement?.classList.contains('source'); + + if (!domNode || (!elementIsTarget && !elementIsSource && !elementEdgeUpdaterType)) { + return; + } + + const handleType = elementEdgeUpdaterType ? elementEdgeUpdaterType : elementIsTarget ? 'target' : 'source'; + const containerBounds = domNode.getBoundingClientRect(); + let recentHoveredHandle: Element; + let connectionPosition = { + x: event.clientX - containerBounds.left, + y: event.clientY - containerBounds.top, + }; + + const handleLookup = getHandleLookup({ + nodes: getNodes(), + nodeId, + handleId, + handleType, + }); // when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas const autoPan = (): void => { - if (!containerBounds || !autoPanOnConnect) { + if (!autoPanOnConnect) { return; } - const xMovement = calcAutoPanVelocity(connectionPosition.x, 35, containerBounds.width - 35) * 20; const yMovement = calcAutoPanVelocity(connectionPosition.y, 35, containerBounds.height - 35) * 20; - movePane({ x: xMovement, y: yMovement }); + panBy({ x: xMovement, y: yMovement }); autoPanId = requestAnimationFrame(autoPan); }; - if (!doc || !domNode) { - return; - } - - const elementBelow = doc.elementFromPoint(event.clientX, event.clientY); - const elementBelowIsTarget = elementBelow?.classList.contains('target'); - const elementBelowIsSource = elementBelow?.classList.contains('source'); - - if (!elementBelowIsTarget && !elementBelowIsSource && !elementEdgeUpdaterType) { - return; - } - - const handleType = elementEdgeUpdaterType ? elementEdgeUpdaterType : elementBelowIsTarget ? 'target' : 'source'; - const containerBounds = domNode.getBoundingClientRect(); - let recentHoveredHandle: Element; - - connectionPosition = { - x: event.clientX - containerBounds.left, - y: event.clientY - containerBounds.top, - }; - autoPan(); setState({ @@ -146,61 +84,82 @@ export function handleMouseDown({ connectionHandleId: handleId, connectionHandleType: handleType, }); + onConnectStart?.(event, { nodeId, handleId, handleType }); function onMouseMove(event: MouseEvent) { + const { transform } = getState(); + connectionPosition = { x: event.clientX - containerBounds.left, y: event.clientY - containerBounds.top, }; - setState({ - connectionPosition, - }); - - const { connection, elementBelow, isValid, isHoveringHandle } = checkElementBelowIsValid( - event, - connectionMode, - isTarget, - nodeId, - handleId, - isValidConnection, - doc + prevClosestHandle = getClosestHandle( + pointToRendererPoint(connectionPosition, transform, false, [1, 1]), + connectionRadius, + handleLookup ); - if (!isHoveringHandle) { - return resetRecentHandle(recentHoveredHandle); - } + setState({ + connectionPosition: prevClosestHandle + ? rendererPointToPoint( + { + x: prevClosestHandle.absX + prevClosestHandle.width / 2, + y: prevClosestHandle.absY + prevClosestHandle.height / 2, + }, + transform + ) + : connectionPosition, + }); - if (connection.source !== connection.target && elementBelow) { - resetRecentHandle(recentHoveredHandle); - recentHoveredHandle = elementBelow; - elementBelow.classList.add('react-flow__handle-connecting'); - elementBelow.classList.toggle('react-flow__handle-valid', isValid); + if (prevClosestHandle) { + const { connection, elementBelow, isValid, isHoveringHandle } = isValidHandle( + prevClosestHandle, + connectionMode, + nodeId, + handleId, + isTarget ? 'target' : 'source', + isValidConnection, + doc + ); + + if (!isHoveringHandle) { + return resetRecentHandle(recentHoveredHandle); + } + + if (connection.source !== connection.target && elementBelow) { + resetRecentHandle(recentHoveredHandle); + recentHoveredHandle = elementBelow; + elementBelow.classList.add('react-flow__handle-connecting'); + elementBelow.classList.toggle('react-flow__handle-valid', isValid); + } } } function onMouseUp(event: MouseEvent) { cancelAnimationFrame(autoPanId); - const { connection, isValid } = checkElementBelowIsValid( - event, - connectionMode, - isTarget, - nodeId, - handleId, - isValidConnection, - doc - ); + if (prevClosestHandle) { + const { connection, isValid } = isValidHandle( + prevClosestHandle, + connectionMode, + nodeId, + handleId, + isTarget ? 'target' : 'source', + isValidConnection, + doc + ); - if (isValid) { - onConnect?.(connection); + if (isValid) { + onConnect?.(connection); + } } - getState().onConnectEnd?.(event); + onConnectEnd?.(event); - if (elementEdgeUpdaterType && onEdgeUpdateEnd) { - onEdgeUpdateEnd(event); + if (elementEdgeUpdaterType) { + onEdgeUpdateEnd?.(event); } resetRecentHandle(recentHoveredHandle); diff --git a/packages/core/src/components/Handle/index.tsx b/packages/core/src/components/Handle/index.tsx index 6825fc07..19a78314 100644 --- a/packages/core/src/components/Handle/index.tsx +++ b/packages/core/src/components/Handle/index.tsx @@ -4,11 +4,12 @@ import { shallow } from 'zustand/shallow'; import { useStore, useStoreApi } from '../../hooks/useStore'; import { useNodeId } from '../../contexts/NodeIdContext'; -import { checkElementBelowIsValid, handleMouseDown } from './handler'; -import { getHostForElement } from '../../utils'; +import { handleMouseDown } from './handler'; +import { devWarn, getHostForElement } from '../../utils'; import { addEdge } from '../../utils/graph'; import { Position } from '../../types'; import type { HandleProps, Connection, ReactFlowState } from '../../types'; +import { isValidHandle } from './utils'; const alwaysValid = () => true; @@ -37,9 +38,13 @@ const Handle = forwardRef( ref ) => { const store = useStoreApi(); + const nodeId = useNodeId(); + + if (!nodeId) { + devWarn('Handle: No node id found. Make sure to only use a Handle inside a custom Node.'); + return null; + } - // @fixme: remove type assertion and handle nodeId === null - const nodeId = useNodeId() as string; const { connectionStartHandle, connectOnClick, noPanClassName } = useStore(selector, shallow); const handleId = id || null; @@ -86,12 +91,16 @@ const Handle = forwardRef( } const doc = getHostForElement(event.target as HTMLElement); - const { connection, isValid } = checkElementBelowIsValid( - event as unknown as MouseEvent, + const { connection, isValid } = isValidHandle( + { + nodeId, + id: handleId, + type, + }, connectionMode, - connectionStartHandle.type === 'target', connectionStartHandle.nodeId, connectionStartHandle.handleId || null, + connectionStartHandle.type, isValidConnection, doc ); @@ -110,6 +119,7 @@ const Handle = forwardRef( data-handleid={handleId} data-nodeid={nodeId} data-handlepos={position} + data-id={`${nodeId}-${handleId}-${type}`} className={cc([ 'react-flow__handle', `react-flow__handle-${position}`, diff --git a/packages/core/src/components/Handle/utils.ts b/packages/core/src/components/Handle/utils.ts new file mode 100644 index 00000000..a476c515 --- /dev/null +++ b/packages/core/src/components/Handle/utils.ts @@ -0,0 +1,143 @@ +import { ConnectionMode, Node, NodeHandleBounds } from '../../types'; +import type { Connection, HandleType, XYPosition } from '../../types'; +import { internalsSymbol } from '../../utils'; + +export type ConnectionHandle = { + id: string | null; + type: HandleType; + nodeId: string; + absX: number; + absY: number; + width: number; + height: number; +}; + +export type ValidConnectionFunc = (connection: Connection) => boolean; + +export function getHandles( + node: Node, + handleBounds: NodeHandleBounds, + type: HandleType, + currentHandle: string +): ConnectionHandle[] { + return (handleBounds[type] || []).reduce((res, h) => { + if (`${node.id}-${h.id}-${type}` !== currentHandle) { + res.push({ + id: h.id || null, + type, + nodeId: node.id, + absX: (node.positionAbsolute?.x ?? 0) + h.x, + absY: (node.positionAbsolute?.y ?? 0) + h.y, + width: h.width, + height: h.height, + }); + } + return res; + }, []); +} + +export function getClosestHandle(pos: XYPosition, connectionRadius: number, handles: ConnectionHandle[]) { + let closestHandle: ConnectionHandle | undefined; + let minDistance = Infinity; + + handles.forEach((handle) => { + const distance = Math.sqrt(Math.pow(handle.absX - pos.x, 2) + Math.pow(handle.absY - pos.y, 2)); + if (distance <= connectionRadius && distance < minDistance) { + minDistance = distance; + closestHandle = handle; + } + }); + + return closestHandle; +} + +type Result = { + elementBelow: Element | null; + isValid: boolean; + connection: Connection; + isHoveringHandle: boolean; +}; + +// checks if and returns connection in fom of an object { source: 123, target: 312 } +export function isValidHandle( + handle: Pick, + connectionMode: ConnectionMode, + fromNodeId: string, + fromHandleId: string | null, + fromType: string, + isValidConnection: ValidConnectionFunc, + doc: Document | ShadowRoot +) { + const result: Result = { + elementBelow: null, + isValid: false, + connection: { source: null, target: null, sourceHandle: null, targetHandle: null }, + isHoveringHandle: false, + }; + const isTarget = fromType === 'target'; + + const elementBelow = doc.querySelector( + `.react-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]` + ); + + if (elementBelow) { + const elementBelowIsTarget = handle.type === 'target'; + const elementBelowIsSource = handle.type === 'source'; + result.isHoveringHandle = true; + + const elementBelowNodeId = elementBelow.getAttribute('data-nodeid'); + const elementBelowHandleId = elementBelow.getAttribute('data-handleid'); + const connection: Connection = isTarget + ? { + source: handle.nodeId, + sourceHandle: handle.id, + target: fromNodeId, + targetHandle: fromHandleId, + } + : { + source: fromNodeId, + sourceHandle: fromHandleId, + target: handle.nodeId, + targetHandle: handle.id, + }; + + result.connection = connection; + + // in strict mode we don't allow target to target or source to source connections + const isValid = + connectionMode === ConnectionMode.Strict + ? (isTarget && elementBelowIsSource) || (!isTarget && elementBelowIsTarget) + : elementBelowNodeId !== handle.nodeId || elementBelowHandleId !== handle.id; + + if (isValid) { + result.isValid = isValidConnection(connection); + } + } + + return result; +} + +type GetHandleLookupParams = { + nodes: Node[]; + nodeId: string; + handleId: string | null; + handleType: string; +}; + +export function getHandleLookup({ nodes, nodeId, handleId, handleType }: GetHandleLookupParams) { + return nodes.reduce((res, node) => { + if (node[internalsSymbol]) { + const { handleBounds } = node[internalsSymbol]; + let sourceHandles: ConnectionHandle[] = []; + let targetHandles: ConnectionHandle[] = []; + + if (handleBounds) { + sourceHandles = getHandles(node, handleBounds, 'source', `${nodeId}-${handleId}-${handleType}`); + targetHandles = getHandles(node, handleBounds, 'target', `${nodeId}-${handleId}-${handleType}`); + } + + res.push(...sourceHandles, ...targetHandles); + } + return res; + }, []); +} diff --git a/packages/core/src/container/ReactFlow/index.tsx b/packages/core/src/container/ReactFlow/index.tsx index c19d6b81..67964f0f 100644 --- a/packages/core/src/container/ReactFlow/index.tsx +++ b/packages/core/src/container/ReactFlow/index.tsx @@ -162,6 +162,7 @@ const ReactFlow = forwardRef( disableKeyboardA11y = false, autoPanOnConnect = true, autoPanOnNodeDrag = true, + connectionRadius = 20, style, id, ...rest diff --git a/packages/core/src/hooks/useDrag/index.ts b/packages/core/src/hooks/useDrag/index.ts index 56fece93..49cd7afe 100644 --- a/packages/core/src/hooks/useDrag/index.ts +++ b/packages/core/src/hooks/useDrag/index.ts @@ -114,13 +114,13 @@ function useDrag({ const yMovement = calcAutoPanVelocity(mousePosition.current.y, 35, containerBounds.current.height - 35) * 20; if (xMovement !== 0 || yMovement !== 0) { - const { transform, movePane } = store.getState(); + const { transform, panBy } = store.getState(); lastPos.current.x = (lastPos.current.x ?? 0) - xMovement / transform[2]; lastPos.current.y = (lastPos.current.y ?? 0) - yMovement / transform[2]; updateNodes(lastPos.current as XYPosition); - movePane({ x: xMovement, y: yMovement }); + panBy({ x: xMovement, y: yMovement }); } autoPanId.current = requestAnimationFrame(autoPan); }; diff --git a/packages/core/src/store/index.ts b/packages/core/src/store/index.ts index 4efac70f..0a79a4b1 100644 --- a/packages/core/src/store/index.ts +++ b/packages/core/src/store/index.ts @@ -252,7 +252,7 @@ const createRFStore = () => nodeInternals: new Map(nodeInternals), }); }, - movePane: (delta: XYPosition) => { + panBy: (delta: XYPosition) => { const { transform, width, height, d3Zoom, d3Selection, translateExtent } = get(); if (!d3Zoom || !d3Selection || (!delta.x && !delta.y)) { diff --git a/packages/core/src/store/initialState.ts b/packages/core/src/store/initialState.ts index 91070d06..6c475b5b 100644 --- a/packages/core/src/store/initialState.ts +++ b/packages/core/src/store/initialState.ts @@ -58,6 +58,7 @@ const initialState: ReactFlowStore = { ariaLiveMessage: '', autoPanOnConnect: true, autoPanOnNodeDrag: true, + connectionRadius: 20, }; export default initialState; diff --git a/packages/core/src/types/component-props.ts b/packages/core/src/types/component-props.ts index eeac7ca7..90b7124f 100644 --- a/packages/core/src/types/component-props.ts +++ b/packages/core/src/types/component-props.ts @@ -141,6 +141,7 @@ export type ReactFlowProps = HTMLAttributes & { disableKeyboardA11y?: boolean; autoPanOnNodeDrag?: boolean; autoPanOnConnect?: boolean; + connectionRadius?: number; }; export type ReactFlowRefType = HTMLDivElement; diff --git a/packages/core/src/types/general.ts b/packages/core/src/types/general.ts index 14a68b0d..6289c9c1 100644 --- a/packages/core/src/types/general.ts +++ b/packages/core/src/types/general.ts @@ -212,6 +212,7 @@ export type ReactFlowStore = { ariaLiveMessage: string; autoPanOnConnect: boolean; autoPanOnNodeDrag: boolean; + connectionRadius: number; }; export type ReactFlowActions = { @@ -232,7 +233,7 @@ export type ReactFlowActions = { cancelConnection: () => void; reset: () => void; triggerNodeChanges: (changes: NodeChange[]) => void; - movePane: (delta: XYPosition) => void; + panBy: (delta: XYPosition) => void; }; export type ReactFlowState = ReactFlowStore & ReactFlowActions; diff --git a/packages/core/src/utils/graph.ts b/packages/core/src/utils/graph.ts index 846050f5..7e94f766 100644 --- a/packages/core/src/utils/graph.ts +++ b/packages/core/src/utils/graph.ts @@ -141,6 +141,13 @@ export const pointToRendererPoint = ( return position; }; +export const rendererPointToPoint = ({ x, y }: XYPosition, [tx, ty, tScale]: Transform): XYPosition => { + return { + x: x * tScale + tx, + y: y * tScale + ty, + }; +}; + export const getNodePositionWithOrigin = ( node: Node | undefined, nodeOrigin: NodeOrigin = [0, 0]