From 75a13e87bc99fa01936f3f3e85d85c9bcda1148a Mon Sep 17 00:00:00 2001 From: moklick Date: Mon, 30 Jan 2023 11:39:19 +0100 Subject: [PATCH] refactor(handle): first check element below then connection radius --- examples/vite-app/src/App/index.tsx | 6 ++ .../EasyConnect/CustomConnectionLine.tsx | 19 ++++ .../src/examples/EasyConnect/CustomNode.tsx | 27 +++++ .../src/examples/EasyConnect/FloatingEdge.tsx | 26 +++++ .../src/examples/EasyConnect/index.tsx | 85 +++++++++++++++ .../src/examples/EasyConnect/style.css | 54 ++++++++++ .../src/examples/EasyConnect/utils.tsx | 102 ++++++++++++++++++ .../src/examples/Validation/index.tsx | 19 ++-- .../core/src/components/Handle/handler.ts | 2 + packages/core/src/components/Handle/index.tsx | 1 + packages/core/src/components/Handle/utils.ts | 26 +++-- 11 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 examples/vite-app/src/examples/EasyConnect/CustomConnectionLine.tsx create mode 100644 examples/vite-app/src/examples/EasyConnect/CustomNode.tsx create mode 100644 examples/vite-app/src/examples/EasyConnect/FloatingEdge.tsx create mode 100644 examples/vite-app/src/examples/EasyConnect/index.tsx create mode 100644 examples/vite-app/src/examples/EasyConnect/style.css create mode 100644 examples/vite-app/src/examples/EasyConnect/utils.tsx diff --git a/examples/vite-app/src/App/index.tsx b/examples/vite-app/src/App/index.tsx index 33347757..02eaac1e 100644 --- a/examples/vite-app/src/App/index.tsx +++ b/examples/vite-app/src/App/index.tsx @@ -9,6 +9,7 @@ import CustomNode from '../examples/CustomNode'; import DefaultNodes from '../examples/DefaultNodes'; import DragHandle from '../examples/DragHandle'; import DragNDrop from '../examples/DragNDrop'; +import EasyConnect from '../examples/EasyConnect'; import Edges from '../examples/Edges'; import EdgeRenderer from '../examples/EdgeRenderer'; import EdgeTypes from '../examples/EdgeTypes'; @@ -96,6 +97,11 @@ const routes: IRoute[] = [ path: '/dragndrop', component: DragNDrop, }, + { + name: 'EasyConnect', + path: '/easy-connect', + component: EasyConnect, + }, { name: 'Edges', path: '/edges', diff --git a/examples/vite-app/src/examples/EasyConnect/CustomConnectionLine.tsx b/examples/vite-app/src/examples/EasyConnect/CustomConnectionLine.tsx new file mode 100644 index 00000000..ea671570 --- /dev/null +++ b/examples/vite-app/src/examples/EasyConnect/CustomConnectionLine.tsx @@ -0,0 +1,19 @@ +import { ConnectionLineComponentProps, getStraightPath } from 'reactflow'; + +function CustomConnectionLine({ fromX, fromY, toX, toY, connectionLineStyle }: ConnectionLineComponentProps) { + const [edgePath] = getStraightPath({ + sourceX: fromX, + sourceY: fromY, + targetX: toX, + targetY: toY, + }); + + return ( + + + + + ); +} + +export default CustomConnectionLine; diff --git a/examples/vite-app/src/examples/EasyConnect/CustomNode.tsx b/examples/vite-app/src/examples/EasyConnect/CustomNode.tsx new file mode 100644 index 00000000..49c1adb9 --- /dev/null +++ b/examples/vite-app/src/examples/EasyConnect/CustomNode.tsx @@ -0,0 +1,27 @@ +import { Handle, NodeProps, Position, ReactFlowState, useStore } from 'reactflow'; + +const connectionNodeIdSelector = (state: ReactFlowState) => state.connectionNodeId; + +export default function CustomNode({ id }: NodeProps) { + const connectionNodeId = useStore(connectionNodeIdSelector); + const isTarget = connectionNodeId && connectionNodeId !== id; + + const targetHandleStyle = { zIndex: isTarget ? 3 : 1 }; + const label = isTarget ? 'Drop here' : 'Drag to connect'; + + return ( +
+
+ + + {label} +
+
+ ); +} diff --git a/examples/vite-app/src/examples/EasyConnect/FloatingEdge.tsx b/examples/vite-app/src/examples/EasyConnect/FloatingEdge.tsx new file mode 100644 index 00000000..4b1038f0 --- /dev/null +++ b/examples/vite-app/src/examples/EasyConnect/FloatingEdge.tsx @@ -0,0 +1,26 @@ +import { useCallback } from 'react'; +import { useStore, getStraightPath, EdgeProps } from 'reactflow'; + +import { getEdgeParams } from './utils.js'; + +function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) { + const sourceNode = useStore(useCallback((store) => store.nodeInternals.get(source), [source])); + const targetNode = useStore(useCallback((store) => store.nodeInternals.get(target), [target])); + + if (!sourceNode || !targetNode) { + return null; + } + + const { sx, sy, tx, ty } = getEdgeParams(sourceNode, targetNode); + + const [edgePath] = getStraightPath({ + sourceX: sx, + sourceY: sy, + targetX: tx, + targetY: ty, + }); + + return ; +} + +export default FloatingEdge; diff --git a/examples/vite-app/src/examples/EasyConnect/index.tsx b/examples/vite-app/src/examples/EasyConnect/index.tsx new file mode 100644 index 00000000..1e26ae3d --- /dev/null +++ b/examples/vite-app/src/examples/EasyConnect/index.tsx @@ -0,0 +1,85 @@ +import { useCallback } from 'react'; +import ReactFlow, { Node, Edge, addEdge, useNodesState, useEdgesState, MarkerType, OnConnect } from 'reactflow'; + +import CustomNode from './CustomNode'; +import FloatingEdge from './FloatingEdge'; +import CustomConnectionLine from './CustomConnectionLine'; + +import 'reactflow/dist/style.css'; +import './style.css'; + +const initialNodes: Node[] = [ + { + id: '1', + type: 'custom', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: '2', + type: 'custom', + position: { x: 250, y: 320 }, + data: {}, + }, + { + id: '3', + type: 'custom', + position: { x: 40, y: 300 }, + data: {}, + }, + { + id: '4', + type: 'custom', + position: { x: 300, y: 0 }, + data: {}, + }, +]; + +const initialEdges: Edge[] = []; + +const connectionLineStyle = { + strokeWidth: 3, + stroke: 'black', +}; + +const nodeTypes = { + custom: CustomNode, +}; + +const edgeTypes = { + floating: FloatingEdge, +}; + +const defaultEdgeOptions = { + style: { strokeWidth: 3, stroke: 'black' }, + type: 'floating', + markerEnd: { + type: MarkerType.ArrowClosed, + color: 'black', + }, +}; + +const EasyConnectExample = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const onConnect: OnConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), [setEdges]); + + return ( + + ); +}; + +export default EasyConnectExample; diff --git a/examples/vite-app/src/examples/EasyConnect/style.css b/examples/vite-app/src/examples/EasyConnect/style.css new file mode 100644 index 00000000..c2ebc0a2 --- /dev/null +++ b/examples/vite-app/src/examples/EasyConnect/style.css @@ -0,0 +1,54 @@ +.customNodeBody { + width: 150px; + height: 80px; + border: 3px solid black; + position: relative; + overflow: hidden; + border-radius: 10px; + display: flex; + justify-content: center; + align-items: center; + font-weight: bold; +} + +.customNode:before { + content: ''; + position: absolute; + top: -10px; + left: 50%; + height: 20px; + width: 40px; + transform: translate(-50%, 0); + background: #d6d5e6; + z-index: 1000; + line-height: 1; + border-radius: 4px; + color: #fff; + font-size: 9px; + border: 2px solid #222138; +} + +div.sourceHandle { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + border-radius: 0; + transform: none; + border: none; + opacity: 0; +} + +div.targetHandle { + width: 100%; + height: 100%; + background: blue; + position: absolute; + top: 0; + left: 0; + border-radius: 0; + transform: none; + border: none; + opacity: 0; +} diff --git a/examples/vite-app/src/examples/EasyConnect/utils.tsx b/examples/vite-app/src/examples/EasyConnect/utils.tsx new file mode 100644 index 00000000..02bf2eaf --- /dev/null +++ b/examples/vite-app/src/examples/EasyConnect/utils.tsx @@ -0,0 +1,102 @@ +import { Node, Position, MarkerType, XYPosition } from 'reactflow'; + +// this helper function returns the intersection point +// of the line between the center of the intersectionNode and the target node +function getNodeIntersection(intersectionNode: Node, targetNode: Node) { + // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a + const { + width: intersectionNodeWidth, + height: intersectionNodeHeight, + positionAbsolute: intersectionNodePosition, + } = intersectionNode; + const targetPosition = targetNode.positionAbsolute!; + + const w = intersectionNodeWidth! / 2; + const h = intersectionNodeHeight! / 2; + + const x2 = intersectionNodePosition!.x + w; + const y2 = intersectionNodePosition!.y + h; + const x1 = targetPosition.x + w; + const y1 = targetPosition.y + h; + + const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); + const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); + const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); + const xx3 = a * xx1; + const yy3 = a * yy1; + const x = w * (xx3 + yy3) + x2; + const y = h * (-xx3 + yy3) + y2; + + return { x, y }; +} + +// returns the position (top,right,bottom or right) passed node compared to the intersection point +function getEdgePosition(node: Node, intersectionPoint: XYPosition) { + const n = { ...node.positionAbsolute, ...node }; + const nx = Math.round(n.x!); + const ny = Math.round(n.y!); + const px = Math.round(intersectionPoint.x); + const py = Math.round(intersectionPoint.y); + + if (px <= nx + 1) { + return Position.Left; + } + if (px >= nx + n.width! - 1) { + return Position.Right; + } + if (py <= ny + 1) { + return Position.Top; + } + if (py >= n.y! + n.height! - 1) { + return Position.Bottom; + } + + return Position.Top; +} + +// returns the parameters (sx, sy, tx, ty, sourcePos, targetPos) you need to create an edge +export function getEdgeParams(source: Node, target: Node) { + const sourceIntersectionPoint = getNodeIntersection(source, target); + const targetIntersectionPoint = getNodeIntersection(target, source); + + const sourcePos = getEdgePosition(source, sourceIntersectionPoint); + const targetPos = getEdgePosition(target, targetIntersectionPoint); + + return { + sx: sourceIntersectionPoint.x, + sy: sourceIntersectionPoint.y, + tx: targetIntersectionPoint.x, + ty: targetIntersectionPoint.y, + sourcePos, + targetPos, + }; +} + +export function createNodesAndEdges() { + const nodes = []; + const edges = []; + const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + + nodes.push({ id: 'target', data: { label: 'Target' }, position: center }); + + for (let i = 0; i < 8; i++) { + const degrees = i * (360 / 8); + const radians = degrees * (Math.PI / 180); + const x = 250 * Math.cos(radians) + center.x; + const y = 250 * Math.sin(radians) + center.y; + + nodes.push({ id: `${i}`, data: { label: 'Source' }, position: { x, y } }); + + edges.push({ + id: `edge-${i}`, + target: 'target', + source: `${i}`, + type: 'floating', + markerEnd: { + type: MarkerType.Arrow, + }, + }); + } + + return { nodes, edges }; +} diff --git a/examples/vite-app/src/examples/Validation/index.tsx b/examples/vite-app/src/examples/Validation/index.tsx index b11c1f92..1488b48c 100644 --- a/examples/vite-app/src/examples/Validation/index.tsx +++ b/examples/vite-app/src/examples/Validation/index.tsx @@ -1,16 +1,17 @@ -import React, { FC, useCallback, useState } from 'react'; +import { FC, useCallback, useState } from 'react'; import ReactFlow, { addEdge, Handle, Connection, Position, Node, - Edge, NodeProps, NodeTypes, useNodesState, useEdgesState, - OnConnectStartParams, + OnConnectStart, + OnConnectEnd, + OnConnect, } from 'reactflow'; import styles from './validation.module.css'; @@ -49,24 +50,24 @@ const ValidationFlow = () => { const [nodes, , onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const onConnectStart = useCallback( - (event: React.MouseEvent, params: OnConnectStartParams) => { + const onConnectStart: OnConnectStart = useCallback( + (event, params) => { console.log('on connect start', params, event, value); setValue(1); }, [value] ); - const onConnect = useCallback( - (params: Connection | Edge) => { + const onConnect: OnConnect = useCallback( + (params) => { console.log('on connect', params); setEdges((eds) => addEdge(params, eds)); }, [setEdges] ); - const onConnectEnd = useCallback( - (event: MouseEvent) => { + const onConnectEnd: OnConnectEnd = useCallback( + (event) => { console.log('on connect end', event, value); setValue(0); }, diff --git a/packages/core/src/components/Handle/handler.ts b/packages/core/src/components/Handle/handler.ts index 180b6027..03bf698c 100644 --- a/packages/core/src/components/Handle/handler.ts +++ b/packages/core/src/components/Handle/handler.ts @@ -125,6 +125,7 @@ export function handlePointerDown({ } const { connection, handleDomNode, isValid } = isValidHandle( + event, prevClosestHandle, connectionMode, nodeId, @@ -148,6 +149,7 @@ export function handlePointerDown({ if (prevClosestHandle) { const { connection, isValid } = isValidHandle( + event, prevClosestHandle, connectionMode, nodeId, diff --git a/packages/core/src/components/Handle/index.tsx b/packages/core/src/components/Handle/index.tsx index 59d2bd7f..dab16653 100644 --- a/packages/core/src/components/Handle/index.tsx +++ b/packages/core/src/components/Handle/index.tsx @@ -102,6 +102,7 @@ const Handle = forwardRef( const doc = getHostForElement(event.target as HTMLElement); const { connection, isValid } = isValidHandle( + event, { nodeId, id: handleId, diff --git a/packages/core/src/components/Handle/utils.ts b/packages/core/src/components/Handle/utils.ts index 439ccc7a..0b391e37 100644 --- a/packages/core/src/components/Handle/utils.ts +++ b/packages/core/src/components/Handle/utils.ts @@ -1,5 +1,7 @@ +import { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from 'react'; + import { ConnectionMode } from '../../types'; -import { internalsSymbol } from '../../utils'; +import { getEventPosition, internalsSymbol } from '../../utils'; import type { Connection, HandleType, XYPosition, Node, NodeHandleBounds } from '../../types'; export type ConnectionHandle = { @@ -61,6 +63,7 @@ type Result = { // checks if and returns connection in fom of an object { source: 123, target: 312 } export function isValidHandle( + event: MouseEvent | TouchEvent | ReactMouseEvent | ReactTouchEvent, handle: Pick, connectionMode: ConnectionMode, fromNodeId: string, @@ -73,23 +76,26 @@ export function isValidHandle( 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); + const handleToCheck = handleBelow?.classList.contains('react-flow__handle') ? handleBelow : handleDomNode; + const result: Result = { - handleDomNode, + handleDomNode: handleToCheck, isValid: false, connection: { source: null, target: null, sourceHandle: null, targetHandle: null }, }; - if (handleDomNode) { - const handleIsTarget = handle.type === 'target'; - const handleIsSource = handle.type === 'source'; - const handleNodeId = handleDomNode.getAttribute('data-nodeid'); - const handleId = handleDomNode.getAttribute('data-handleid'); + if (handleToCheck) { + const handleType = getHandleType(undefined, handleToCheck); + const handleNodeId = handleToCheck.getAttribute('data-nodeid'); + const handleId = handleToCheck.getAttribute('data-handleid'); const connection: Connection = { source: isTarget ? handle.nodeId : fromNodeId, - sourceHandle: isTarget ? handle.id : fromHandleId, + sourceHandle: isTarget ? handleId : fromHandleId, target: isTarget ? fromNodeId : handle.nodeId, - targetHandle: isTarget ? fromHandleId : handle.id, + targetHandle: isTarget ? fromHandleId : handleId, }; result.connection = connection; @@ -97,7 +103,7 @@ export function isValidHandle( // in strict mode we don't allow target to target or source to source connections const isValid = connectionMode === ConnectionMode.Strict - ? (isTarget && handleIsSource) || (!isTarget && handleIsTarget) + ? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target') : handleNodeId !== fromNodeId || handleId !== fromHandleId; if (isValid) {