diff --git a/example/src/Provider/index.js b/example/src/Provider/index.js index b4b8f305..2f6b979b 100644 --- a/example/src/Provider/index.js +++ b/example/src/Provider/index.js @@ -33,6 +33,7 @@ const ProviderFlow = () => { onConnect={onConnect} onElementsRemove={onElementsRemove} onLoad={onLoad} + connectionMode="loose" > diff --git a/src/components/Edges/wrapEdge.tsx b/src/components/Edges/wrapEdge.tsx index afbea66a..7579c57b 100644 --- a/src/components/Edges/wrapEdge.tsx +++ b/src/components/Edges/wrapEdge.tsx @@ -1,9 +1,9 @@ import React, { memo, ComponentType, useCallback, useState } from 'react'; import cc from 'classcat'; -import { useStoreActions } from '../../store/hooks'; +import { useStoreActions, useStoreState } from '../../store/hooks'; import { Edge, EdgeProps, WrapEdgeProps } from '../../types'; -import { onMouseDown } from '../../components/Handle/BaseHandle'; +import { onMouseDown } from '../../components/Handle/handler'; export default (EdgeComponent: ComponentType) => { const EdgeWrapper = ({ @@ -41,6 +41,8 @@ export default (EdgeComponent: ComponentType) => { const addSelectedElements = useStoreActions((actions) => actions.addSelectedElements); const setConnectionNodeId = useStoreActions((actions) => actions.setConnectionNodeId); const setPosition = useStoreActions((actions) => actions.setConnectionPosition); + const connectionMode = useStoreState((state) => state.connectionMode); + const [updating, setUpdating] = useState(false); const inactive = !elementsSelectable && !onClick; @@ -85,7 +87,8 @@ export default (EdgeComponent: ComponentType) => { setPosition, onConnectEdge, isTarget, - isValidConnection + isValidConnection, + connectionMode ); }, [id, source, target, type, sourceHandleId, targetHandleId, setConnectionNodeId, setPosition] diff --git a/src/components/Handle/BaseHandle.tsx b/src/components/Handle/BaseHandle.tsx deleted file mode 100644 index 4b7921a0..00000000 --- a/src/components/Handle/BaseHandle.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import React, { memo, MouseEvent as ReactMouseEvent, CSSProperties } from 'react'; -import cc from 'classcat'; - -import { - HandleType, - ElementId, - Position, - XYPosition, - OnConnectFunc, - OnConnectStartFunc, - OnConnectStopFunc, - OnConnectEndFunc, - Connection, - SetConnectionId, -} from '../../types'; - -type ValidConnectionFunc = (connection: Connection) => boolean; -export type SetSourceIdFunc = (params: SetConnectionId) => void; - -interface BaseHandleProps { - type: HandleType; - nodeId: ElementId; - onConnect: OnConnectFunc; - onConnectStart?: OnConnectStartFunc; - onConnectStop?: OnConnectStopFunc; - onConnectEnd?: OnConnectEndFunc; - position: Position; - setConnectionNodeId: SetSourceIdFunc; - setPosition: (pos: XYPosition) => void; - isValidConnection: ValidConnectionFunc; - id?: ElementId | null; - className?: string; - style?: CSSProperties; -} - -type Result = { - elementBelow: Element | null; - isValid: boolean; - connection: Connection; - isHoveringHandle: boolean; -}; - -export function onMouseDown( - event: ReactMouseEvent, - handleId: ElementId | null, - nodeId: ElementId, - setConnectionNodeId: SetSourceIdFunc, - setPosition: (pos: XYPosition) => void, - onConnect: OnConnectFunc, - isTarget: boolean, - isValidConnection: ValidConnectionFunc, - onConnectStart?: OnConnectStartFunc, - onConnectStop?: OnConnectStopFunc, - onConnectEnd?: OnConnectEndFunc -): void { - const reactFlowNode = (event.target as Element).closest('.react-flow'); - - if (!reactFlowNode) { - return; - } - - const handleType = isTarget ? 'target' : 'source'; - const containerBounds = reactFlowNode.getBoundingClientRect(); - let recentHoveredHandle: Element; - - setPosition({ - x: event.clientX - containerBounds.left, - y: event.clientY - containerBounds.top, - }); - - setConnectionNodeId({ connectionNodeId: nodeId, connectionHandleId: handleId, connectionHandleType: handleType }); - - if (onConnectStart) { - onConnectStart(event, { nodeId, handleId, handleType }); - } - - function resetRecentHandle(): void { - if (!recentHoveredHandle) { - return; - } - - recentHoveredHandle.classList.remove('react-flow__handle-valid'); - recentHoveredHandle.classList.remove('react-flow__handle-connecting'); - } - - // checks if element below mouse is a handle and returns connection in form of an object { source: 123, target: 312 } - function checkElementBelowIsValid(event: MouseEvent) { - const elementBelow = document.elementFromPoint(event.clientX, event.clientY); - - const result: Result = { - elementBelow, - isValid: false, - connection: { source: null, target: null, sourceHandle: null, targetHandle: null }, - isHoveringHandle: false, - }; - - if (elementBelow && (elementBelow.classList.contains('target') || elementBelow.classList.contains('source'))) { - result.isHoveringHandle = true; - if ( - (isTarget && elementBelow.classList.contains('source')) || - (!isTarget && elementBelow.classList.contains('target')) - ) { - let connection: Connection = { source: null, target: null, sourceHandle: null, targetHandle: null }; - - if (isTarget) { - const sourceId = elementBelow.getAttribute('data-nodeid'); - const sourcehandleId = elementBelow.getAttribute('data-handleid'); - connection = { source: sourceId, sourceHandle: sourcehandleId, target: nodeId, targetHandle: handleId }; - } else { - const targetId = elementBelow.getAttribute('data-nodeid'); - const targetHandleId = elementBelow.getAttribute('data-handleid'); - connection = { source: nodeId, sourceHandle: handleId, target: targetId, targetHandle: targetHandleId }; - } - - const isValid = isValidConnection(connection); - - result.connection = connection; - result.isValid = isValid; - } - } - - return result; - } - - function onMouseMove(event: MouseEvent) { - setPosition({ - x: event.clientX - containerBounds.left, - y: event.clientY - containerBounds.top, - }); - - const { connection, elementBelow, isValid, isHoveringHandle } = checkElementBelowIsValid(event); - - if (!isHoveringHandle) { - return resetRecentHandle(); - } - - const isOwnHandle = connection.source === connection.target; - - if (!isOwnHandle && elementBelow) { - recentHoveredHandle = elementBelow; - elementBelow.classList.add('react-flow__handle-connecting'); - elementBelow.classList.toggle('react-flow__handle-valid', isValid); - } - } - - function onMouseUp(event: MouseEvent) { - const { connection, isValid } = checkElementBelowIsValid(event); - - if (onConnectStop) { - onConnectStop(event); - } - - if (isValid && onConnect) { - onConnect(connection); - } - - if (onConnectEnd) { - onConnectEnd(event); - } - - resetRecentHandle(); - setConnectionNodeId({ connectionNodeId: null, connectionHandleId: null, connectionHandleType: null }); - - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - } - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); -} - -const BaseHandle = ({ - type, - nodeId, - onConnect, - onConnectStart, - onConnectStop, - onConnectEnd, - position, - setConnectionNodeId, - setPosition, - className, - id = null, - isValidConnection, - ...rest -}: BaseHandleProps) => { - const isTarget = type === 'target'; - const handleClasses = cc([ - 'react-flow__handle', - `react-flow__handle-${position}`, - 'nodrag', - className, - { - source: !isTarget, - target: isTarget, - }, - ]); - - const handleId = id || null; - - return ( -
- onMouseDown( - event, - handleId, - nodeId, - setConnectionNodeId, - setPosition, - onConnect, - isTarget, - isValidConnection, - onConnectStart, - onConnectStop, - onConnectEnd - ) - } - {...rest} - /> - ); -}; - -BaseHandle.displayName = 'BaseHandle'; - -export default memo(BaseHandle); diff --git a/src/components/Handle/handler.ts b/src/components/Handle/handler.ts new file mode 100644 index 00000000..ea72cc6d --- /dev/null +++ b/src/components/Handle/handler.ts @@ -0,0 +1,171 @@ +import { MouseEvent as ReactMouseEvent } from 'react'; + +import { + ElementId, + XYPosition, + OnConnectFunc, + OnConnectStartFunc, + OnConnectStopFunc, + OnConnectEndFunc, + ConnectionMode, + SetConnectionId, + Connection, +} from '../../types'; + +type ValidConnectionFunc = (connection: Connection) => boolean; +export type SetSourceIdFunc = (params: SetConnectionId) => void; + +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 } +function checkElementBelowIsValid( + event: MouseEvent, + connectionMode: ConnectionMode, + isTarget: boolean, + nodeId: ElementId, + handleId: ElementId | null, + isValidConnection: ValidConnectionFunc +) { + const elementBelow = document.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; + + // in strict mode we don't allow target to target or source to source connections + const isValid = + connectionMode === ConnectionMode.Strict + ? (isTarget && elementBelowIsSource) || (!isTarget && elementBelowIsTarget) + : true; + + if (isValid) { + 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; + result.isValid = isValidConnection(connection); + } + } + + return result; +} + +function resetRecentHandle(hoveredHandle: Element): void { + hoveredHandle?.classList.remove('react-flow__handle-valid'); + hoveredHandle?.classList.remove('react-flow__handle-connecting'); +} + +export function onMouseDown( + event: ReactMouseEvent, + handleId: ElementId | null, + nodeId: ElementId, + setConnectionNodeId: SetSourceIdFunc, + setPosition: (pos: XYPosition) => void, + onConnect: OnConnectFunc, + isTarget: boolean, + isValidConnection: ValidConnectionFunc, + connectionMode: ConnectionMode, + onConnectStart?: OnConnectStartFunc, + onConnectStop?: OnConnectStopFunc, + onConnectEnd?: OnConnectEndFunc +): void { + const reactFlowNode = (event.target as Element).closest('.react-flow'); + + if (!reactFlowNode) { + return; + } + + const handleType = isTarget ? 'target' : 'source'; + const containerBounds = reactFlowNode.getBoundingClientRect(); + let recentHoveredHandle: Element; + + setPosition({ + x: event.clientX - containerBounds.left, + y: event.clientY - containerBounds.top, + }); + + setConnectionNodeId({ connectionNodeId: nodeId, connectionHandleId: handleId, connectionHandleType: handleType }); + onConnectStart?.(event, { nodeId, handleId, handleType }); + + function onMouseMove(event: MouseEvent) { + setPosition({ + x: event.clientX - containerBounds.left, + y: event.clientY - containerBounds.top, + }); + + const { connection, elementBelow, isValid, isHoveringHandle } = checkElementBelowIsValid( + event, + connectionMode, + isTarget, + nodeId, + handleId, + isValidConnection + ); + + if (!isHoveringHandle) { + return resetRecentHandle(recentHoveredHandle); + } + + const isOwnHandle = connection.source === connection.target; + + if (!isOwnHandle && elementBelow) { + recentHoveredHandle = elementBelow; + elementBelow.classList.add('react-flow__handle-connecting'); + elementBelow.classList.toggle('react-flow__handle-valid', isValid); + } + } + + function onMouseUp(event: MouseEvent) { + const { connection, isValid } = checkElementBelowIsValid( + event, + connectionMode, + isTarget, + nodeId, + handleId, + isValidConnection + ); + + onConnectStop?.(event); + + if (isValid) { + onConnect?.(connection); + } + + onConnectEnd?.(event); + + resetRecentHandle(recentHoveredHandle); + setConnectionNodeId({ connectionNodeId: null, connectionHandleId: null, connectionHandleType: null }); + + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); +} diff --git a/src/components/Handle/index.tsx b/src/components/Handle/index.tsx index 627afb86..1fb15a42 100644 --- a/src/components/Handle/index.tsx +++ b/src/components/Handle/index.tsx @@ -1,16 +1,18 @@ -import React, { memo, useContext } from 'react'; +import React, { memo, useContext, useCallback } from 'react'; import cc from 'classcat'; import { useStoreActions, useStoreState } from '../../store/hooks'; -import BaseHandle from './BaseHandle'; import NodeIdContext from '../../contexts/NodeIdContext'; +import { HandleProps, Connection, ElementId, Position } from '../../types'; -import { HandleProps, ElementId, Position, Connection } from '../../types'; +import { onMouseDown } from './handler'; + +const alwaysValid = () => true; const Handle = ({ type = 'source', position = Position.Top, - isValidConnection = () => true, + isValidConnection = alwaysValid, isConnectable = true, style, className, @@ -24,32 +26,69 @@ const Handle = ({ const onConnectStart = useStoreState((state) => state.onConnectStart); const onConnectStop = useStoreState((state) => state.onConnectStop); const onConnectEnd = useStoreState((state) => state.onConnectEnd); + const connectionMode = useStoreState((state) => state.connectionMode); + const handleId = id || null; + const isTarget = type === 'target'; - const onConnectExtended = (params: Connection) => { - if (onConnectAction) { - onConnectAction(params); - } + const onConnectExtended = useCallback( + (params: Connection) => { + onConnectAction?.(params); + onConnect?.(params); + }, + [onConnectAction, onConnect] + ); - if (onConnect) { - onConnect(params); - } - }; - const handleClasses = cc([className, { connectable: isConnectable }]); + const onMouseDownHandler = useCallback( + (event: React.MouseEvent) => { + onMouseDown( + event, + handleId, + nodeId, + setConnectionNodeId, + setPosition, + onConnectExtended, + isTarget, + isValidConnection, + connectionMode, + onConnectStart, + onConnectStop, + onConnectEnd + ); + }, + [ + handleId, + nodeId, + setConnectionNodeId, + setPosition, + onConnectExtended, + isTarget, + isValidConnection, + connectionMode, + onConnectStart, + onConnectStop, + onConnectEnd, + ] + ); + + const handleClasses = cc([ + 'react-flow__handle', + `react-flow__handle-${position}`, + 'nodrag', + className, + { + source: !isTarget, + target: isTarget, + connectable: isConnectable, + }, + ]); return ( - ); diff --git a/src/container/GraphView/index.tsx b/src/container/GraphView/index.tsx index 683829df..9a649e07 100644 --- a/src/container/GraphView/index.tsx +++ b/src/container/GraphView/index.tsx @@ -45,6 +45,7 @@ const GraphView = ({ onSelectionDrag, onSelectionDragStop, onSelectionContextMenu, + connectionMode, connectionLineType, connectionLineStyle, connectionLineComponent, @@ -94,6 +95,7 @@ const GraphView = ({ const setMinZoom = useStoreActions((actions) => actions.setMinZoom); const setMaxZoom = useStoreActions((actions) => actions.setMaxZoom); const setTranslateExtent = useStoreActions((actions) => actions.setTranslateExtent); + const setConnectionMode = useStoreActions((actions) => actions.setConnectionMode); const currentStore = useStore(); const { zoomIn, zoomOut, zoomTo, transform, fitView, initialized } = useZoomPanHelper(); @@ -188,6 +190,12 @@ const GraphView = ({ } }, [translateExtent]); + useEffect(() => { + if (typeof connectionMode !== 'undefined') { + setConnectionMode(connectionMode); + } + }, [connectionMode]); + return ( , 'on onPaneContextMenu?: (event: MouseEvent) => void; nodeTypes?: NodeTypesType; edgeTypes?: EdgeTypesType; + connectionMode?: ConnectionMode; connectionLineType?: ConnectionLineType; connectionLineStyle?: CSSProperties; connectionLineComponent?: ConnectionLineComponent; @@ -129,6 +131,7 @@ const ReactFlow = ({ onSelectionDrag, onSelectionDragStop, onSelectionContextMenu, + connectionMode, connectionLineType = ConnectionLineType.Bezier, connectionLineStyle, connectionLineComponent, @@ -183,6 +186,7 @@ const ReactFlow = ({ onNodeDragStop={onNodeDragStop} nodeTypes={nodeTypesParsed} edgeTypes={edgeTypesParsed} + connectionMode={connectionMode} connectionLineType={connectionLineType} connectionLineStyle={connectionLineStyle} connectionLineComponent={connectionLineComponent} diff --git a/src/store/index.ts b/src/store/index.ts index 851e1f76..e9f3973a 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -26,6 +26,7 @@ import { NodeDiffUpdate, TranslateExtent, SnapGrid, + ConnectionMode, } from '../types'; type NodeDimensionUpdate = { @@ -70,6 +71,7 @@ export interface StoreModel { connectionHandleId: ElementId | null; connectionHandleType: HandleType | null; connectionPosition: XYPosition; + connectionMode: ConnectionMode; snapToGrid: boolean; snapGrid: SnapGrid; @@ -136,6 +138,8 @@ export interface StoreModel { unsetUserSelection: Action; setMultiSelectionActive: Action; + + setConnectionMode: Action; } export const storeModel: StoreModel = { @@ -175,6 +179,7 @@ export const storeModel: StoreModel = { connectionHandleId: null, connectionHandleType: 'source', connectionPosition: { x: 0, y: 0 }, + connectionMode: ConnectionMode.Strict, snapGrid: [15, 15], snapToGrid: false, @@ -500,6 +505,10 @@ export const storeModel: StoreModel = { setMultiSelectionActive: action((state, isActive) => { state.multiSelectionActive = isActive; }), + + setConnectionMode: action((state, connectionMode) => { + state.connectionMode = connectionMode; + }), }; const nodeEnv: string = (typeof __ENV__ !== 'undefined' && __ENV__) as string; diff --git a/src/types/index.ts b/src/types/index.ts index 50e1377a..50e68a15 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -264,6 +264,11 @@ export interface Connection { targetHandle: ElementId | null; } +export enum ConnectionMode { + Strict = 'strict', + Loose = 'loose', +} + export enum ConnectionLineType { Bezier = 'default', Straight = 'straight',