diff --git a/examples/vite-app/package.json b/examples/vite-app/package.json index ff8d4877..6226fda0 100644 --- a/examples/vite-app/package.json +++ b/examples/vite-app/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite --port 3000 --open --host", + "dev": "vite --port 3000 --open --host --force", "serve": "vite serve --port 3000", "build": "vite build", "test:dev": "cypress open", @@ -14,7 +14,6 @@ "test-e2e": "start-server-and-test 'pnpm serve' http-get://localhost:3000 'pnpm test-e2e-cypress'" }, "dependencies": { - "@reactflow/node-resizer": "workspace:*", "classcat": "^5.0.3", "dagre": "^0.8.5", "localforage": "^1.10.0", diff --git a/examples/vite-app/src/examples/EasyConnect/CustomNode.tsx b/examples/vite-app/src/examples/EasyConnect/CustomNode.tsx index bd107c93..3e489362 100644 --- a/examples/vite-app/src/examples/EasyConnect/CustomNode.tsx +++ b/examples/vite-app/src/examples/EasyConnect/CustomNode.tsx @@ -1,6 +1,6 @@ import { Handle, NodeProps, Position, ReactFlowState, useStore } from 'reactflow'; -const connectionNodeIdSelector = (state: ReactFlowState) => state.connectionNodeId; +const connectionNodeIdSelector = (state: ReactFlowState) => state.connectionStartHandle?.nodeId; export default function CustomNode({ id }: NodeProps) { const connectionNodeId = useStore(connectionNodeIdSelector); diff --git a/packages/core/src/components/ConnectionLine/index.tsx b/packages/core/src/components/ConnectionLine/index.tsx index ee320216..0d083762 100644 --- a/packages/core/src/components/ConnectionLine/index.tsx +++ b/packages/core/src/components/ConnectionLine/index.tsx @@ -43,7 +43,7 @@ const ConnectionLine = ({ useCallback( (s: ReactFlowStore) => ({ fromNode: s.nodeInternals.get(nodeId), - handleId: s.connectionHandleId, + handleId: s.connectionStartHandle?.handleId, toX: (s.connectionPosition.x - s.transform[0]) / s.transform[2], toY: (s.connectionPosition.y - s.transform[1]) / s.transform[2], connectionMode: s.connectionMode, @@ -133,8 +133,8 @@ type ConnectionLineWrapperProps = { }; const selector = (s: ReactFlowState) => ({ - nodeId: s.connectionNodeId, - handleType: s.connectionHandleType, + nodeId: s.connectionStartHandle?.nodeId, + handleType: s.connectionStartHandle?.type, nodesConnectable: s.nodesConnectable, connectionStatus: s.connectionStatus, width: s.width, diff --git a/packages/core/src/components/Edges/wrapEdge.tsx b/packages/core/src/components/Edges/wrapEdge.tsx index e14af22a..1c780a9b 100644 --- a/packages/core/src/components/Edges/wrapEdge.tsx +++ b/packages/core/src/components/Edges/wrapEdge.tsx @@ -1,11 +1,10 @@ import { memo, useState, useMemo, useRef, type ComponentType, type KeyboardEvent } from 'react'; import cc from 'classcat'; -import { getMarkerId, elementSelectionKeys } from '@reactflow/utils'; +import { getMarkerId, elementSelectionKeys, XYHandle } from '@reactflow/utils'; import type { Connection } from '@reactflow/system'; import { useStoreApi } from '../../hooks/useStore'; import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions'; -import { handlePointerDown } from '../Handle/handler'; import { EdgeAnchor } from './EdgeAnchor'; import { getMouseHandler } from './utils'; import type { EdgeProps, WrapEdgeProps } from '../../types'; @@ -96,7 +95,22 @@ export default (EdgeComponent: ComponentType) => { return; } - const { edges, isValidConnection: isValidConnectionStore } = store.getState(); + const { + autoPanOnConnect, + domNode, + edges, + isValidConnection: isValidConnectionStore, + connectionMode, + connectionRadius, + transform, + lib, + onConnectStart, + onConnectEnd, + cancelConnection, + getNodes, + panBy, + updateConnection, + } = store.getState(); const nodeId = isSourceHandle ? target : source; const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null; const handleType = isSourceHandle ? 'target' : 'source'; @@ -104,6 +118,7 @@ export default (EdgeComponent: ComponentType) => { const isTarget = isSourceHandle; const edge = edges.find((e) => e.id === id)!; + const nodes = getNodes(); setUpdating(true); onEdgeUpdateStart?.(event, edge, handleType); @@ -115,17 +130,26 @@ export default (EdgeComponent: ComponentType) => { const onConnectEdge = (connection: Connection) => onEdgeUpdate?.(edge, connection); - handlePointerDown({ - event, + XYHandle.onPointerDown(event.nativeEvent, { + autoPanOnConnect, + connectionMode, + connectionRadius, + domNode, handleId, nodeId, - onConnect: onConnectEdge, + nodes, isTarget, - getState: store.getState, - setState: store.setState, - isValidConnection, edgeUpdaterType: handleType, + transform, + lib, + cancelConnection, + panBy, + isValidConnection, + onConnect: onConnectEdge, + onConnectStart, + onConnectEnd, onEdgeUpdateEnd: _onEdgeUpdateEnd, + updateConnection, }); }; diff --git a/packages/core/src/components/Handle/handler.ts b/packages/core/src/components/Handle/handler.ts deleted file mode 100644 index 4a533968..00000000 --- a/packages/core/src/components/Handle/handler.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from 'react'; -import { StoreApi } from 'zustand'; -import { - getHostForElement, - calcAutoPan, - getEventPosition, - pointToRendererPoint, - rendererPointToPoint, -} from '@reactflow/utils'; -import type { OnConnect, HandleType, Connection } from '@reactflow/system'; - -import { - ConnectionHandle, - getClosestHandle, - getConnectionStatus, - getHandleLookup, - getHandleType, - isValidHandle, - resetRecentHandle, - ValidConnectionFunc, -} from './utils'; -import type { ReactFlowState } from '../../types'; - -export function handlePointerDown({ - event, - handleId, - nodeId, - onConnect, - isTarget, - getState, - setState, - isValidConnection, - edgeUpdaterType, - onEdgeUpdateEnd, -}: { - event: ReactMouseEvent | ReactTouchEvent; - handleId: string | null; - nodeId: string; - onConnect: OnConnect; - isTarget: boolean; - getState: StoreApi['getState']; - setState: StoreApi['setState']; - isValidConnection: ValidConnectionFunc; - edgeUpdaterType?: HandleType; - onEdgeUpdateEnd?: (evt: MouseEvent | TouchEvent) => void; -}): void { - // when react-flow is used inside a shadow root we can't use document - const doc = getHostForElement(event.target as HTMLElement); - const { - connectionMode, - domNode, - autoPanOnConnect, - connectionRadius, - onConnectStart, - panBy, - getNodes, - cancelConnection, - } = getState(); - let autoPanId = 0; - let closestHandle: ConnectionHandle | null; - - const { x, y } = getEventPosition(event.nativeEvent); - const clickedHandle = doc?.elementFromPoint(x, y); - const handleType = getHandleType(edgeUpdaterType, clickedHandle); - const containerBounds = domNode?.getBoundingClientRect(); - - if (!containerBounds || !handleType) { - return; - } - - let prevActiveHandle: Element; - let connectionPosition = getEventPosition(event.nativeEvent, containerBounds); - let autoPanStarted = false; - let connection: Connection | null = null; - let isValid = false; - let handleDomNode: Element | null = null; - - 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 (!autoPanOnConnect) { - return; - } - const [xMovement, yMovement] = calcAutoPan(connectionPosition, containerBounds); - - panBy({ x: xMovement, y: yMovement }); - autoPanId = requestAnimationFrame(autoPan); - }; - - setState({ - connectionPosition, - connectionStatus: null, - // connectionNodeId etc will be removed in the next major in favor of connectionStartHandle - connectionNodeId: nodeId, - connectionHandleId: handleId, - connectionHandleType: handleType, - connectionStartHandle: { - nodeId, - handleId, - type: handleType, - }, - connectionEndHandle: null, - }); - - onConnectStart?.(event, { nodeId, handleId, handleType }); - - function onPointerMove(event: MouseEvent | TouchEvent) { - const { transform } = getState(); - - connectionPosition = getEventPosition(event, containerBounds); - closestHandle = getClosestHandle( - pointToRendererPoint(connectionPosition, transform, false, [1, 1]), - connectionRadius, - handleLookup - ); - - if (!autoPanStarted) { - autoPan(); - autoPanStarted = true; - } - - const result = isValidHandle( - event, - closestHandle, - connectionMode, - nodeId, - handleId, - isTarget ? 'target' : 'source', - isValidConnection, - doc - ); - - handleDomNode = result.handleDomNode; - connection = result.connection; - isValid = result.isValid; - - setState({ - connectionPosition: - closestHandle && isValid - ? rendererPointToPoint( - { - x: closestHandle.x, - y: closestHandle.y, - }, - transform - ) - : connectionPosition, - connectionStatus: getConnectionStatus(!!closestHandle, isValid), - connectionEndHandle: result.endHandle, - }); - - if (!closestHandle && !isValid && !handleDomNode) { - return resetRecentHandle(prevActiveHandle); - } - - if (connection.source !== connection.target && handleDomNode) { - resetRecentHandle(prevActiveHandle); - prevActiveHandle = handleDomNode; - // @todo: remove the old class names "react-flow__handle-" in the next major version - handleDomNode.classList.add('connecting', 'react-flow__handle-connecting'); - handleDomNode.classList.toggle('valid', isValid); - handleDomNode.classList.toggle('react-flow__handle-valid', isValid); - } - } - - function onPointerUp(event: MouseEvent | TouchEvent) { - if ((closestHandle || handleDomNode) && connection && isValid) { - onConnect?.(connection); - } - - // it's important to get a fresh reference from the store here - // in order to get the latest state of onConnectEnd - getState().onConnectEnd?.(event); - - if (edgeUpdaterType) { - onEdgeUpdateEnd?.(event); - } - - resetRecentHandle(prevActiveHandle); - cancelConnection(); - cancelAnimationFrame(autoPanId); - autoPanStarted = false; - isValid = false; - connection = null; - handleDomNode = null; - - doc.removeEventListener('mousemove', onPointerMove as EventListener); - doc.removeEventListener('mouseup', onPointerUp as EventListener); - - doc.removeEventListener('touchmove', onPointerMove as EventListener); - doc.removeEventListener('touchend', onPointerUp as EventListener); - } - - doc.addEventListener('mousemove', onPointerMove as EventListener); - doc.addEventListener('mouseup', onPointerUp as EventListener); - - doc.addEventListener('touchmove', onPointerMove as EventListener); - doc.addEventListener('touchend', onPointerUp as EventListener); -} diff --git a/packages/core/src/components/Handle/index.tsx b/packages/core/src/components/Handle/index.tsx index 0ba6c400..2c7fe7d8 100644 --- a/packages/core/src/components/Handle/index.tsx +++ b/packages/core/src/components/Handle/index.tsx @@ -2,16 +2,12 @@ import { memo, HTMLAttributes, forwardRef, MouseEvent as ReactMouseEvent, TouchE import cc from 'classcat'; import { shallow } from 'zustand/shallow'; import { errorMessages, Position, type HandleProps, type Connection, type HandleType } from '@reactflow/system'; -import { getHostForElement, isMouseEvent } from '@reactflow/utils'; +import { XYHandle, getHostForElement, isMouseEvent } from '@reactflow/utils'; import { useStore, useStoreApi } from '../../hooks/useStore'; import { useNodeId } from '../../contexts/NodeIdContext'; -import { handlePointerDown } from './handler'; import { addEdge } from '../../utils/'; import { type ReactFlowState } from '../../types'; -import { isValidHandle } from './utils'; - -const alwaysValid = () => true; export type HandleComponentProps = HandleProps & Omit, 'id'>; @@ -95,15 +91,26 @@ const Handle = forwardRef( isConnectableStart && ((isMouseTriggered && (event as ReactMouseEvent).button === 0) || !isMouseTriggered) ) { - handlePointerDown({ - event, + const currentStore = store.getState(); + + XYHandle.onPointerDown(event.nativeEvent, { + autoPanOnConnect: currentStore.autoPanOnConnect, + connectionMode: currentStore.connectionMode, + connectionRadius: currentStore.connectionRadius, + domNode: currentStore.domNode, + nodes: currentStore.getNodes(), + transform: currentStore.transform, + lib: currentStore.lib, + isTarget, handleId, nodeId, + panBy: currentStore.panBy, + cancelConnection: currentStore.cancelConnection, + onConnectStart: currentStore.onConnectStart, + onConnectEnd: currentStore.onConnectEnd, + updateConnection: currentStore.updateConnection, onConnect: onConnectExtended, - isTarget, - getState: store.getState, - setState: store.setState, - isValidConnection: isValidConnection || store.getState().isValidConnection || alwaysValid, + isValidConnection: isValidConnection || currentStore.isValidConnection, }); } @@ -121,6 +128,7 @@ const Handle = forwardRef( connectionClickStartHandle, connectionMode, isValidConnection: isValidConnectionStore, + lib, } = store.getState(); if (!nodeId || (!connectionClickStartHandle && !isConnectableStart)) { @@ -128,27 +136,27 @@ const Handle = forwardRef( } if (!connectionClickStartHandle) { - onClickConnectStart?.(event, { nodeId, handleId, handleType: type }); + onClickConnectStart?.(event.nativeEvent, { nodeId, handleId, handleType: type }); store.setState({ connectionClickStartHandle: { nodeId, type, handleId } }); return; } const doc = getHostForElement(event.target as HTMLElement); - const isValidConnectionHandler = isValidConnection || isValidConnectionStore || alwaysValid; - const { connection, isValid } = isValidHandle( - event.nativeEvent, - { + const isValidConnectionHandler = isValidConnection || isValidConnectionStore; + const { connection, isValid } = XYHandle.isValid(event.nativeEvent, { + handle: { nodeId, id: handleId, type, }, connectionMode, - connectionClickStartHandle.nodeId, - connectionClickStartHandle.handleId || null, - connectionClickStartHandle.type, - isValidConnectionHandler, - doc - ); + fromNodeId: connectionClickStartHandle.nodeId, + fromHandleId: connectionClickStartHandle.handleId || null, + fromType: connectionClickStartHandle.type, + isValidConnection: isValidConnectionHandler, + doc, + lib, + }); if (isValid) { onConnectExtended(connection); diff --git a/packages/core/src/store/index.ts b/packages/core/src/store/index.ts index 2c31ad3d..6aae84b0 100644 --- a/packages/core/src/store/index.ts +++ b/packages/core/src/store/index.ts @@ -1,12 +1,6 @@ import { createStore } from 'zustand'; import { clampPosition, getDimensions, fitView, getHandleBounds } from '@reactflow/utils'; -import { - internalsSymbol, - type NodeDimensionUpdate, - type CoordinateExtent, - type NodeDragItem, - type XYPosition, -} from '@reactflow/system'; +import { internalsSymbol, type CoordinateExtent } from '@reactflow/system'; import { applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; import { createNodeInternals, updateAbsoluteNodePositions, updateNodesAndEdgesSelections } from './utils'; @@ -20,7 +14,6 @@ import type { NodeSelectionChange, NodePositionChange, UnselectNodesAndEdgesParams, - NodeChange, } from '../types'; const createRFStore = () => @@ -48,7 +41,7 @@ const createRFStore = () => set({ nodeInternals, edges: nextEdges, hasDefaultNodes, hasDefaultEdges }); }, - updateNodeDimensions: (updates: NodeDimensionUpdate[]) => { + updateNodeDimensions: (updates) => { const { onNodesChange, nodeInternals, @@ -132,7 +125,7 @@ const createRFStore = () => onNodesChange?.(changes); } }, - updateNodePositions: (nodeDragItems: NodeDragItem[] | Node[], positionChanged = true, dragging = false) => { + updateNodePositions: (nodeDragItems, positionChanged = true, dragging = false) => { const { triggerNodeChanges } = get(); const changes = nodeDragItems.map((node) => { @@ -153,7 +146,7 @@ const createRFStore = () => triggerNodeChanges(changes); }, - triggerNodeChanges: (changes: NodeChange[]) => { + triggerNodeChanges: (changes) => { const { onNodesChange, nodeInternals, hasDefaultNodes, nodeOrigin, getNodes, elevateNodesOnSelect } = get(); if (changes?.length) { @@ -167,7 +160,7 @@ const createRFStore = () => } }, - addSelectedNodes: (selectedNodeIds: string[]) => { + addSelectedNodes: (selectedNodeIds) => { const { multiSelectionActive, edges, getNodes } = get(); let changedNodes: NodeSelectionChange[]; let changedEdges: EdgeSelectionChange[] | null = null; @@ -186,7 +179,7 @@ const createRFStore = () => set, }); }, - addSelectedEdges: (selectedEdgeIds: string[]) => { + addSelectedEdges: (selectedEdgeIds) => { const { multiSelectionActive, edges, getNodes } = get(); let changedEdges: EdgeSelectionChange[]; let changedNodes: NodeSelectionChange[] | null = null; @@ -225,19 +218,19 @@ const createRFStore = () => set, }); }, - setMinZoom: (minZoom: number) => { + setMinZoom: (minZoom) => { const { panZoom, maxZoom } = get(); panZoom?.setScaleExtent([minZoom, maxZoom]); set({ minZoom }); }, - setMaxZoom: (maxZoom: number) => { + setMaxZoom: (maxZoom) => { const { panZoom, minZoom } = get(); panZoom?.setScaleExtent([minZoom, maxZoom]); set({ maxZoom }); }, - setTranslateExtent: (translateExtent: CoordinateExtent) => { + setTranslateExtent: (translateExtent) => { get().panZoom?.setTranslateExtent(translateExtent); set({ translateExtent }); @@ -260,7 +253,7 @@ const createRFStore = () => set, }); }, - setNodeExtent: (nodeExtent: CoordinateExtent) => { + setNodeExtent: (nodeExtent) => { const { nodeInternals } = get(); nodeInternals.forEach((node) => { @@ -272,7 +265,7 @@ const createRFStore = () => nodeInternals: new Map(nodeInternals), }); }, - panBy: (delta: XYPosition): boolean => { + panBy: (delta): boolean => { const { transform, width, height, panZoom, translateExtent } = get(); if (!panZoom || (!delta.x && !delta.y)) { @@ -300,13 +293,22 @@ const createRFStore = () => }, cancelConnection: () => set({ - connectionNodeId: initialState.connectionNodeId, - connectionHandleId: initialState.connectionHandleId, - connectionHandleType: initialState.connectionHandleType, connectionStatus: initialState.connectionStatus, connectionStartHandle: initialState.connectionStartHandle, connectionEndHandle: initialState.connectionEndHandle, }), + updateConnection: (params) => { + const { connectionStatus, connectionStartHandle, connectionEndHandle, connectionPosition } = get(); + + const currentConnection = { + connectionPosition: params.connectionPosition ?? connectionPosition, + connectionStatus: params.connectionStatus ?? connectionStatus, + connectionStartHandle: params.connectionStartHandle ?? connectionStartHandle, + connectionEndHandle: params.connectionEndHandle ?? connectionEndHandle, + }; + + set(currentConnection); + }, reset: () => set({ ...initialState }), })); diff --git a/packages/core/src/store/initialState.ts b/packages/core/src/store/initialState.ts index 28e0d465..4a34de55 100644 --- a/packages/core/src/store/initialState.ts +++ b/packages/core/src/store/initialState.ts @@ -22,9 +22,6 @@ const initialState: ReactFlowStore = { nodesSelectionActive: false, userSelectionActive: false, userSelectionRect: null, - connectionNodeId: null, - connectionHandleId: null, - connectionHandleType: 'source', connectionPosition: { x: 0, y: 0 }, connectionStatus: null, connectionMode: ConnectionMode.Strict, @@ -61,6 +58,8 @@ const initialState: ReactFlowStore = { connectionRadius: 20, onError: devWarn, isValidConnection: undefined, + + lib: 'react', }; export default initialState; diff --git a/packages/core/src/types/component-props.ts b/packages/core/src/types/component-props.ts index f8d05a20..760f1338 100644 --- a/packages/core/src/types/component-props.ts +++ b/packages/core/src/types/component-props.ts @@ -3,6 +3,8 @@ import type { ConnectionMode, ConnectionLineType, OnConnect, + OnConnectStart, + OnConnectEnd, CoordinateExtent, KeyCode, PanOnScrollMode, @@ -16,6 +18,7 @@ import type { HandleType, SelectionMode, OnError, + IsValidConnection, } from '@reactflow/system'; import type { @@ -25,8 +28,6 @@ import type { Node, Edge, ConnectionLineComponent, - OnConnectStart, - OnConnectEnd, OnEdgeUpdateFunc, OnInit, DefaultEdgeOptions, @@ -40,7 +41,6 @@ import type { SelectionDragHandler, EdgeMouseHandler, } from '.'; -import { ValidConnectionFunc } from '../components/Handle/utils'; export type ReactFlowProps = HTMLAttributes & { nodes?: Node[]; @@ -148,7 +148,7 @@ export type ReactFlowProps = HTMLAttributes & { autoPanOnConnect?: boolean; connectionRadius?: number; onError?: OnError; - isValidConnection?: ValidConnectionFunc; + isValidConnection?: IsValidConnection; }; export type ReactFlowRefType = HTMLDivElement; diff --git a/packages/core/src/types/general.ts b/packages/core/src/types/general.ts index ec86e7e8..b8bb015c 100644 --- a/packages/core/src/types/general.ts +++ b/packages/core/src/types/general.ts @@ -1,15 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { - MouseEvent as ReactMouseEvent, - TouchEvent as ReactTouchEvent, - ComponentType, - MemoExoticComponent, -} from 'react'; +import type { ComponentType, MemoExoticComponent } from 'react'; import { FitViewParamsBase, FitViewOptionsBase, NodeProps, - OnConnectStartParams, ZoomInOut, ZoomTo, SetViewport, @@ -18,7 +12,6 @@ import { SetCenter, FitBounds, Project, - Connection, } from '@reactflow/system'; import type { NodeChange, EdgeChange, Node, WrapNodeProps, Edge, EdgeProps, WrapEdgeProps, ReactFlowInstance } from '.'; @@ -46,16 +39,11 @@ export type OnSelectionChangeParams = { export type OnSelectionChangeFunc = (params: OnSelectionChangeParams) => void; -export type OnConnectStart = (event: ReactMouseEvent | ReactTouchEvent, params: OnConnectStartParams) => void; -export type OnConnectEnd = (event: MouseEvent | TouchEvent) => void; - export type FitViewParams = FitViewParamsBase; export type FitViewOptions = FitViewOptionsBase; export type FitView = (fitViewOptions?: FitViewOptions) => boolean; export type OnInit = (reactFlowInstance: ReactFlowInstance) => void; -export type IsValidConnection = (edge: Edge | Connection) => boolean; - export type ViewportHelperFunctions = { zoomIn: ZoomInOut; zoomOut: ZoomInOut; diff --git a/packages/core/src/types/store.ts b/packages/core/src/types/store.ts index dbd7741d..a0760329 100644 --- a/packages/core/src/types/store.ts +++ b/packages/core/src/types/store.ts @@ -2,7 +2,6 @@ import { ConnectionMode, type ConnectionStatus, type CoordinateExtent, - type HandleType, type NodeDimensionUpdate, type UpdateNodePositions, type NodeOrigin, @@ -16,11 +15,15 @@ import { type XYPosition, type PanZoomInstance, type PanBy, - OnNodeDrag, - OnSelectionDrag, - OnMoveStart, - OnMove, - OnMoveEnd, + type OnConnectStart, + type OnConnectEnd, + type OnNodeDrag, + type OnSelectionDrag, + type OnMoveStart, + type OnMove, + type OnMoveEnd, + type IsValidConnection, + type UpdateConnection, } from '@reactflow/system'; import type { @@ -30,15 +33,12 @@ import type { OnNodesChange, OnEdgesChange, NodeInternals, - OnConnectStart, - OnConnectEnd, DefaultEdgeOptions, FitViewOptions, OnNodesDelete, OnEdgesDelete, OnSelectionChangeFunc, UnselectNodesAndEdgesParams, - IsValidConnection, } from '.'; export type ReactFlowStore = { @@ -67,9 +67,6 @@ export type ReactFlowStore = { userSelectionActive: boolean; userSelectionRect: SelectionRect | null; - connectionNodeId: string | null; - connectionHandleId: string | null; - connectionHandleType: HandleType | null; connectionPosition: XYPosition; connectionStatus: ConnectionStatus | null; connectionMode: ConnectionMode; @@ -135,6 +132,8 @@ export type ReactFlowStore = { connectionRadius: number; isValidConnection?: IsValidConnection; + + lib: string; }; export type ReactFlowActions = { @@ -153,6 +152,8 @@ export type ReactFlowActions = { setTranslateExtent: (translateExtent: CoordinateExtent) => void; setNodeExtent: (nodeExtent: CoordinateExtent) => void; cancelConnection: () => void; + // @todo can this be reused by system? + updateConnection: UpdateConnection; reset: () => void; triggerNodeChanges: (changes: NodeChange[]) => void; panBy: PanBy; diff --git a/packages/svelte/src/lib/components/ConnectionLine/ConnectionLine.svelte b/packages/svelte/src/lib/components/ConnectionLine/ConnectionLine.svelte index d49d2412..770b7241 100644 --- a/packages/svelte/src/lib/components/ConnectionLine/ConnectionLine.svelte +++ b/packages/svelte/src/lib/components/ConnectionLine/ConnectionLine.svelte @@ -8,7 +8,7 @@ {#if $connectionPath} - + diff --git a/packages/svelte/src/lib/components/Handle/Handle.svelte b/packages/svelte/src/lib/components/Handle/Handle.svelte index d26e98df..044b387a 100644 --- a/packages/svelte/src/lib/components/Handle/Handle.svelte +++ b/packages/svelte/src/lib/components/Handle/Handle.svelte @@ -2,9 +2,8 @@ import { getContext, createEventDispatcher } from 'svelte'; import cc from 'classcat'; import { Position, type Connection } from '@reactflow/system'; - import { isMouseEvent } from '@reactflow/utils'; + import { XYHandle, isMouseEvent } from '@reactflow/utils'; - import { handlePointerDown } from './handler'; import { useStore } from '$lib/store'; import type { HandleComponentProps } from '$lib/types'; @@ -23,7 +22,8 @@ const handleId = id || null; const dispatch = createEventDispatcher(); - + + const store = useStore(); const { connectionMode, domNode, @@ -31,11 +31,12 @@ connectionRadius, transform, isValidConnection, + lib, addEdge, panBy, cancelConnection, updateConnection, - } = useStore(); + } = store; function dispatchEvent(eventName: string, params?: Connection) { dispatch(eventName, params || { nodeId, handleId, type }); @@ -50,23 +51,22 @@ const isMouseTriggered = isMouseEvent(event); if ((isMouseTriggered && event.button === 0) || !isMouseTriggered) { - handlePointerDown({ - event, + XYHandle.onPointerDown(event, { handleId, nodeId, isTarget, connectionRadius: $connectionRadius, domNode: $domNode, - nodes, + nodes: $nodes, connectionMode: $connectionMode, - transform, + transform: $transform, + lib: $lib, + autoPanOnConnect: true, isValidConnection: $isValidConnection, - onConnect: onConnectExtended, updateConnection, cancelConnection, panBy, - onConnectStart: () => dispatchEvent('connect:start'), - onConnectEnd: () => dispatchEvent('connect:end') + onConnect: onConnectExtended, }); } } diff --git a/packages/svelte/src/lib/components/Handle/handler.ts b/packages/svelte/src/lib/components/Handle/handler.ts deleted file mode 100644 index 0275f333..00000000 --- a/packages/svelte/src/lib/components/Handle/handler.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { get, type Writable } from 'svelte/store'; -import { - getHostForElement, - calcAutoPan, - getEventPosition, - pointToRendererPoint, - rendererPointToPoint -} from '@reactflow/utils'; -import type { - OnConnect, - HandleType, - Connection, - ConnectionMode, - XYPosition, - Transform -} from '@reactflow/system'; - -import { - getClosestHandle, - getConnectionStatus, - getHandleLookup, - getHandleType, - isValidHandle, - resetRecentHandle, - type ConnectionHandle -} from './utils'; -import type { ConnectionData, IsValidConnection, Node } from '$lib/types'; - -export function handlePointerDown({ - event, - handleId, - nodeId, - onConnect, - domNode, - nodes, - connectionMode, - connectionRadius, - isTarget, - transform: transformStore, - panBy, - updateConnection, - cancelConnection, - isValidConnection, - edgeUpdaterType, - onEdgeUpdateEnd, - onConnectStart, - onConnectEnd -}: { - event: MouseEvent | TouchEvent; - handleId: string | null; - nodeId: string; - onConnect: OnConnect; - isTarget: boolean; - connectionMode: ConnectionMode; - domNode: HTMLDivElement | null; - nodes: Writable; - connectionRadius: number; - isValidConnection: IsValidConnection; - transform: Writable; - updateConnection: (connection: Partial) => void; - cancelConnection: () => void; - panBy: (delta: XYPosition) => void; - edgeUpdaterType?: HandleType; - onEdgeUpdateEnd?: (evt: MouseEvent | TouchEvent) => void; - onConnectStart: () => void; - onConnectEnd: () => void; -}): void { - // when svelte-flow is used inside a shadow root we can't use document - const doc = getHostForElement(event.target as HTMLElement); - let autoPanId = 0; - let prevClosestHandle: ConnectionHandle | null; - - const { x, y } = getEventPosition(event); - const clickedHandle = doc?.elementFromPoint(x, y); - const handleType = getHandleType(edgeUpdaterType, clickedHandle); - const containerBounds = domNode?.getBoundingClientRect(); - - if (!containerBounds || !handleType) { - return; - } - - let prevActiveHandle: Element; - let connectionPosition = getEventPosition(event, containerBounds); - let autoPanStarted = false; - let connection: Connection | null = null; - let isValid = false; - let handleDomNode: Element | null = null; - - const autoPanOnConnect = true; - - const handleLookup = getHandleLookup({ - nodes: get(nodes), - 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 => { - // @todd add prop - if (!autoPanOnConnect) { - return; - } - const [xMovement, yMovement] = calcAutoPan(connectionPosition, containerBounds); - - panBy({ x: xMovement, y: yMovement }); - autoPanId = requestAnimationFrame(autoPan); - }; - - updateConnection({ - position: connectionPosition, - nodeId, - handleId, - handleType, - status: null - }); - - // onConnectStart?.(event, { nodeId, handleId, handleType }); - onConnectStart(); - - function onPointerMove(event: MouseEvent | TouchEvent) { - const transform = get(transformStore); - connectionPosition = getEventPosition(event, containerBounds); - - prevClosestHandle = getClosestHandle( - pointToRendererPoint(connectionPosition, transform, false, [1, 1]), - connectionRadius, - handleLookup - ); - - if (!autoPanStarted) { - autoPan(); - autoPanStarted = true; - } - - const result = isValidHandle( - event, - prevClosestHandle, - connectionMode, - nodeId, - handleId, - isTarget ? 'target' : 'source', - isValidConnection, - doc, - get(nodes) - ); - - handleDomNode = result.handleDomNode; - connection = result.connection; - isValid = result.isValid; - - updateConnection({ - position: - prevClosestHandle && isValid - ? rendererPointToPoint( - { - x: prevClosestHandle.x, - y: prevClosestHandle.y - }, - transform - ) - : connectionPosition, - status: getConnectionStatus(!!prevClosestHandle, isValid) - }); - - if (!prevClosestHandle && !isValid && !handleDomNode) { - return resetRecentHandle(prevActiveHandle); - } - - if (connection.source !== connection.target && handleDomNode) { - resetRecentHandle(prevActiveHandle); - prevActiveHandle = handleDomNode; - // @todo: remove the old class names "svelte-flow__handle-" in the next major version - handleDomNode.classList.add('connecting'); - handleDomNode.classList.toggle('valid', isValid); - } - } - - function onPointerUp(event: MouseEvent | TouchEvent) { - if ((prevClosestHandle || handleDomNode) && connection && isValid) { - onConnect?.(connection); - } - - // it's important to get a fresh reference from the store here - // in order to get the latest state of onConnectEnd - - onConnectEnd(); - - if (edgeUpdaterType) { - onEdgeUpdateEnd?.(event); - } - - resetRecentHandle(prevActiveHandle); - cancelConnection(); - cancelAnimationFrame(autoPanId); - autoPanStarted = false; - isValid = false; - connection = null; - handleDomNode = null; - - doc.removeEventListener('mousemove', onPointerMove as EventListener); - doc.removeEventListener('mouseup', onPointerUp as EventListener); - - doc.removeEventListener('touchmove', onPointerMove as EventListener); - doc.removeEventListener('touchend', onPointerUp as EventListener); - } - - doc.addEventListener('mousemove', onPointerMove as EventListener); - doc.addEventListener('mouseup', onPointerUp as EventListener); - - doc.addEventListener('touchmove', onPointerMove as EventListener); - doc.addEventListener('touchend', onPointerUp as EventListener); -} diff --git a/packages/svelte/src/lib/components/Handle/utils.ts b/packages/svelte/src/lib/components/Handle/utils.ts deleted file mode 100644 index 18322718..00000000 --- a/packages/svelte/src/lib/components/Handle/utils.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { internalsSymbol, ConnectionMode, type ConnectionStatus } from '@reactflow/system'; -import type { Connection, HandleType, XYPosition, NodeHandleBounds } from '@reactflow/system'; -import { getEventPosition } from '@reactflow/utils'; - -import type { IsValidConnection, Node } from '$lib/types'; - -export type ConnectionHandle = { - id: string | null; - type: HandleType; - nodeId: string; - x: number; - y: number; -}; - -export type ValidConnectionFunc = (connection: Connection) => boolean; - -// this functions collects all handles and adds an absolute position -// so that we can later find the closest handle to the mouse position -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, - x: (node.positionAbsolute?.x ?? 0) + h.x + h.width / 2, - y: (node.positionAbsolute?.y ?? 0) + h.y + h.height / 2 - }); - } - return res; - }, []); -} - -export function getClosestHandle( - pos: XYPosition, - connectionRadius: number, - handles: ConnectionHandle[] -): ConnectionHandle | null { - let closestHandle: ConnectionHandle | null = null; - let minDistance = Infinity; - - handles.forEach((handle) => { - const distance = Math.sqrt(Math.pow(handle.x - pos.x, 2) + Math.pow(handle.y - pos.y, 2)); - if (distance <= connectionRadius && distance < minDistance) { - minDistance = distance; - closestHandle = handle; - } - }); - - return closestHandle; -} - -type Result = { - handleDomNode: Element | null; - isValid: boolean; - connection: Connection; -}; - -const nullConnection: Connection = { - source: null, - target: null, - sourceHandle: null, - targetHandle: null -}; - -// checks if and returns connection in fom of an object { source: 123, target: 312 } -export function isValidHandle( - event: MouseEvent | TouchEvent, - handle: Pick | null, - connectionMode: ConnectionMode, - fromNodeId: string, - fromHandleId: string | null, - fromType: string, - isValidConnection: IsValidConnection, - doc: Document | ShadowRoot, - nodes: Node[] -) { - const isTarget = fromType === 'target'; - const handleDomNode = doc.querySelector( - `.svelte-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]` - ); - const { x, y } = getEventPosition(event); - const handleBelow = doc.elementFromPoint(x, y); - const handleToCheck = handleBelow?.classList.contains('svelte-flow__handle') - ? handleBelow - : handleDomNode; - - const result: Result = { - handleDomNode: handleToCheck, - isValid: false, - connection: nullConnection - }; - - if (handleToCheck) { - const handleType = getHandleType(undefined, handleToCheck); - const handleNodeId = handleToCheck.getAttribute('data-nodeid'); - const handleId = handleToCheck.getAttribute('data-handleid'); - - const connection: Connection = { - source: isTarget ? handleNodeId : fromNodeId, - sourceHandle: isTarget ? handleId : fromHandleId, - target: isTarget ? fromNodeId : handleNodeId, - targetHandle: isTarget ? fromHandleId : handleId - }; - - result.connection = connection; - - // in strict mode we don't allow target to target or source to source connections - const isValid = - connectionMode === ConnectionMode.Strict - ? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target') - : handleNodeId !== fromNodeId || handleId !== fromHandleId; - - if (isValid) { - const fromNode: Node | undefined = nodes.find((n) => n.id === connection.source); - const toNode: Node | undefined = nodes.find((n) => n.id === connection.target); - - if (fromNode && toNode) result.isValid = isValidConnection(connection, { fromNode, toNode }); - } - } - - 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; - }, []); -} - -export function getHandleType( - edgeUpdaterType: HandleType | undefined, - handleDomNode: Element | null -): HandleType | null { - if (edgeUpdaterType) { - return edgeUpdaterType; - } else if (handleDomNode?.classList.contains('target')) { - return 'target'; - } else if (handleDomNode?.classList.contains('source')) { - return 'source'; - } - - return null; -} - -export function resetRecentHandle(handleDomNode: Element): void { - handleDomNode?.classList.remove('valid', 'connecting'); -} - -export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleValid: boolean) { - let connectionStatus = null; - - if (isHandleValid) { - connectionStatus = 'valid'; - } else if (isInsideConnectionRadius && !isHandleValid) { - connectionStatus = 'invalid'; - } - - return connectionStatus as ConnectionStatus; -} diff --git a/packages/svelte/src/lib/container/Pane/Pane.svelte b/packages/svelte/src/lib/container/Pane/Pane.svelte index c915987a..2ea2696f 100644 --- a/packages/svelte/src/lib/container/Pane/Pane.svelte +++ b/packages/svelte/src/lib/container/Pane/Pane.svelte @@ -36,6 +36,11 @@ import { useStore } from '$lib/store'; import { getConnectedEdges } from '$lib/utils'; import type { Node, Edge } from '$lib/types'; + import type { PaneProps } from './types'; + + type $$Props = PaneProps; + + export let panOnDrag: $$Props['panOnDrag'] = undefined; const dispatch = createEventDispatcher(); const { @@ -133,9 +138,9 @@ // We only want to trigger click functions when in selection mode if // the user did not move the mouse. - // if (!userSelectionActive && userSelectionRect && event.target === container.current) { - // onClick?.(event); - // } + if (!isSelecting && $selectionRectMode === 'user' && event.target === container) { + onClick?.(event); + } selectionRect.set(null); if (selectedNodes.length > 0) { @@ -155,10 +160,10 @@ }; const onContextMenu = (event: MouseEvent) => { - // if (Array.isArray(panOnDrag) && panOnDrag?.includes(2)) { - // event.preventDefault(); - // return; - // } + if (Array.isArray(panOnDrag) && panOnDrag?.includes(2)) { + event.preventDefault(); + return; + } dispatch('pane:contextmenu', event); }; diff --git a/packages/svelte/src/lib/container/Pane/types.ts b/packages/svelte/src/lib/container/Pane/types.ts new file mode 100644 index 00000000..9495397b --- /dev/null +++ b/packages/svelte/src/lib/container/Pane/types.ts @@ -0,0 +1,3 @@ +export type PaneProps = { + panOnDrag?: boolean | number[]; +}; diff --git a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte index bd8ba901..ac9df3f7 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte +++ b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte @@ -167,7 +167,7 @@ {panOnScroll} {panOnDrag} > - + diff --git a/packages/svelte/src/lib/container/SvelteFlow/types.ts b/packages/svelte/src/lib/container/SvelteFlow/types.ts index ed078da9..baf45f58 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/types.ts +++ b/packages/svelte/src/lib/container/SvelteFlow/types.ts @@ -11,17 +11,11 @@ import type { OnMove, OnMoveEnd, CoordinateExtent, - PanOnScrollMode + PanOnScrollMode, + IsValidConnection } from '@reactflow/system'; -import type { - Edge, - Node, - NodeTypes, - KeyDefinition, - EdgeTypes, - IsValidConnection -} from '$lib/types'; +import type { Edge, Node, NodeTypes, KeyDefinition, EdgeTypes } from '$lib/types'; export type SvelteFlowProps = DOMAttributes & { id?: string; diff --git a/packages/svelte/src/lib/store/connection-path.ts b/packages/svelte/src/lib/store/connection-path.ts index 317d260e..a707f533 100644 --- a/packages/svelte/src/lib/store/connection-path.ts +++ b/packages/svelte/src/lib/store/connection-path.ts @@ -21,20 +21,23 @@ export function getConnectionPath(store: SvelteFlowStoreState) { store.transform ], ([connection, connectionLineType, connectionMode, nodes, transform]) => { - if (!connection.nodeId) { + if (!connection.connectionStartHandle?.nodeId) { return null; } - const fromNode = nodes.find((n) => n.id === connection.nodeId); + const fromNode = nodes.find((n) => n.id === connection.connectionStartHandle?.nodeId); const fromHandleBounds = fromNode?.[internalsSymbol]?.handleBounds; - const handleBoundsStrict = fromHandleBounds?.[connection.handleType || 'source'] || []; + const handleBoundsStrict = + fromHandleBounds?.[connection.connectionStartHandle.type || 'source'] || []; const handleBoundsLoose = handleBoundsStrict ? handleBoundsStrict - : fromHandleBounds?.[connection.handleType === 'source' ? 'target' : 'source']!; + : fromHandleBounds?.[ + connection?.connectionStartHandle?.type === 'source' ? 'target' : 'source' + ]!; const handleBounds = connectionMode === ConnectionMode.Strict ? handleBoundsStrict : handleBoundsLoose; - const fromHandle = connection.handleId - ? handleBounds.find((d) => d.id === connection.handleId) + const fromHandle = connection.connectionStartHandle?.handleId + ? handleBounds.find((d) => d.id === connection.connectionStartHandle?.handleId) : handleBounds[0]; const fromHandleX = fromHandle ? fromHandle.x + fromHandle.width / 2 @@ -49,8 +52,8 @@ export function getConnectionPath(store: SvelteFlowStoreState) { sourceX: fromX, sourceY: fromY, sourcePosition: fromPosition, - targetX: ((connection.position?.x ?? 0) - transform[0]) / transform[2], - targetY: ((connection.position?.y ?? 0) - transform[1]) / transform[2], + targetX: ((connection.connectionPosition?.x ?? 0) - transform[0]) / transform[2], + targetY: ((connection.connectionPosition?.y ?? 0) - transform[1]) / transform[2], targetPosition: toPosition }; diff --git a/packages/svelte/src/lib/store/index.ts b/packages/svelte/src/lib/store/index.ts index 838deab3..fe8262f0 100644 --- a/packages/svelte/src/lib/store/index.ts +++ b/packages/svelte/src/lib/store/index.ts @@ -7,7 +7,8 @@ import { type ViewportHelperFunctionOptions, type Connection, type XYPosition, - type CoordinateExtent + type CoordinateExtent, + type UpdateConnection } from '@reactflow/system'; import { createMarkerIds, @@ -304,21 +305,22 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore { return transformChanged; } - function updateConnection(connectionUpdate: Partial | null) { + const updateConnection: UpdateConnection = (update) => { const currentConnectionData = get(store.connection); + const nextConnectionData = currentConnectionData ? { ...initConnectionData, ...currentConnectionData, - ...connectionUpdate + ...update } : { ...initConnectionData, - ...connectionUpdate + ...update }; store.connection.set(nextConnectionData); - } + }; function cancelConnection() { updateConnection(initConnectionData); diff --git a/packages/svelte/src/lib/store/initial-store.ts b/packages/svelte/src/lib/store/initial-store.ts index 9dd586e5..d65a79f2 100644 --- a/packages/svelte/src/lib/store/initial-store.ts +++ b/packages/svelte/src/lib/store/initial-store.ts @@ -8,7 +8,8 @@ import { type SnapGrid, type MarkerProps, type PanZoomInstance, - type CoordinateExtent + type CoordinateExtent, + type IsValidConnection } from '@reactflow/system'; import DefaultNode from '$lib/components/nodes/DefaultNode.svelte'; @@ -18,23 +19,14 @@ import BezierEdge from '$lib/components/edges/BezierEdge.svelte'; import StraightEdge from '$lib/components/edges/StraightEdge.svelte'; import SmoothStepEdge from '$lib/components/edges/SmoothStepEdge.svelte'; import StepEdge from '$lib/components/edges/StepEdge.svelte'; -import type { - ConnectionData, - NodeTypes, - EdgeTypes, - EdgeLayouted, - Edge, - Node, - IsValidConnection -} from '$lib/types'; +import type { ConnectionData, NodeTypes, EdgeTypes, EdgeLayouted, Edge, Node } from '$lib/types'; import { infiniteExtent } from '@reactflow/utils'; export const initConnectionData = { - nodeId: null, - handleId: null, - handleType: null, - position: null, - status: null + connectionStartHandle: null, + connectionEndHandle: null, + connectionPosition: null, + connectionStatus: null }; export const initialNodeTypes = { @@ -88,5 +80,6 @@ export const initialStoreState = { elementsSelectable: writable(true), selectNodesOnDrag: writable(true), markers: readable([]), - defaultMarkerColor: writable('#b1b1b7') + defaultMarkerColor: writable('#b1b1b7'), + lib: readable('svelte') }; diff --git a/packages/svelte/src/lib/store/types.ts b/packages/svelte/src/lib/store/types.ts index 76bfb6db..00dca142 100644 --- a/packages/svelte/src/lib/store/types.ts +++ b/packages/svelte/src/lib/store/types.ts @@ -4,11 +4,12 @@ import type { ViewportHelperFunctionOptions, Connection, UpdateNodePositions, - CoordinateExtent + CoordinateExtent, + UpdateConnection } from '@reactflow/system'; import type { initialStoreState } from './initial-store'; -import type { Node, Edge, ConnectionData, NodeTypes, EdgeTypes, FitViewOptions } from '$lib/types'; +import type { Node, Edge, NodeTypes, EdgeTypes, FitViewOptions } from '$lib/types'; import type { Writable } from 'svelte/store'; export type SvelteFlowStoreActions = { @@ -27,7 +28,7 @@ export type SvelteFlowStoreActions = { addSelectedNodes: (ids: string[]) => void; addSelectedEdges: (ids: string[]) => void; panBy: (delta: XYPosition) => boolean; - updateConnection: (connection: Partial) => void; + updateConnection: UpdateConnection; cancelConnection: () => void; reset(): void; }; diff --git a/packages/svelte/src/lib/types/general.ts b/packages/svelte/src/lib/types/general.ts index 0ac19424..918547e6 100644 --- a/packages/svelte/src/lib/types/general.ts +++ b/packages/svelte/src/lib/types/general.ts @@ -1,10 +1,10 @@ import type { ShortcutModifierDefinition } from '@svelte-put/shortcut'; import type { - Connection, FitViewOptionsBase, HandleType, Position, - XYPosition + XYPosition, + ConnectingHandle } from '@reactflow/system'; import type { Node } from './nodes'; @@ -14,11 +14,10 @@ export type KeyDefinitionObject = { key: string; modifier?: KeyModifier }; export type KeyDefinition = string | KeyDefinitionObject; export type ConnectionData = { - position: XYPosition | null; - nodeId: string | null; - handleId: string | null; - handleType: HandleType | null; - status: string | null; + connectionPosition: XYPosition | null; + connectionStartHandle: ConnectingHandle | null; + connectionEndHandle: ConnectingHandle | null; + connectionStatus: string | null; }; export type HandleComponentProps = { @@ -30,8 +29,3 @@ export type HandleComponentProps = { }; export type FitViewOptions = FitViewOptionsBase; - -export type IsValidConnection = ( - connection: Connection, - { fromNode, toNode }: { fromNode: Node; toNode: Node } -) => boolean; diff --git a/packages/system/src/types/general.ts b/packages/system/src/types/general.ts index 0b1412c9..0190f7df 100644 --- a/packages/system/src/types/general.ts +++ b/packages/system/src/types/general.ts @@ -3,8 +3,9 @@ import type { D3DragEvent, Selection as D3Selection, SubjectPosition, ZoomBehavi import type { XYPosition, Rect } from './utils'; import type { BaseNode, NodeDragItem, NodeOrigin } from './nodes'; -import type { HandleType } from './handles'; +import type { ConnectingHandle, HandleType } from './handles'; import { PanZoomInstance } from './panzoom'; +import { BaseEdge } from '..'; export type Project = (position: XYPosition) => XYPosition; @@ -34,7 +35,17 @@ export enum ConnectionMode { Loose = 'loose', } +export type OnConnectStartParams = { + nodeId: string | null; + handleId: string | null; + handleType: HandleType | null; +}; + +export type OnConnectStart = (event: MouseEvent | TouchEvent, params: OnConnectStartParams) => void; export type OnConnect = (connection: Connection) => void; +export type OnConnectEnd = (event: MouseEvent | TouchEvent) => void; + +export type IsValidConnection = (edge: BaseEdge | Connection) => boolean; export type FitViewParamsBase = { nodes: NodeType[]; @@ -55,12 +66,6 @@ export type FitViewOptionsBase = { nodes?: (Partial & { id: NodeType['id'] })[]; }; -export type OnConnectStartParams = { - nodeId: string | null; - handleId: string | null; - handleType: HandleType | null; -}; - export type Viewport = { x: number; y: number; @@ -124,3 +129,10 @@ export type UpdateNodePositions = ( dragging?: boolean ) => void; export type PanBy = (delta: XYPosition) => boolean; + +export type UpdateConnection = (params: { + connectionPosition?: XYPosition | null; + connectionStatus?: ConnectionStatus | null; + connectionStartHandle?: ConnectingHandle | null; + connectionEndHandle?: ConnectingHandle | null; +}) => void; diff --git a/packages/system/src/types/handles.ts b/packages/system/src/types/handles.ts index 7e40d1e5..be0ee608 100644 --- a/packages/system/src/types/handles.ts +++ b/packages/system/src/types/handles.ts @@ -1,4 +1,4 @@ -import type { XYPosition, Position, Dimensions, OnConnect, Connection } from '.'; +import type { XYPosition, Position, Dimensions, OnConnect, IsValidConnection } from '.'; export type HandleType = 'source' | 'target'; @@ -14,6 +14,14 @@ export type ConnectingHandle = { handleId?: string | null; }; +export type ConnectionHandle = { + id: string | null; + type: HandleType; + nodeId: string; + x: number; + y: number; +}; + export type HandleProps = { type: HandleType; position: Position; @@ -21,6 +29,6 @@ export type HandleProps = { isConnectableStart?: boolean; isConnectableEnd?: boolean; onConnect?: OnConnect; - isValidConnection?: (connection: Connection) => boolean; + isValidConnection?: IsValidConnection; id?: string; }; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 6cccc3de..813e54d8 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,6 @@ export * from './graph'; export * from './utils'; export * from './marker'; +export * from './xyhandle'; export * from './xypanzoom'; export * from './xydrag'; diff --git a/packages/utils/src/xyhandle/index.ts b/packages/utils/src/xyhandle/index.ts new file mode 100644 index 00000000..69e35938 --- /dev/null +++ b/packages/utils/src/xyhandle/index.ts @@ -0,0 +1,312 @@ +import { getHostForElement, calcAutoPan, getEventPosition, pointToRendererPoint, rendererPointToPoint } from '..'; +import { + OnConnect, + OnConnectStart, + HandleType, + Connection, + ConnectionMode, + PanBy, + BaseNode, + Transform, + ConnectingHandle, + OnConnectEnd, + UpdateConnection, + IsValidConnection, + ConnectionHandle, +} from '@reactflow/system'; + +import { getClosestHandle, getConnectionStatus, getHandleLookup, getHandleType, resetRecentHandle } from './utils'; + +export type OnPointerDownParams = { + autoPanOnConnect: boolean; + connectionMode: ConnectionMode; + connectionRadius: number; + domNode: HTMLDivElement | null; + handleId: string | null; + nodeId: string; + isTarget: boolean; + nodes: BaseNode[]; + transform: Transform; + lib: string; + edgeUpdaterType?: HandleType; + updateConnection: UpdateConnection; + panBy: PanBy; + cancelConnection: () => void; + onConnectStart?: OnConnectStart; + onConnect?: OnConnect; + onConnectEnd?: OnConnectEnd; + isValidConnection?: IsValidConnection; + onEdgeUpdateEnd?: (evt: MouseEvent | TouchEvent) => void; +}; + +export type IsValidParams = { + handle: Pick | null; + connectionMode: ConnectionMode; + fromNodeId: string; + fromHandleId: string | null; + fromType: HandleType; + isValidConnection?: IsValidConnection; + doc: Document | ShadowRoot; + lib: string; +}; + +export type XYHandleInstance = { + onPointerDown: (event: MouseEvent | TouchEvent, params: OnPointerDownParams) => void; + isValid: (event: MouseEvent | TouchEvent, params: IsValidParams) => Result; +}; + +type Result = { + handleDomNode: Element | null; + isValid: boolean; + connection: Connection; + endHandle: ConnectingHandle | null; +}; + +const nullConnection: Connection = { source: null, target: null, sourceHandle: null, targetHandle: null }; + +const alwaysValid = () => true; + +function onPointerDown( + event: MouseEvent | TouchEvent, + { + connectionMode, + connectionRadius, + handleId, + nodeId, + edgeUpdaterType, + isTarget, + domNode, + nodes, + transform, + lib, + autoPanOnConnect, + panBy, + cancelConnection, + onConnectStart, + onConnect, + onConnectEnd, + isValidConnection = alwaysValid, + onEdgeUpdateEnd, + updateConnection, + }: OnPointerDownParams +) { + // when react-flow is used inside a shadow root we can't use document + const doc = getHostForElement(event.target as HTMLElement); + let autoPanId = 0; + let closestHandle: ConnectionHandle | null; + + const { x, y } = getEventPosition(event); + const clickedHandle = doc?.elementFromPoint(x, y); + const handleType = getHandleType(edgeUpdaterType, clickedHandle); + const containerBounds = domNode?.getBoundingClientRect(); + + if (!containerBounds || !handleType) { + return; + } + + let prevActiveHandle: Element; + let connectionPosition = getEventPosition(event, containerBounds); + let autoPanStarted = false; + let connection: Connection | null = null; + let isValid = false; + let handleDomNode: Element | null = null; + + const handleLookup = getHandleLookup({ + nodes, + 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) { + return; + } + const [x, y] = calcAutoPan(connectionPosition, containerBounds); + + panBy({ x, y }); + autoPanId = requestAnimationFrame(autoPan); + } + + updateConnection({ + connectionPosition, + connectionStatus: null, + // connectionNodeId etc will be removed in the next major in favor of connectionStartHandle + connectionStartHandle: { + nodeId, + handleId, + type: handleType, + }, + connectionEndHandle: null, + }); + + onConnectStart?.(event, { nodeId, handleId, handleType }); + + function onPointerMove(event: MouseEvent | TouchEvent) { + connectionPosition = getEventPosition(event, containerBounds); + closestHandle = getClosestHandle( + pointToRendererPoint(connectionPosition, transform, false, [1, 1]), + connectionRadius, + handleLookup + ); + + if (!autoPanStarted) { + autoPan(); + autoPanStarted = true; + } + + const result = isValidHandle(event, { + handle: closestHandle, + connectionMode, + fromNodeId: nodeId, + fromHandleId: handleId, + fromType: isTarget ? 'target' : 'source', + isValidConnection, + doc, + lib, + }); + + handleDomNode = result.handleDomNode; + connection = result.connection; + isValid = result.isValid; + + updateConnection({ + connectionPosition: + closestHandle && isValid + ? rendererPointToPoint( + { + x: closestHandle.x, + y: closestHandle.y, + }, + transform + ) + : connectionPosition, + connectionStatus: getConnectionStatus(!!closestHandle, isValid), + connectionEndHandle: result.endHandle, + }); + + if (!closestHandle && !isValid && !handleDomNode) { + return resetRecentHandle(prevActiveHandle, lib); + } + + if (connection.source !== connection.target && handleDomNode) { + resetRecentHandle(prevActiveHandle, lib); + prevActiveHandle = handleDomNode; + // @todo: remove the old class names "react-flow__handle-" in the next major version + handleDomNode.classList.add('connecting', `${lib}-flow__handle-connecting`); + handleDomNode.classList.toggle('valid', isValid); + handleDomNode.classList.toggle(`${lib}-flow__handle-valid`, isValid); + } + } + + function onPointerUp(event: MouseEvent | TouchEvent) { + if ((closestHandle || handleDomNode) && connection && isValid) { + onConnect?.(connection); + } + + // it's important to get a fresh reference from the store here + // in order to get the latest state of onConnectEnd + onConnectEnd?.(event); + + if (edgeUpdaterType) { + onEdgeUpdateEnd?.(event); + } + + resetRecentHandle(prevActiveHandle, lib); + cancelConnection(); + cancelAnimationFrame(autoPanId); + autoPanStarted = false; + isValid = false; + connection = null; + handleDomNode = null; + + doc.removeEventListener('mousemove', onPointerMove as EventListener); + doc.removeEventListener('mouseup', onPointerUp as EventListener); + + doc.removeEventListener('touchmove', onPointerMove as EventListener); + doc.removeEventListener('touchend', onPointerUp as EventListener); + } + + doc.addEventListener('mousemove', onPointerMove as EventListener); + doc.addEventListener('mouseup', onPointerUp as EventListener); + + doc.addEventListener('touchmove', onPointerMove as EventListener); + doc.addEventListener('touchend', onPointerUp as EventListener); +} + +// checks if and returns connection in fom of an object { source: 123, target: 312 } +function isValidHandle( + event: MouseEvent | TouchEvent, + { + handle, + connectionMode, + fromNodeId, + fromHandleId, + fromType, + doc, + lib, + isValidConnection = alwaysValid, + }: IsValidParams +) { + const isTarget = fromType === 'target'; + const handleDomNode = doc.querySelector( + `.${lib}-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]` + ); + const { x, y } = getEventPosition(event); + const handleBelow = doc.elementFromPoint(x, y); + // we always want to prioritize the handle below the mouse cursor over the closest distance handle, + // because it could be that the center of another handle is closer to the mouse pointer than the handle below the cursor + const handleToCheck = handleBelow?.classList.contains(`${lib}-flow__handle`) ? handleBelow : handleDomNode; + + const result: Result = { + handleDomNode: handleToCheck, + isValid: false, + connection: nullConnection, + endHandle: null, + }; + + console.log(1, result); + + if (handleToCheck) { + const handleType = getHandleType(undefined, handleToCheck); + const handleNodeId = handleToCheck.getAttribute('data-nodeid'); + const handleId = handleToCheck.getAttribute('data-handleid'); + const connectable = handleToCheck.classList.contains('connectable'); + const connectableEnd = handleToCheck.classList.contains('connectableend'); + + const connection: Connection = { + source: isTarget ? handleNodeId : fromNodeId, + sourceHandle: isTarget ? handleId : fromHandleId, + target: isTarget ? fromNodeId : handleNodeId, + targetHandle: isTarget ? fromHandleId : handleId, + }; + + result.connection = connection; + + const isConnectable = connectable && connectableEnd; + // in strict mode we don't allow target to target or source to source connections + const isValid = + isConnectable && + (connectionMode === ConnectionMode.Strict + ? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target') + : handleNodeId !== fromNodeId || handleId !== fromHandleId); + + if (isValid) { + result.endHandle = { + nodeId: handleNodeId as string, + handleId, + type: handleType as HandleType, + }; + + result.isValid = isValidConnection(connection); + } + } + + return result; +} + +export const XYHandle: XYHandleInstance = { + onPointerDown, + isValid: isValidHandle, +}; diff --git a/packages/core/src/components/Handle/utils.ts b/packages/utils/src/xyhandle/utils.ts similarity index 50% rename from packages/core/src/components/Handle/utils.ts rename to packages/utils/src/xyhandle/utils.ts index 1ec471a8..3677c626 100644 --- a/packages/core/src/components/Handle/utils.ts +++ b/packages/utils/src/xyhandle/utils.ts @@ -1,31 +1,17 @@ import { internalsSymbol, - ConnectionMode, ConnectionStatus, - type ConnectingHandle, - type Connection, type HandleType, type NodeHandleBounds, type XYPosition, + type BaseNode, + type ConnectionHandle, } from '@reactflow/system'; -import { getEventPosition } from '@reactflow/utils'; - -import type { Node } from '../../types'; - -export type ConnectionHandle = { - id: string | null; - type: HandleType; - nodeId: string; - x: number; - y: number; -}; - -export type ValidConnectionFunc = (connection: Connection) => boolean; // this functions collects all handles and adds an absolute position // so that we can later find the closest handle to the mouse position export function getHandles( - node: Node, + node: BaseNode, handleBounds: NodeHandleBounds, type: HandleType, currentHandle: string @@ -75,83 +61,8 @@ export function getClosestHandle( closestHandles.find((handle) => handle.type === 'target') || closestHandles[0]; } -type Result = { - handleDomNode: Element | null; - isValid: boolean; - connection: Connection; - endHandle: ConnectingHandle | null; -}; - -const nullConnection: Connection = { source: null, target: null, sourceHandle: null, targetHandle: null }; - -// checks if and returns connection in fom of an object { source: 123, target: 312 } -export function isValidHandle( - event: MouseEvent | TouchEvent, - handle: Pick | null, - connectionMode: ConnectionMode, - fromNodeId: string, - fromHandleId: string | null, - fromType: HandleType, - isValidConnection: ValidConnectionFunc, - doc: Document | ShadowRoot -) { - const isTarget = fromType === 'target'; - const handleDomNode = doc.querySelector( - `.react-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]` - ); - const { x, y } = getEventPosition(event); - const handleBelow = doc.elementFromPoint(x, y); - // we always want to prioritize the handle below the mouse cursor over the closest distance handle, - // because it could be that the center of another handle is closer to the mouse pointer than the handle below the cursor - const handleToCheck = handleBelow?.classList.contains('react-flow__handle') ? handleBelow : handleDomNode; - - const result: Result = { - handleDomNode: handleToCheck, - isValid: false, - connection: nullConnection, - endHandle: null, - }; - - if (handleToCheck) { - const handleType = getHandleType(undefined, handleToCheck); - const handleNodeId = handleToCheck.getAttribute('data-nodeid'); - const handleId = handleToCheck.getAttribute('data-handleid'); - const connectable = handleToCheck.classList.contains('connectable'); - const connectableEnd = handleToCheck.classList.contains('connectableend'); - - const connection: Connection = { - source: isTarget ? handleNodeId : fromNodeId, - sourceHandle: isTarget ? handleId : fromHandleId, - target: isTarget ? fromNodeId : handleNodeId, - targetHandle: isTarget ? fromHandleId : handleId, - }; - - result.connection = connection; - - const isConnectable = connectable && connectableEnd; - // in strict mode we don't allow target to target or source to source connections - const isValid = - isConnectable && - (connectionMode === ConnectionMode.Strict - ? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target') - : handleNodeId !== fromNodeId || handleId !== fromHandleId); - - if (isValid) { - result.endHandle = { - nodeId: handleNodeId as string, - handleId, - type: handleType as HandleType, - }; - - result.isValid = isValidConnection(connection); - } - } - - return result; -} - type GetHandleLookupParams = { - nodes: Node[]; + nodes: BaseNode[]; nodeId: string; handleId: string | null; handleType: string; @@ -190,8 +101,8 @@ export function getHandleType( return null; } -export function resetRecentHandle(handleDomNode: Element): void { - handleDomNode?.classList.remove('valid', 'connecting', 'react-flow__handle-valid', 'react-flow__handle-connecting'); +export function resetRecentHandle(handleDomNode: Element, lib: string): void { + handleDomNode?.classList.remove('valid', 'connecting', `${lib}-flow__handle-valid`, `${lib}-flow__handle-connecting`); } export function getConnectionStatus(isInsideConnectionRadius: boolean, isHandleValid: boolean) {