diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 2665abfa..18307c79 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -45,6 +45,7 @@ import InteractiveMinimap from '../examples/InteractiveMinimap'; import UseOnSelectionChange from '../examples/UseOnSelectionChange'; import NodeToolbar from '../examples/NodeToolbar'; import useNodesInitialized from '../examples/UseNodesInit'; +import useHandleConnectionStatus from '../examples/UseHandleConnectionStatus'; export interface IRoute { name: string; @@ -273,6 +274,11 @@ const routes: IRoute[] = [ path: 'usereactflow', component: UseReactFlow, }, + { + name: 'useHandleConnectionStatus', + path: 'usehandleconnectionstatus', + component: useHandleConnectionStatus, + }, { name: 'useUpdateNodeInternals', path: 'useupdatenodeinternals', diff --git a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx index aebc8e0e..6857e1ac 100644 --- a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx +++ b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx @@ -1,4 +1,4 @@ -import React, { memo, FC, CSSProperties, useCallback } from 'react'; +import React, { memo, FC, CSSProperties, useCallback, useEffect } from 'react'; import { Handle, Position, NodeProps, Connection, Edge, useOnViewportChange, Viewport } from '@xyflow/react'; const targetHandleStyle: CSSProperties = { background: '#555' }; diff --git a/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx b/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx new file mode 100644 index 00000000..f8a31143 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnectionStatus/MultiHandleNode.tsx @@ -0,0 +1,40 @@ +import { memo, FC, useEffect, useCallback } from 'react'; +import { Handle, Position, NodeProps, useHandleConnectionStatus, Connection } from '@xyflow/react'; +import { HandleComponentProps } from '@xyflow/react/dist/esm/components/Handle'; + +function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) { + const onConnect = useCallback( + (connections: Connection[]) => console.log('onConnect handler, node id:', nodeId, connections), + [nodeId] + ); + + const onDisconnect = useCallback( + (connections: Connection[]) => console.log('onDisconnect handler, node id:', nodeId, connections), + [nodeId] + ); + const status = useHandleConnectionStatus({ + handleType: handleProps.type, + handleId: handleProps.id, + onConnect, + onDisconnect, + }); + + useEffect(() => { + console.log('useEffect, node id:', nodeId, handleProps.type, status); + }, [status]); + + return ; +} + +const CustomNode: FC = ({ id }) => { + return ( +
+ +
node {id}
+ + +
+ ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx b/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx new file mode 100644 index 00000000..85d4f9e1 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnectionStatus/SingleHandleNode.tsx @@ -0,0 +1,42 @@ +import { memo, FC, useEffect, useCallback } from 'react'; +import { Handle, Position, NodeProps, useHandleConnectionStatus, Connection } from '@xyflow/react'; +import { HandleComponentProps } from '@xyflow/react/dist/esm/components/Handle'; + +function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) { + const onConnect = useCallback( + (connections: Connection[]) => { + console.log('onConnect handler, node id:', nodeId, connections); + }, + [nodeId] + ); + const onDisconnect = useCallback( + (connections: Connection[]) => { + console.log('onDisconnect handler, node id:', nodeId, connections); + }, + [nodeId] + ); + const status = useHandleConnectionStatus({ + handleType: handleProps.type, + handleId: handleProps.id, + onConnect, + onDisconnect, + }); + + useEffect(() => { + // console.log('useEffect, node id:', nodeId, handleProps.type, status); + }, [status]); + + return ; +} + +const CustomNode: FC = ({ id }) => { + return ( +
+ +
node {id}
+ +
+ ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/examples/UseHandleConnectionStatus/index.tsx b/examples/react/src/examples/UseHandleConnectionStatus/index.tsx new file mode 100644 index 00000000..c6aa4a68 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnectionStatus/index.tsx @@ -0,0 +1,118 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + MiniMap, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, +} from '@xyflow/react'; + +import MultiHandleNode from './MultiHandleNode'; +import SingleHandleNode from './SingleHandleNode'; + +const nodeTypes = { + multi: MultiHandleNode, + single: SingleHandleNode, +}; + +const initNodes = [ + { + id: '1', + type: 'single', + data: {}, + position: { x: 0, y: 0 }, + }, + { + id: '2', + type: 'single', + data: {}, + position: { x: 200, y: -100 }, + }, + { + id: '3', + type: 'single', + data: {}, + position: { x: 200, y: 100 }, + }, + + { + id: '4', + type: 'multi', + data: {}, + position: { x: 400, y: 0 }, + }, + { + id: '5', + type: 'multi', + data: {}, + position: { x: 600, y: -100 }, + }, + { + id: '6', + type: 'multi', + data: {}, + position: { x: 600, y: 100 }, + }, +]; + +const initEdges = [ + { + id: 'e1-2', + source: '1', + target: '2', + }, + { + id: 'e1-3', + source: '1', + target: '3', + }, + + { + id: 'e4a-5', + source: '4', + sourceHandle: 'a', + target: '5', + }, + { + id: 'e4b-5', + source: '4', + sourceHandle: 'b', + target: '6', + }, +]; + +const defaultEdgeOptions = { + animated: true, +}; + +const CustomNodeFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + return ( + + + + + + ); +}; + +export default CustomNodeFlow; diff --git a/packages/react/src/hooks/useHandleConnectionStatus.ts b/packages/react/src/hooks/useHandleConnectionStatus.ts new file mode 100644 index 00000000..062d5d93 --- /dev/null +++ b/packages/react/src/hooks/useHandleConnectionStatus.ts @@ -0,0 +1,85 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { Connection, HandleType } from '@xyflow/system'; + +import { useStore } from './useStore'; +import { useNodeId } from '../contexts/NodeIdContext'; + +type useHandleConnectionStatusParams = { + handleType: HandleType; + nodeId?: string; + handleId?: string | null; + onConnect?: (connections: Connection[]) => void; + onDisconnect?: (connections: Connection[]) => void; +}; + +function connectionsEqual(a: Connection[] | null, b: Connection[] | null) { + if (!a && !b) { + return true; + } + + if (!a || !b) { + return false; + } + + if (a.length !== b.length) { + return false; + } + + return a.every((connA) => + b.find( + (connB) => + connA.source === connB.source && + connA.target === connB.target && + connA.sourceHandle === connB.sourceHandle && + connA.targetHandle === connB.targetHandle + ) + ); +} + +export function useHandleConnectionStatus({ + handleType, + nodeId, + handleId = null, + onConnect, + onDisconnect, +}: useHandleConnectionStatusParams): { + connected: boolean; + connections: Connection[] | null; +} { + const _nodeId = useNodeId(); + const prevConnections = useRef(null); + const currentNodeId = nodeId || _nodeId; + + const connections = useStore( + (state) => state.connectionLookup.get(`${currentNodeId}-${handleType}-${handleId}`) || null, + connectionsEqual + ); + + useEffect(() => { + // we don't want to trigger the handlers for the initial render + if (prevConnections.current && prevConnections.current !== connections) { + if (prevConnections.current?.length > (connections?.length ?? 0)) { + const disconnect = prevConnections.current.filter( + (prevConnection) => !connections?.find((connection) => connection.source === prevConnection.source) + ); + onDisconnect?.(disconnect); + } else if (connections?.length) { + const connect = connections.filter( + (connection) => + !prevConnections.current?.find((prevConnection) => prevConnection.source === connection.source) + ); + onConnect?.(connect); + } + } + + prevConnections.current = connections ?? []; + }, [connections, onConnect, onDisconnect]); + + return useMemo( + () => ({ + connected: !!connections, + connections, + }), + [connections] + ); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2a693638..d9013b7c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -22,6 +22,7 @@ export { useStore, useStoreApi } from './hooks/useStore'; export { default as useOnViewportChange, type UseOnViewportChangeOptions } from './hooks/useOnViewportChange'; export { default as useOnSelectionChange, type UseOnSelectionChangeOptions } from './hooks/useOnSelectionChange'; export { default as useNodesInitialized, type UseNodesInitializedOptions } from './hooks/useNodesInitialized'; +export { useHandleConnectionStatus } from './hooks/useHandleConnectionStatus'; export { useNodeId } from './contexts/NodeIdContext'; export { applyNodeChanges, applyEdgeChanges, handleParentExpand } from './utils/changes'; diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 0f50f385..0237491a 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -10,7 +10,7 @@ import { } from '@xyflow/system'; import { applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; -import { updateNodesAndEdgesSelections } from './utils'; +import { updateConnectionLookup, updateNodesAndEdgesSelections } from './utils'; import getInitialState from './initialState'; import type { ReactFlowState, @@ -49,8 +49,12 @@ const createRFStore = ({ set({ nodes: nextNodes }); }, setEdges: (edges: Edge[]) => { - const { defaultEdgeOptions = {} } = get(); - set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) }); + const { defaultEdgeOptions = {}, connectionLookup } = get(); + const nextEdges = edges.map((e) => ({ ...defaultEdgeOptions, ...e })); + + updateConnectionLookup(connectionLookup, nextEdges); + + set({ edges: nextEdges }); }, // when the user works with an uncontrolled flow, // we set a flag `hasDefaultNodes` / `hasDefaultEdges` diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 48e5434f..1c843fe4 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -5,9 +5,11 @@ import { getNodesBounds, getViewportForBounds, Transform, + Connection, } from '@xyflow/system'; import type { Edge, Node, ReactFlowStore } from '../types'; +import { updateConnectionLookup } from './utils'; const getInitialState = ({ nodes = [], @@ -23,6 +25,7 @@ const getInitialState = ({ fitView?: boolean; } = {}): ReactFlowStore => { const nodeLookup = new Map(); + const connectionLookup = updateConnectionLookup(new Map(), edges); const nextNodes = updateNodes(nodes, nodeLookup, { nodeOrigin: [0, 0], elevateNodesOnSelect: false }); let transform: Transform = [0, 0, 1]; @@ -42,6 +45,7 @@ const getInitialState = ({ nodes: nextNodes, nodeLookup, edges: edges, + connectionLookup, onNodesChange: null, onEdgesChange: null, hasDefaultNodes: false, diff --git a/packages/react/src/store/utils.ts b/packages/react/src/store/utils.ts index 86cddde7..ac9e43fa 100644 --- a/packages/react/src/store/utils.ts +++ b/packages/react/src/store/utils.ts @@ -1,5 +1,6 @@ import type { StoreApi } from 'zustand'; import type { Edge, EdgeSelectionChange, Node, NodeSelectionChange, ReactFlowState } from '../types'; +import { Connection } from '@xyflow/system'; export function handleControlledSelectionChange( changes: NodeSelectionChange[] | EdgeSelectionChange[], @@ -42,3 +43,26 @@ export function updateNodesAndEdgesSelections({ changedNodes, changedEdges, get, onEdgesChange?.(changedEdges); } } + +export function updateConnectionLookup(lookup: Map, edges: Edge[]) { + lookup.clear(); + + edges.forEach((edge) => { + const { source, target, sourceHandle = null, targetHandle = null } = edge; + + if (source && target) { + const sourceKey = `${source}-source-${sourceHandle}`; + const targetKey = `${target}-target-${targetHandle}`; + + const prevSource = lookup.get(sourceKey); + const prevTarget = lookup.get(targetKey); + + const connection = { source, target, sourceHandle, targetHandle }; + + lookup.set(sourceKey, prevSource ? [...prevSource, connection] : [connection]); + lookup.set(targetKey, prevTarget ? [...prevTarget, connection] : [connection]); + } + }); + + return lookup; +} diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index c473cbd5..3ee1175d 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -24,6 +24,7 @@ import { type OnMoveEnd, type IsValidConnection, type UpdateConnection, + Connection, } from '@xyflow/system'; import type { @@ -49,6 +50,7 @@ export type ReactFlowStore = { nodes: Node[]; nodeLookup: Map; edges: Edge[]; + connectionLookup: Map; onNodesChange: OnNodesChange | null; onEdgesChange: OnEdgesChange | null; hasDefaultNodes: boolean;