From 5af609696b6e4d8ec5edecf0ac0dc2031e8798b2 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 18 Jan 2023 17:51:43 +0100 Subject: [PATCH] feat(connection-radius): use closest handle / snappy edges #2759 --- .../core/src/components/Handle/handler.ts | 177 +++++++++++++----- packages/core/src/components/Handle/index.tsx | 13 +- .../core/src/container/ReactFlow/index.tsx | 1 + packages/core/src/store/initialState.ts | 1 + packages/core/src/types/component-props.ts | 1 + packages/core/src/types/general.ts | 1 + packages/core/src/utils/graph.ts | 7 + 7 files changed, 152 insertions(+), 49 deletions(-) diff --git a/packages/core/src/components/Handle/handler.ts b/packages/core/src/components/Handle/handler.ts index e5cafd21..103a9471 100644 --- a/packages/core/src/components/Handle/handler.ts +++ b/packages/core/src/components/Handle/handler.ts @@ -1,9 +1,10 @@ import type { MouseEvent as ReactMouseEvent } from 'react'; import { StoreApi } from 'zustand'; -import { getHostForElement, calcAutoPanVelocity } from '../../utils'; -import { ConnectionMode } from '../../types'; +import { getHostForElement, calcAutoPanVelocity, internalsSymbol } from '../../utils'; +import { ConnectionMode, Node, NodeHandleBounds } from '../../types'; import type { OnConnect, Connection, HandleType, ReactFlowState, XYPosition } from '../../types'; +import { pointToRendererPoint, rendererPointToPoint } from '../../utils/graph'; type ValidConnectionFunc = (connection: Connection) => boolean; @@ -15,55 +16,60 @@ type Result = { }; // 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, +export function isHandleValid( + handle: Pick | undefined, connectionMode: ConnectionMode, - isTarget: boolean, - nodeId: string, - handleId: string | null, + fromNodeId: string, + fromHandleId: string | null, + fromType: string, 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, + elementBelow: null, isValid: false, connection: { source: null, target: null, sourceHandle: null, targetHandle: null }, isHoveringHandle: false, }; + const isTarget = fromType === 'target'; - if (elementBelow && (elementBelowIsTarget || elementBelowIsSource)) { - result.isHoveringHandle = true; + if (handle) { + const elementBelow = doc.querySelector( + `.react-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]` + ); - 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, - }; + if (elementBelow) { + const elementBelowIsTarget = handle.type === 'target'; + const elementBelowIsSource = handle.type === 'source'; + result.isHoveringHandle = true; - result.connection = connection; + 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, + }; - // 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; + result.connection = connection; - if (isValid) { - result.isValid = isValidConnection(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); + } } } @@ -75,6 +81,53 @@ function resetRecentHandle(hoveredHandle: Element): void { hoveredHandle?.classList.remove('react-flow__handle-connecting'); } +type ConnectionHandle = { + id: string | null; + type: HandleType; + nodeId: string; + absX: number; + absY: number; + width: number; + height: number; +}; + +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; + }, []); +} + +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; +} + export function handleMouseDown({ event, handleId, @@ -100,9 +153,10 @@ 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(); + const { onConnectStart, movePane, connectionMode, domNode, autoPanOnConnect, connectionRadius } = getState(); let connectionPosition: XYPosition = { x: 0, y: 0 }; let autoPanId = 0; + let prevClosestHandle: ConnectionHandle | undefined; // when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas const autoPan = (): void => { @@ -148,22 +202,55 @@ export function handleMouseDown({ }); onConnectStart?.(event, { nodeId, handleId, handleType }); + const handles = getState() + .getNodes() + .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; + }, []); + function onMouseMove(event: MouseEvent) { + const { transform } = getState(); connectionPosition = { x: event.clientX - containerBounds.left, y: event.clientY - containerBounds.top, }; + prevClosestHandle = getClosestHandle( + pointToRendererPoint(connectionPosition, transform, false, [1, 1]), + connectionRadius, + handles + ); + setState({ - connectionPosition, + connectionPosition: prevClosestHandle + ? rendererPointToPoint( + { + x: prevClosestHandle.absX + prevClosestHandle.width / 2, + y: prevClosestHandle.absY + prevClosestHandle.height / 2, + }, + transform + ) + : connectionPosition, }); - const { connection, elementBelow, isValid, isHoveringHandle } = checkElementBelowIsValid( - event, + const { connection, elementBelow, isValid, isHoveringHandle } = isHandleValid( + prevClosestHandle, connectionMode, - isTarget, nodeId, handleId, + isTarget ? 'target' : 'source', isValidConnection, doc ); @@ -183,12 +270,12 @@ export function handleMouseDown({ function onMouseUp(event: MouseEvent) { cancelAnimationFrame(autoPanId); - const { connection, isValid } = checkElementBelowIsValid( - event, + const { connection, isValid } = isHandleValid( + prevClosestHandle, connectionMode, - isTarget, nodeId, handleId, + isTarget ? 'target' : 'source', isValidConnection, doc ); diff --git a/packages/core/src/components/Handle/index.tsx b/packages/core/src/components/Handle/index.tsx index 6825fc07..2d9ebde0 100644 --- a/packages/core/src/components/Handle/index.tsx +++ b/packages/core/src/components/Handle/index.tsx @@ -4,7 +4,7 @@ import { shallow } from 'zustand/shallow'; import { useStore, useStoreApi } from '../../hooks/useStore'; import { useNodeId } from '../../contexts/NodeIdContext'; -import { checkElementBelowIsValid, handleMouseDown } from './handler'; +import { handleMouseDown, isHandleValid } from './handler'; import { getHostForElement } from '../../utils'; import { addEdge } from '../../utils/graph'; import { Position } from '../../types'; @@ -86,12 +86,16 @@ const Handle = forwardRef( } const doc = getHostForElement(event.target as HTMLElement); - const { connection, isValid } = checkElementBelowIsValid( - event as unknown as MouseEvent, + const { connection, isValid } = isHandleValid( + { + nodeId, + id: handleId, + type, + }, connectionMode, - connectionStartHandle.type === 'target', connectionStartHandle.nodeId, connectionStartHandle.handleId || null, + connectionStartHandle.type, isValidConnection, doc ); @@ -110,6 +114,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/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/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..b7b5c16e 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 = { 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]