diff --git a/examples/astro-xyflow/src/components/ReactFlowExample/CustomNode.tsx b/examples/astro-xyflow/src/components/ReactFlowExample/CustomNode.tsx index dc371239..6dc30709 100644 --- a/examples/astro-xyflow/src/components/ReactFlowExample/CustomNode.tsx +++ b/examples/astro-xyflow/src/components/ReactFlowExample/CustomNode.tsx @@ -7,7 +7,7 @@ const sourceHandleStyleB: CSSProperties = { left: 'auto', }; -const CustomNode: FC = ({ data, xPos, yPos }) => { +const CustomNode: FC = ({ data, positionAbsoluteX, positionAbsoluteY }) => { return ( <> @@ -18,7 +18,7 @@ const CustomNode: FC = ({ data, xPos, yPos }) => {
Position:{' '} - {xPos.toFixed(2)},{yPos.toFixed(2)} + {positionAbsoluteX.toFixed(2)},{positionAbsoluteY.toFixed(2)}
diff --git a/examples/astro-xyflow/src/components/ReactFlowExample/index.tsx b/examples/astro-xyflow/src/components/ReactFlowExample/index.tsx index ae5a5c87..4f8ef0ab 100644 --- a/examples/astro-xyflow/src/components/ReactFlowExample/index.tsx +++ b/examples/astro-xyflow/src/components/ReactFlowExample/index.tsx @@ -27,7 +27,7 @@ const initialNodes: Node[] = [ type: 'input', data: { label: 'Node 1' }, position: { x: 250, y: 5 }, - size: nodeSize, + ...nodeSize, handles: [ { type: 'source', @@ -41,23 +41,19 @@ const initialNodes: Node[] = [ id: '2', data: { label: 'Node 2' }, position: { x: 100, y: 100 }, - size: nodeSize, + ...nodeSize, handles: [ { type: 'source', position: Position.Bottom, x: nodeSize.width * 0.5, y: nodeSize.height, - width: 1, - height: 1, }, { type: 'target', position: Position.Top, x: nodeSize.width * 0.5, y: 0, - width: 1, - height: 1, }, ], }, @@ -65,23 +61,19 @@ const initialNodes: Node[] = [ id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 }, - size: nodeSize, + ...nodeSize, handles: [ { type: 'source', position: Position.Bottom, x: nodeSize.width * 0.5, y: nodeSize.height, - width: 1, - height: 1, }, { type: 'target', position: Position.Top, x: nodeSize.width * 0.5, y: 0, - width: 1, - height: 1, }, ], }, diff --git a/examples/astro-xyflow/src/components/SvelteFlowExample/index.svelte b/examples/astro-xyflow/src/components/SvelteFlowExample/index.svelte index cfa8a449..73196d53 100644 --- a/examples/astro-xyflow/src/components/SvelteFlowExample/index.svelte +++ b/examples/astro-xyflow/src/components/SvelteFlowExample/index.svelte @@ -11,10 +11,8 @@ data: { label: 'Node 0' }, sourcePosition: Position.Right, targetPosition: Position.Left, - size: { - width: 100, - height: 40, - }, + width: 100, + height: 40, handles: [ { type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'target', x: 0, y: 20, position: Position.Left }, @@ -26,10 +24,8 @@ data: { label: 'A' }, sourcePosition: Position.Right, targetPosition: Position.Left, - size: { - width: 100, - height: 40, - }, + width: 100, + height: 40, handles: [ { type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'target', x: 0, y: 20, position: Position.Left }, @@ -41,10 +37,8 @@ data: { label: 'B' }, sourcePosition: Position.Right, targetPosition: Position.Left, - size: { - width: 100, - height: 40, - }, + width: 100, + height: 40, handles: [ { type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'target', x: 0, y: 20, position: Position.Left }, @@ -56,10 +50,8 @@ data: { label: 'C' }, sourcePosition: Position.Right, targetPosition: Position.Left, - size: { - width: 100, - height: 40, - }, + width: 100, + height: 40, handles: [ { type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'target', x: 0, y: 20, position: Position.Left }, diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 6c6b596c..d38f5449 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -1,5 +1,6 @@ import Basic from '../examples/Basic'; import Backgrounds from '../examples/Backgrounds'; +import ColorMode from '../examples/ColorMode'; import ControlledUncontrolled from '../examples/ControlledUncontrolled'; import ControlledViewport from '../examples/ControlledViewport'; import CustomConnectionLine from '../examples/CustomConnectionLine'; @@ -20,7 +21,6 @@ import Interaction from '../examples/Interaction'; import Intersection from '../examples/Intersection'; import Layouting from '../examples/Layouting'; import MultiFlows from '../examples/MultiFlows'; -import NestedNodes from '../examples/NestedNodes'; import NodeResizer from '../examples/NodeResizer'; import NodeTypeChange from '../examples/NodeTypeChange'; import NodeTypesObjectChange from '../examples/NodeTypesObjectChange'; @@ -43,7 +43,9 @@ import CancelConnection from '../examples/CancelConnection'; import InteractiveMinimap from '../examples/InteractiveMinimap'; import UseOnSelectionChange from '../examples/UseOnSelectionChange'; import NodeToolbar from '../examples/NodeToolbar'; -import useNodesInitialized from '../examples/UseNodesInit'; +import UseNodesInitialized from '../examples/UseNodesInit'; +import UseNodesData from '../examples/UseNodesData'; +import UseHandleConnections from '../examples/UseHandleConnections'; export interface IRoute { name: string; @@ -62,6 +64,11 @@ const routes: IRoute[] = [ path: 'backgrounds', component: Backgrounds, }, + { + name: 'Color Mode', + path: 'color-mode', + component: ColorMode, + }, { name: 'Cancel Connection', path: 'cancel-connection', @@ -177,11 +184,6 @@ const routes: IRoute[] = [ path: 'multiflows', component: MultiFlows, }, - { - name: 'Nested Nodes', - path: 'nested-nodes', - component: NestedNodes, - }, { name: 'Node Type Change', path: 'nodetype-change', @@ -255,7 +257,7 @@ const routes: IRoute[] = [ { name: 'useNodesInitialized', path: 'use-nodes-initialized', - component: useNodesInitialized, + component: UseNodesInitialized, }, { name: 'useOnSelectionChange', @@ -267,6 +269,16 @@ const routes: IRoute[] = [ path: 'usereactflow', component: UseReactFlow, }, + { + name: 'useHandleConnections', + path: 'usehandleconnections', + component: UseHandleConnections, + }, + { + name: 'useNodesData', + path: 'usenodesdata', + component: UseNodesData, + }, { name: 'useUpdateNodeInternals', path: 'useupdatenodeinternals', diff --git a/examples/react/src/examples/ColorMode/index.tsx b/examples/react/src/examples/ColorMode/index.tsx new file mode 100644 index 00000000..eae8ad86 --- /dev/null +++ b/examples/react/src/examples/ColorMode/index.tsx @@ -0,0 +1,90 @@ +import { ChangeEventHandler, useCallback, useState } from 'react'; +import { + ReactFlow, + addEdge, + Node, + useNodesState, + useEdgesState, + OnConnect, + Edge, + MiniMap, + Background, + Controls, + Panel, + ColorMode, + Position, +} from '@xyflow/react'; + +import './style.css'; + +const nodeDefaults = { + sourcePosition: Position.Right, + targetPosition: Position.Left, +}; + +const initialNodes: Node[] = [ + { id: 'A', type: 'input', position: { x: 0, y: 150 }, data: { label: 'A' }, ...nodeDefaults }, + { id: 'B', position: { x: 250, y: 0 }, data: { label: 'B' }, ...nodeDefaults }, + { id: 'C', position: { x: 250, y: 150 }, data: { label: 'C' }, ...nodeDefaults }, + { id: 'D', position: { x: 250, y: 300 }, data: { label: 'D' }, ...nodeDefaults }, +]; + +const initialEdges: Edge[] = [ + { + id: 'A-B', + source: 'A', + target: 'B', + }, + { + id: 'A-C', + source: 'A', + target: 'C', + }, + { + id: 'A-D', + source: 'A', + target: 'D', + }, +]; + +const ColorModeFlow = () => { + const [colorMode, setColorMode] = useState('light'); + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const onConnect: OnConnect = useCallback( + (params) => { + console.log('on connect', params); + setEdges((eds) => addEdge(params, eds)); + }, + [setEdges] + ); + + const onChange: ChangeEventHandler = (evt) => setColorMode(evt.target.value as ColorMode); + + return ( + + + + + + + + + + ); +}; + +export default ColorModeFlow; diff --git a/examples/react/src/examples/ColorMode/style.css b/examples/react/src/examples/ColorMode/style.css new file mode 100644 index 00000000..e69de29b diff --git a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx index aebc8e0e..6857e1ac 100644 --- a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx +++ b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx @@ -1,4 +1,4 @@ -import React, { memo, FC, CSSProperties, useCallback } from 'react'; +import React, { memo, FC, CSSProperties, useCallback, useEffect } from 'react'; import { Handle, Position, NodeProps, Connection, Edge, useOnViewportChange, Viewport } from '@xyflow/react'; const targetHandleStyle: CSSProperties = { background: '#555' }; diff --git a/examples/react/src/examples/EasyConnect/index.tsx b/examples/react/src/examples/EasyConnect/index.tsx index 7e600848..11e347f1 100644 --- a/examples/react/src/examples/EasyConnect/index.tsx +++ b/examples/react/src/examples/EasyConnect/index.tsx @@ -5,7 +5,6 @@ import CustomNode from './CustomNode'; import FloatingEdge from './FloatingEdge'; import CustomConnectionLine from './CustomConnectionLine'; -import '@xyflow/react/dist/style.css'; import './style.css'; const initialNodes: Node[] = [ diff --git a/examples/react/src/examples/EasyConnect/utils.tsx b/examples/react/src/examples/EasyConnect/utils.tsx index cef818f5..6d87795b 100644 --- a/examples/react/src/examples/EasyConnect/utils.tsx +++ b/examples/react/src/examples/EasyConnect/utils.tsx @@ -4,12 +4,13 @@ import { Node, Position, MarkerType, XYPosition } from '@xyflow/react'; // 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!; + } = intersectionNode.computed || {}; + const targetPosition = targetNode.computed?.positionAbsolute!; const w = intersectionNodeWidth! / 2; const h = intersectionNodeHeight! / 2; @@ -32,7 +33,7 @@ function getNodeIntersection(intersectionNode: Node, targetNode: Node) { // 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 n = { ...node.computed?.positionAbsolute, ...node }; const nx = Math.round(n.x!); const ny = Math.round(n.y!); const px = Math.round(intersectionPoint.x); @@ -41,13 +42,13 @@ function getEdgePosition(node: Node, intersectionPoint: XYPosition) { if (px <= nx + 1) { return Position.Left; } - if (px >= nx + n.width! - 1) { + if (px >= nx + n.computed?.width! - 1) { return Position.Right; } if (py <= ny + 1) { return Position.Top; } - if (py >= n.y! + n.height! - 1) { + if (py >= n.y! + n.computed?.height! - 1) { return Position.Bottom; } diff --git a/examples/react/src/examples/FloatingEdges/utils.ts b/examples/react/src/examples/FloatingEdges/utils.ts index 543b0945..c8a6e1c1 100644 --- a/examples/react/src/examples/FloatingEdges/utils.ts +++ b/examples/react/src/examples/FloatingEdges/utils.ts @@ -5,11 +5,11 @@ import { Position, XYPosition, Node, Edge } from '@xyflow/react'; function getNodeIntersection(intersectionNode: Node, targetNode: Node): XYPosition { // 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, - position: intersectionNodePosition, - } = intersectionNode; + const { position: intersectionNodePosition } = intersectionNode; + const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode.computed ?? { + width: 0, + height: 0, + }; const targetPosition = targetNode.position; const w = (intersectionNodeWidth ?? 0) / 2; @@ -42,13 +42,13 @@ function getEdgePosition(node: Node, intersectionPoint: XYPosition) { if (px <= nx + 1) { return Position.Left; } - if (px >= nx + (n.width ?? 0) - 1) { + if (px >= nx + (n.computed?.width ?? 0) - 1) { return Position.Right; } if (py <= ny + 1) { return Position.Top; } - if (py >= n.y + (n.height ?? 0) - 1) { + if (py >= n.y + (n.computed?.height ?? 0) - 1) { return Position.Bottom; } diff --git a/examples/react/src/examples/Hidden/index.tsx b/examples/react/src/examples/Hidden/index.tsx index 586f300f..f00d8bae 100644 --- a/examples/react/src/examples/Hidden/index.tsx +++ b/examples/react/src/examples/Hidden/index.tsx @@ -48,8 +48,10 @@ const initialEdges: Edge[] = [ const setHidden = (hidden: boolean) => (els: any[]) => els.map((e: any) => { - e.hidden = hidden; - return e; + return { + ...e, + hidden, + }; }); const HiddenFlow = () => { diff --git a/examples/react/src/examples/Layouting/index.tsx b/examples/react/src/examples/Layouting/index.tsx index 4ec3e14c..7383e41e 100644 --- a/examples/react/src/examples/Layouting/index.tsx +++ b/examples/react/src/examples/Layouting/index.tsx @@ -56,16 +56,16 @@ const LayoutFlow = () => { const layoutedNodes = nodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id); - node.targetPosition = isHorizontal ? Position.Left : Position.Top; - node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom; - // we need to pass a slightly different position in order to notify react flow about the change - // @TODO how can we change the position handling so that we dont need this hack? - node.position = { - x: nodeWithPosition.x + Math.random() / 1000, - y: nodeWithPosition.y, - }; - return node; + return { + ...node, + targetPosition: isHorizontal ? Position.Left : Position.Top, + sourcePosition: isHorizontal ? Position.Right : Position.Bottom, + position: { + x: nodeWithPosition.x, + y: nodeWithPosition.y, + }, + }; }); setNodes(layoutedNodes); diff --git a/examples/react/src/examples/NestedNodes/index.tsx b/examples/react/src/examples/NestedNodes/index.tsx deleted file mode 100644 index 56bc3ca8..00000000 --- a/examples/react/src/examples/NestedNodes/index.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useState, MouseEvent, useCallback } from 'react'; -import { - ReactFlow, - Controls, - MiniMap, - Background, - addEdge, - useNodesState, - useEdgesState, - Node, - Edge, - ReactFlowInstance, - Connection, -} from '@xyflow/react'; - -const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', node); -const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node); -const onEdgeClick = (_: MouseEvent, edge: Edge) => console.log('click', edge); - -const initialNodes: Node[] = [ - { - id: '1', - type: 'input', - data: { label: 'Node 1' }, - position: { x: 250, y: 5 }, - className: 'light', - }, - { - id: '2', - data: { label: 'Node 2' }, - position: { x: 100, y: 100 }, - className: 'light', - style: { backgroundColor: 'rgba(255, 0, 0, 0.8)', width: 200, height: 200 }, - }, - { - id: '2a', - data: { label: 'Node 2a' }, - position: { x: 10, y: 50 }, - parentNode: '2', - }, - { - id: '3', - data: { label: 'Node 3' }, - position: { x: 320, y: 100 }, - className: 'light', - }, - { - id: '4', - data: { label: 'Node 4' }, - position: { x: 320, y: 200 }, - className: 'light', - style: { backgroundColor: 'rgba(255, 0, 0, 0.7)', width: 300, height: 300 }, - }, - { - id: '4a', - data: { label: 'Node 4a' }, - position: { x: 15, y: 65 }, - className: 'light', - parentNode: '4', - extent: 'parent', - }, - { - id: '4b', - data: { label: 'Node 4b' }, - position: { x: 15, y: 120 }, - className: 'light', - style: { - backgroundColor: 'rgba(255, 0, 255, 0.7)', - height: 150, - width: 270, - }, - parentNode: '4', - }, - { - id: '4b1', - data: { label: 'Node 4b1' }, - position: { x: 20, y: 40 }, - className: 'light', - parentNode: '4b', - }, - { - id: '4b2', - data: { label: 'Node 4b2' }, - position: { x: 100, y: 100 }, - className: 'light', - parentNode: '4b', - }, -]; - -const initialEdges: Edge[] = [ - { id: 'e1-2', source: '1', target: '2', animated: true }, - { id: 'e1-3', source: '1', target: '3' }, - { id: 'e2a-4a', source: '2a', target: '4a' }, - { id: 'e3-4', source: '3', target: '4' }, - { id: 'e3-4b', source: '3', target: '4b' }, - { id: 'e4a-4b1', source: '4a', target: '4b1' }, - { id: 'e4a-4b2', source: '4a', target: '4b2' }, - { id: 'e4b1-4b2', source: '4b1', target: '4b2' }, -]; - -const NestedFlow = () => { - const [rfInstance, setRfInstance] = useState(null); - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - - const onConnect = useCallback( - (connection: Connection) => { - setEdges((eds) => addEdge(connection, eds)); - }, - [setEdges] - ); - const onInit = useCallback((reactFlowInstance: ReactFlowInstance) => setRfInstance(reactFlowInstance), []); - - const updatePos = () => { - setNodes((nds) => { - return nds.map((n) => { - n.position = { - x: Math.random() * 400, - y: Math.random() * 400, - }; - - return n; - }); - }); - }; - - const logToObject = () => console.log(rfInstance?.toObject()); - const resetTransform = () => rfInstance?.setViewport({ x: 0, y: 0, zoom: 1 }); - - const toggleClassnames = () => { - setNodes((nds) => { - return nds.map((n) => { - n.className = n.className === 'light' ? 'dark' : 'light'; - return n; - }); - }); - }; - - const toggleChildNodes = () => { - setNodes((nds) => { - return nds.map((n) => { - n.hidden = !!n.parentNode && !n.hidden; - return n; - }); - }); - }; - - return ( - - - - - -
- - - - - -
-
- ); -}; - -export default NestedFlow; diff --git a/examples/react/src/examples/NodeResizer/index.tsx b/examples/react/src/examples/NodeResizer/index.tsx index e276996c..11ce29d2 100644 --- a/examples/react/src/examples/NodeResizer/index.tsx +++ b/examples/react/src/examples/NodeResizer/index.tsx @@ -16,8 +16,6 @@ import CustomResizer from './CustomResizer'; import VerticalResizer from './VerticalResizer'; import HorizontalResizer from './HorizontalResizer'; -import '@xyflow/react/dist/style.css'; - const nodeTypes = { defaultResizer: DefaultResizer, customResizer: CustomResizer, diff --git a/examples/react/src/examples/Overview/index.tsx b/examples/react/src/examples/Overview/index.tsx index 09110529..e4879ace 100644 --- a/examples/react/src/examples/Overview/index.tsx +++ b/examples/react/src/examples/Overview/index.tsx @@ -23,7 +23,7 @@ const onNodeDragStop = (_: ReactMouseEvent, node: Node, nodes: Node[]) => consol const onNodeDoubleClick = (_: ReactMouseEvent, node: Node) => console.log('node double click', node); const onPaneClick = (event: ReactMouseEvent) => console.log('pane click', event); const onPaneScroll = (event?: ReactMouseEvent) => console.log('pane scroll', event); -const onPaneContextMenu = (event: ReactMouseEvent) => console.log('pane context menu', event); +const onPaneContextMenu = (event: ReactMouseEvent | MouseEvent) => console.log('pane context menu', event); const onSelectionDrag = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag', nodes); const onSelectionDragStart = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag start', nodes); const onSelectionDragStop = (_: ReactMouseEvent, nodes: Node[]) => console.log('selection drag stop', nodes); @@ -230,9 +230,9 @@ const OverviewFlow = () => { onEdgesDelete={onEdgesDelete} onPaneMouseMove={onPaneMouseMove} > - + - + ); }; diff --git a/examples/react/src/examples/Stress/index.tsx b/examples/react/src/examples/Stress/index.tsx index c1c24d86..14b56b09 100644 --- a/examples/react/src/examples/Stress/index.tsx +++ b/examples/react/src/examples/Stress/index.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { ReactFlow, Edge, @@ -16,6 +16,7 @@ import { } from '@xyflow/react'; import { getNodesAndEdges } from './utils'; +import { FrameRecorder, generateMouseEventParamsTargetingNode, nextFrame } from './performanceUtils'; const { nodes: initialNodes, edges: initialEdges } = getNodesAndEdges(25, 25); @@ -25,6 +26,166 @@ const StressFlow = () => { const onConnect = useCallback((connection: Connection) => { setEdges((eds) => addEdge(connection, eds)); }, []); + + const dragInViewport = async () => { + // Note: selecting specifically node 18, as it’s normally located in the right part of the viewport – + // which means dragging it left is safe to do without scrolling the viewport. + const nodeElement = document.querySelector('.react-flow__node[data-id="18"]'); + if (!nodeElement) throw new Error('Node with id 18 not found'); + + const frameRecorder = new FrameRecorder(); + + // Hold down the mouse + frameRecorder.setStage('mousedown'); + const mouseDownEvent = generateMouseEventParamsTargetingNode(nodeElement); + nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseDownEvent)); + await nextFrame(); + + // Start at the node position and move the mouse 5px to the left on every frame + frameRecorder.setStage('mousemove'); + let currentXPosition = mouseDownEvent.clientX; + for (let iteration = 0; iteration < 20; ++iteration) { + const movementX = -5; + currentXPosition += movementX; + + nodeElement.dispatchEvent( + new MouseEvent('mousemove', { + ...mouseDownEvent, + clientX: currentXPosition, + screenX: currentXPosition, + movementX, + }) + ); + await nextFrame(); + } + + // Release the mouse + frameRecorder.setStage('mouseup'); + nodeElement.dispatchEvent( + new MouseEvent('mouseup', { + ...mouseDownEvent, + clientX: currentXPosition, + screenX: currentXPosition, + }) + ); + await nextFrame(); + + // Log the results + await frameRecorder.endRecordingAsync(); + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + }; + + const dragOutsideViewport = async () => { + const randomNodeIndex = Math.floor(Math.random() * nodes.length); + const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodes[randomNodeIndex].id}"]`); + if (!nodeElement) throw new Error('Node not found'); + + const frameRecorder = new FrameRecorder(); + + // Hold down the mouse + frameRecorder.setStage('mousedown'); + const mouseDownEvent = generateMouseEventParamsTargetingNode(nodeElement); + nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseDownEvent)); + await nextFrame(); + + // Move the mouse to the top of the viewport (so that the viewport starts + // scrolling up). Then, wiggle the mouse up and down to keep the viewport + // scrolling. + frameRecorder.setStage('mousemove'); + let currentYPosition = 50; + for (let iteration = 0; iteration < 20; ++iteration) { + const movementY = Math.random() > 0.5 ? +2 : -2; + currentYPosition += movementY; + + nodeElement.dispatchEvent( + new MouseEvent('mousemove', { + ...mouseDownEvent, + clientY: currentYPosition, + screenY: currentYPosition, + movementY, + }) + ); + await nextFrame(); + } + + // Release the mouse + frameRecorder.setStage('mouseup'); + nodeElement.dispatchEvent( + new MouseEvent('mouseup', { + ...mouseDownEvent, + clientY: currentYPosition, + screenY: currentYPosition, + }) + ); + await nextFrame(); + + // Log the results + await frameRecorder.endRecordingAsync(); + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + }; + + const selectNode = async () => { + const randomNodeIndex = Math.floor(Math.random() * nodes.length); + const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodes[randomNodeIndex].id}"]`); + if (!nodeElement) throw new Error('Node not found'); + + const frameRecorder = new FrameRecorder(); + + const mouseEvent = generateMouseEventParamsTargetingNode(nodeElement); + + // mousedown + frameRecorder.setStage('mousedown'); + nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseEvent)); + await nextFrame(); + + // click + frameRecorder.setStage('click'); + nodeElement.dispatchEvent(new MouseEvent('click', mouseEvent)); + await nextFrame(); + + // mouseup + frameRecorder.setStage('mouseup'); + nodeElement.dispatchEvent(new MouseEvent('mouseup', mouseEvent)); + await nextFrame(); + + // Log the results + await frameRecorder.endRecordingAsync(); + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + }; + + const [key, setKey] = useState(0); + const frameRecorderRef = useRef(null); + function remount() { + frameRecorderRef.current = new FrameRecorder(); + setKey((k) => k + 1); + } + useEffect(() => { + const frameRecorder = frameRecorderRef.current; + if (!frameRecorder) return; + + frameRecorder.endRecordingAsync().then(() => { + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + + frameRecorderRef.current = null; + }); + }, [key]); + const updatePos = () => { setNodes((nds) => { return nds.map((n) => { @@ -56,6 +217,7 @@ const StressFlow = () => { return ( { minZoom={0.2} fitView > - + + + + diff --git a/examples/react/src/examples/Stress/performanceUtils.ts b/examples/react/src/examples/Stress/performanceUtils.ts new file mode 100644 index 00000000..f14f8711 --- /dev/null +++ b/examples/react/src/examples/Stress/performanceUtils.ts @@ -0,0 +1,150 @@ +type Frame = { + duration: number; + stage: string; +}; + +/** + * Measures and outputs the duration of every frame that happens between the + * instance is created and `endRecording()` is called. + * + * Usage: + * + * ```ts + * const recorder = new FrameRecorder(); + * + * // Do some performance-intensive stuff + * + * await recorder.endRecordingAsync(); + * + * console.log(recorder.getFrames()); + * console.log(recorder.getFramesForObservable()); // → paste into https://observablehq.com/@iamakulov/long-frame-visualizer + * ``` + */ +export class FrameRecorder { + private frames: Frame[] = []; + private animationFrameId: number; + private stage: string = ''; + + constructor() { + let lastFrameTimestamp = performance.now(); + + const measureFrame = () => { + const timestamp = performance.now(); + + // Visualize the frames in the Performance pane (see the collapsed + // “Timings” section) – so it’s easier to see what exactly each frame + // captured + performance.measure(`frame (${this.stage})`, { + start: lastFrameTimestamp, + end: timestamp, + }); + + this.frames.push({ + duration: timestamp - lastFrameTimestamp, + stage: this.stage, + }); + + lastFrameTimestamp = timestamp; + + this.animationFrameId = requestAnimationFrame(measureFrame); + }; + + this.animationFrameId = requestAnimationFrame(measureFrame); + } + + // The method is explicitly marked `async` in its name to make sure the caller + // doesn’t forget to `await` it. (Otherwise, some events might be lost.) + async endRecordingAsync() { + this.setStage('waiting for idle'); + await new Promise((resolve) => requestIdleCallback(resolve)); + requestAnimationFrame(() => { + cancelAnimationFrame(this.animationFrameId); + }); + } + + /** + * Adds an optional annotation to all subsequent frames. Useful to + * differentiate frames from different events – e.g. you can call + * `setState("mousedown")` before dispatching a mousedown event, and then + * `setState("mouseup")` before a mouseup one. + * + * When used, will affect both `getFramesForObservable()` and `getFrames()`. + */ + setStage(stage: string) { + this.stage = stage; + } + + getFramesForObservable() { + return this.frames.map((frame, index) => ({ ...frame, index })); + } + + getFrames() { + // Group frames by stage – so you could see which frames originated from `mousedown` vs `mousemove` vs `mouseup` events + const framesPerStage: Record = {}; + for (const frame of this.frames) { + const stage = frame.stage; + if (!framesPerStage[stage]) { + framesPerStage[stage] = []; + } + framesPerStage[stage].push(frame.duration); + } + + // If there’s only one stage, return the frames directly + return framesPerStage; + } +} + +/** + * Returns a promise that resolves when the next frame starts, and everything + * that was already scheduled in the event queue has been processed. + */ +export function nextFrame() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * Generates params for a new MouseEvent() that will target the given node. + */ +export function generateMouseEventParamsTargetingNode(node: Element) { + const nodePosition = node.getBoundingClientRect(); + + // Let’s make the event (eg click) happen 5px to the right and 5px to the + // bottom of the node’s top-left corner + const positionRelativeToNode = { + left: 5, + top: 5, + }; + + return { + clientX: Math.round(nodePosition.left + positionRelativeToNode.left), + clientY: Math.round(nodePosition.top + positionRelativeToNode.top), + movementX: 0, + movementY: 0, + offsetX: positionRelativeToNode.left, + offsetY: positionRelativeToNode.top, + screenX: Math.round(nodePosition.left + positionRelativeToNode.left), + screenY: Math.round(nodePosition.top + positionRelativeToNode.top), + + // Required boilerplate + altKey: false, + bubbles: true, + button: 0, + buttons: 1, + cancelBubble: false, + cancelable: true, + composed: true, + ctrlKey: false, + currentTarget: null, + defaultPrevented: false, + detail: 1, + eventPhase: 0, + fromElement: null, + isTrusted: true, + metaKey: false, + relatedTarget: null, + returnValue: true, + shiftKey: false, + view: window, + which: 1, + }; +} diff --git a/examples/react/src/examples/Subflow/DebugNode.tsx b/examples/react/src/examples/Subflow/DebugNode.tsx index 9170ef65..6a8a2d26 100644 --- a/examples/react/src/examples/Subflow/DebugNode.tsx +++ b/examples/react/src/examples/Subflow/DebugNode.tsx @@ -11,13 +11,13 @@ const idStyle: CSSProperties = { left: 2, }; -const DebugNode: FC = ({ zIndex, xPos, yPos, id }) => { +const DebugNode: FC = ({ zIndex, positionAbsoluteX, positionAbsoluteY, id }) => { return ( <>
{id}
- x:{Math.round(xPos || 0)} y:{Math.round(yPos || 0)} z:{zIndex} + x:{Math.round(positionAbsoluteX)} y:{Math.round(positionAbsoluteY)} z:{zIndex}
diff --git a/examples/react/src/examples/UpdateNode/index.tsx b/examples/react/src/examples/UpdateNode/index.tsx index 7c320606..c32fca34 100644 --- a/examples/react/src/examples/UpdateNode/index.tsx +++ b/examples/react/src/examples/UpdateNode/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { ReactFlow, Node, Edge, useNodesState, useEdgesState } from '@xyflow/react'; +import { ReactFlow, Node, Edge, useNodesState, useEdgesState, ReactFlowProvider, useReactFlow } from '@xyflow/react'; import styles from './updatenode.module.css'; @@ -13,6 +13,7 @@ const initialEdges: Edge[] = [{ id: 'e1-2', source: '1', target: '2' }]; const UpdateNode = () => { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const { updateNode } = useReactFlow(); const [nodeName, setNodeName] = useState('Node 1'); const [nodeBg, setNodeBg] = useState('#eee'); @@ -23,9 +24,12 @@ const UpdateNode = () => { nds.map((n) => { if (n.id === '1') { // it's important that you create a new object here in order to notify react flow about the change - n.data = { - ...n.data, - label: nodeName, + return { + ...n, + data: { + ...n.data, + label: nodeName, + }, }; } @@ -39,7 +43,10 @@ const UpdateNode = () => { nds.map((n) => { if (n.id === '1') { // it's important that you create a new object here in order to notify react flow about the change - n.style = { ...n.style, backgroundColor: nodeBg }; + return { + ...n, + style: { ...n.style, backgroundColor: nodeBg }, + }; } return n; @@ -51,8 +58,10 @@ const UpdateNode = () => { setNodes((nds) => nds.map((n) => { if (n.id === '1' || n.id === 'e1-2') { - // when you update a simple type you can just update the value - n.hidden = nodeHidden; + return { + ...n, + hidden: nodeHidden, + }; } return n; @@ -80,9 +89,19 @@ const UpdateNode = () => { setNodeHidden(evt.target.checked)} /> + +
); }; -export default UpdateNode; +export default () => ( + + + +); diff --git a/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx new file mode 100644 index 00000000..4301e647 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx @@ -0,0 +1,39 @@ +import { memo, FC, useEffect, useCallback } from 'react'; +import { Handle, Position, NodeProps, useHandleConnections, Connection, HandleComponentProps } from '@xyflow/react'; + +function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) { + const onConnect = useCallback( + (connections: Connection[]) => console.log('onConnect handler, node id:', nodeId, connections), + [nodeId] + ); + + const onDisconnect = useCallback( + (connections: Connection[]) => console.log('onDisconnect handler, node id:', nodeId, connections), + [nodeId] + ); + const connections = useHandleConnections({ + type: handleProps.type, + id: handleProps.id, + onConnect, + onDisconnect, + }); + + useEffect(() => { + console.log('useEffect, node id:', nodeId, handleProps.type, connections); + }, [connections]); + + return ; +} + +const CustomNode: FC = ({ id }) => { + return ( +
+ +
node {id}
+ + +
+ ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx new file mode 100644 index 00000000..6163f108 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx @@ -0,0 +1,41 @@ +import { memo, FC, useEffect, useCallback } from 'react'; +import { Handle, Position, NodeProps, useHandleConnections, Connection, HandleComponentProps } from '@xyflow/react'; + +function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) { + const onConnect = useCallback( + (connections: Connection[]) => { + console.log('onConnect handler, node id:', nodeId, connections); + }, + [nodeId] + ); + const onDisconnect = useCallback( + (connections: Connection[]) => { + console.log('onDisconnect handler, node id:', nodeId, connections); + }, + [nodeId] + ); + const connections = useHandleConnections({ + type: handleProps.type, + id: handleProps.id, + onConnect, + onDisconnect, + }); + + useEffect(() => { + console.log('useEffect, node id:', nodeId, handleProps.type, connections); + }, [connections]); + + return ; +} + +const CustomNode: FC = ({ id }) => { + return ( +
+ +
node {id}
+ +
+ ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/examples/UseHandleConnections/index.tsx b/examples/react/src/examples/UseHandleConnections/index.tsx new file mode 100644 index 00000000..c6aa4a68 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnections/index.tsx @@ -0,0 +1,118 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + MiniMap, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, +} from '@xyflow/react'; + +import MultiHandleNode from './MultiHandleNode'; +import SingleHandleNode from './SingleHandleNode'; + +const nodeTypes = { + multi: MultiHandleNode, + single: SingleHandleNode, +}; + +const initNodes = [ + { + id: '1', + type: 'single', + data: {}, + position: { x: 0, y: 0 }, + }, + { + id: '2', + type: 'single', + data: {}, + position: { x: 200, y: -100 }, + }, + { + id: '3', + type: 'single', + data: {}, + position: { x: 200, y: 100 }, + }, + + { + id: '4', + type: 'multi', + data: {}, + position: { x: 400, y: 0 }, + }, + { + id: '5', + type: 'multi', + data: {}, + position: { x: 600, y: -100 }, + }, + { + id: '6', + type: 'multi', + data: {}, + position: { x: 600, y: 100 }, + }, +]; + +const initEdges = [ + { + id: 'e1-2', + source: '1', + target: '2', + }, + { + id: 'e1-3', + source: '1', + target: '3', + }, + + { + id: 'e4a-5', + source: '4', + sourceHandle: 'a', + target: '5', + }, + { + id: 'e4b-5', + source: '4', + sourceHandle: 'b', + target: '6', + }, +]; + +const defaultEdgeOptions = { + animated: true, +}; + +const CustomNodeFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + return ( + + + + + + ); +}; + +export default CustomNodeFlow; diff --git a/examples/react/src/examples/UseNodesData/ResultNode.tsx b/examples/react/src/examples/UseNodesData/ResultNode.tsx new file mode 100644 index 00000000..ac9777dd --- /dev/null +++ b/examples/react/src/examples/UseNodesData/ResultNode.tsx @@ -0,0 +1,22 @@ +import { memo } from 'react'; +import { Handle, Position, useHandleConnections, useNodesData } from '@xyflow/react'; + +function ResultNode() { + const connections = useHandleConnections({ + type: 'target', + }); + const nodesData = useNodesData(connections.map((connection) => connection.source)); + + return ( +
+ +
+ incoming texts:{' '} + {nodesData?.filter((nodeData) => nodeData.text !== undefined).map(({ text }, i) =>
{text}
) || + 'none'} +
+
+ ); +} + +export default memo(ResultNode); diff --git a/examples/react/src/examples/UseNodesData/TextNode.tsx b/examples/react/src/examples/UseNodesData/TextNode.tsx new file mode 100644 index 00000000..6ca424e6 --- /dev/null +++ b/examples/react/src/examples/UseNodesData/TextNode.tsx @@ -0,0 +1,20 @@ +import { memo, ChangeEventHandler } from 'react'; +import { Position, NodeProps, Handle, useReactFlow } from '@xyflow/react'; + +function TextNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); + + const onChange: ChangeEventHandler = (evt) => updateNodeData(id, { text: evt.target.value }); + + return ( +
+
node {id}
+
+ +
+ +
+ ); +} + +export default memo(TextNode); diff --git a/examples/react/src/examples/UseNodesData/UppercaseNode.tsx b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx new file mode 100644 index 00000000..699dc37e --- /dev/null +++ b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx @@ -0,0 +1,24 @@ +import { memo, useEffect } from 'react'; +import { Position, NodeProps, useReactFlow, Handle, useHandleConnections, useNodesData } from '@xyflow/react'; + +function UppercaseNode({ id }: NodeProps) { + const { updateNodeData } = useReactFlow(); + const connections = useHandleConnections({ + type: 'target', + }); + const nodeData = useNodesData(connections[0]?.source); + + useEffect(() => { + updateNodeData(id, { text: nodeData?.text.toUpperCase() }); + }, [nodeData]); + + return ( +
+ +
uppercase transform
+ +
+ ); +} + +export default memo(UppercaseNode); diff --git a/examples/react/src/examples/UseNodesData/index.tsx b/examples/react/src/examples/UseNodesData/index.tsx new file mode 100644 index 00000000..7bb1a94e --- /dev/null +++ b/examples/react/src/examples/UseNodesData/index.tsx @@ -0,0 +1,102 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, + Node, + Edge, +} from '@xyflow/react'; + +import TextNode from './TextNode'; +import ResultNode from './ResultNode'; +import UppercaseNode from './UppercaseNode'; + +export type TextNode = Node<{ text: string }, 'text'>; +export type ResultNode = Node<{}, 'result'>; +export type UppercaseNode = Node<{}, 'uppercase'>; +export type MyNode = Node<{ text: string }, 'text'> | Node<{}, 'result'> | Node<{}, 'uppercase'>; + +const nodeTypes = { + text: TextNode, + result: ResultNode, + uppercase: UppercaseNode, +}; + +const initNodes: MyNode[] = [ + { + id: '1', + type: 'text', + data: { + text: 'hello', + }, + position: { x: -100, y: -50 }, + }, + { + id: '1a', + type: 'uppercase', + data: {}, + position: { x: 100, y: 0 }, + }, + + { + id: '2', + type: 'text', + data: { + text: 'world', + }, + position: { x: 0, y: 100 }, + }, + + { + id: '3', + type: 'result', + data: {}, + position: { x: 300, y: 50 }, + }, +]; + +const initEdges: Edge[] = [ + { + id: 'e1-1a', + source: '1', + target: '1a', + }, + { + id: 'e1a-3', + source: '1a', + target: '3', + }, + { + id: 'e2-3', + source: '2', + target: '3', + }, +]; + +const CustomNodeFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + return ( + + + + + ); +}; + +export default CustomNodeFlow; diff --git a/examples/react/src/generic-tests/node-toolbar/components/ToolbarNode.tsx b/examples/react/src/generic-tests/node-toolbar/components/ToolbarNode.tsx new file mode 100644 index 00000000..30384445 --- /dev/null +++ b/examples/react/src/generic-tests/node-toolbar/components/ToolbarNode.tsx @@ -0,0 +1,19 @@ +import { memo, FC } from 'react'; +import { Handle, Position, NodeProps, NodeToolbar } from '@xyflow/react'; + +const CustomNode: FC = ({ id, data }) => { + return ( + <> + + + + + +
{data.label}
+ + + + ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/generic-tests/node-toolbar/general.ts b/examples/react/src/generic-tests/node-toolbar/general.ts new file mode 100644 index 00000000..d7df3fa9 --- /dev/null +++ b/examples/react/src/generic-tests/node-toolbar/general.ts @@ -0,0 +1,50 @@ +import { Position, type Node } from '@xyflow/react'; +import ToolbarNode from './components/ToolbarNode'; + +const positions = ['top', 'right', 'bottom', 'left']; +const alignments = ['start', 'center', 'end']; + +const nodes: Node[] = [ + { + id: 'default-node', + type: 'ToolbarNode', + data: { label: 'toolbar top', toolbarPosition: Position.Top }, + position: { x: 0, y: -200 }, + className: 'react-flow__node-default', + }, +]; + +positions.forEach((position, posIndex) => { + alignments.forEach((align, alignIndex) => { + const id = `node-${align}-${position}`; + nodes.push({ + id, + type: 'ToolbarNode', + data: { + label: `toolbar ${position} ${align}`, + toolbarPosition: position as Position, + toolbarAlign: align, + toolbarVisible: true, + }, + className: 'react-flow__node-default', + position: { x: posIndex * 300, y: alignIndex * 100 }, + }); + }); +}); + +export default { + flowProps: { + fitView: true, + nodeTypes: { + ToolbarNode, + }, + nodes, + edges: [ + { + id: 'first-edge', + source: 'default-node', + target: 'node-start-top', + }, + ], + }, +} satisfies FlowConfig; diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index 663a29b5..4fdcd68a 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import App from './App'; +import App from './App/index'; import '@xyflow/react/dist/style.css'; import './index.css'; diff --git a/examples/svelte/src/components/Header/Header.svelte b/examples/svelte/src/components/Header/Header.svelte index 0bd941b7..9d67f049 100644 --- a/examples/svelte/src/components/Header/Header.svelte +++ b/examples/svelte/src/components/Header/Header.svelte @@ -4,18 +4,22 @@ const routes = [ 'add-node-on-drop', + 'color-mode', 'custom-connection-line', 'customnode', 'dagre', 'drag-n-drop', 'edges', 'figma', + 'handle-connect', 'interaction', 'intersections', + 'node-toolbar', 'overview', 'stress', 'subflows', 'two-way-viewport', + 'usenodesdata', 'usesvelteflow', 'useupdatenodeinternals', 'validation' diff --git a/examples/svelte/src/generic-tests/node-toolbar/components/ToolbarNode.svelte b/examples/svelte/src/generic-tests/node-toolbar/components/ToolbarNode.svelte new file mode 100644 index 00000000..0a998217 --- /dev/null +++ b/examples/svelte/src/generic-tests/node-toolbar/components/ToolbarNode.svelte @@ -0,0 +1,30 @@ + + + + + + + +
+
{data.label}
+ + +
+ + diff --git a/examples/svelte/src/generic-tests/node-toolbar/general.ts b/examples/svelte/src/generic-tests/node-toolbar/general.ts new file mode 100644 index 00000000..6fce800f --- /dev/null +++ b/examples/svelte/src/generic-tests/node-toolbar/general.ts @@ -0,0 +1,50 @@ +import { Position, type Node } from '@xyflow/svelte'; +import ToolbarNode from './components/ToolbarNode.svelte'; + +const positions = ['top', 'right', 'bottom', 'left']; +const alignments = ['start', 'center', 'end']; + +const nodes: Node[] = [ + { + id: 'default-node', + type: 'ToolbarNode', + data: { label: 'toolbar top', toolbarPosition: Position.Top }, + position: { x: 0, y: -200 }, + class: 'react-flow__node-default' + } +]; + +positions.forEach((position, posIndex) => { + alignments.forEach((align, alignIndex) => { + const id = `node-${align}-${position}`; + nodes.push({ + id, + type: 'ToolbarNode', + data: { + label: `toolbar ${position} ${align}`, + toolbarPosition: position as Position, + toolbarAlign: align, + toolbarVisible: true + }, + class: 'react-flow__node-default', + position: { x: posIndex * 300, y: alignIndex * 100 } + }); + }); +}); + +export default { + flowProps: { + fitView: true, + nodeTypes: { + ToolbarNode + }, + nodes, + edges: [ + { + id: 'first-edge', + source: 'default-node', + target: 'node-start-top' + } + ] + } +} satisfies FlowConfig; diff --git a/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte b/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte index 5556ca80..809ae82c 100644 --- a/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte +++ b/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte @@ -1,6 +1,12 @@ @@ -69,11 +77,11 @@ {edges} fitView fitViewOptions={{ padding: 2 }} - on:connectstart={({ detail: { nodeId } }) => { + onconnectstart={(_, { nodeId }) => { // Memorize the nodeId you start draggin a connection line from a node connectingNodeId = nodeId; }} - on:connectend={handleConnectEnd} + onconnectend={handleConnectEnd} /> diff --git a/examples/svelte/src/routes/examples/color-mode/+page.svelte b/examples/svelte/src/routes/examples/color-mode/+page.svelte new file mode 100644 index 00000000..59ddfdfd --- /dev/null +++ b/examples/svelte/src/routes/examples/color-mode/+page.svelte @@ -0,0 +1,54 @@ + + + + + + + + + + + diff --git a/examples/svelte/src/routes/examples/customnode/+page.svelte b/examples/svelte/src/routes/examples/customnode/+page.svelte index 14130a47..525b0706 100644 --- a/examples/svelte/src/routes/examples/customnode/+page.svelte +++ b/examples/svelte/src/routes/examples/customnode/+page.svelte @@ -85,9 +85,9 @@ {nodes} {edges} {nodeTypes} - style="--background-color: {$bgColor}" + style="--xy-background-color: {$bgColor}" fitView - on:connect={onConnect} + onconnect={onConnect} > diff --git a/examples/svelte/src/routes/examples/customnode/CustomNode.svelte b/examples/svelte/src/routes/examples/customnode/CustomNode.svelte index 25d37d32..ca01be5e 100644 --- a/examples/svelte/src/routes/examples/customnode/CustomNode.svelte +++ b/examples/svelte/src/routes/examples/customnode/CustomNode.svelte @@ -10,7 +10,7 @@
- +
Custom Color Picker Node: {$colorStore}
@@ -20,14 +20,8 @@ on:input={(evt) => colorStore.set(evt.currentTarget.value)} value={$colorStore} /> - - + +
diff --git a/examples/svelte/src/routes/examples/edges/CustomBezierEdge.svelte b/examples/svelte/src/routes/examples/edges/CustomBezierEdge.svelte new file mode 100644 index 00000000..48d86c3d --- /dev/null +++ b/examples/svelte/src/routes/examples/edges/CustomBezierEdge.svelte @@ -0,0 +1,55 @@ + + + diff --git a/examples/svelte/src/routes/examples/handle-connect/+page.svelte b/examples/svelte/src/routes/examples/handle-connect/+page.svelte new file mode 100644 index 00000000..4f121c83 --- /dev/null +++ b/examples/svelte/src/routes/examples/handle-connect/+page.svelte @@ -0,0 +1,94 @@ + + + + + + + diff --git a/examples/svelte/src/routes/examples/handle-connect/MultiHandleNode.svelte b/examples/svelte/src/routes/examples/handle-connect/MultiHandleNode.svelte new file mode 100644 index 00000000..ebc85c74 --- /dev/null +++ b/examples/svelte/src/routes/examples/handle-connect/MultiHandleNode.svelte @@ -0,0 +1,110 @@ + + +
+ +
node {id}
+ onConnectSource('a', connections)} + ondisconnect={(connections) => onDisconnectSource('a', connections)} + class="source-a" + /> + onConnectSource('b', connections)} + ondisconnect={(connections) => onDisconnectSource('b', connections)} + class="source-b" + /> +
+ + diff --git a/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte b/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte new file mode 100644 index 00000000..7c1e026d --- /dev/null +++ b/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte @@ -0,0 +1,77 @@ + + +
+ +
node {id}
+ +
+ + diff --git a/examples/svelte/src/routes/examples/node-toolbar/+page.svelte b/examples/svelte/src/routes/examples/node-toolbar/+page.svelte new file mode 100644 index 00000000..1a4360e5 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-toolbar/+page.svelte @@ -0,0 +1,63 @@ + + +
+ + + + +
diff --git a/examples/svelte/src/routes/examples/node-toolbar/CustomNode.svelte b/examples/svelte/src/routes/examples/node-toolbar/CustomNode.svelte new file mode 100644 index 00000000..af2dabaa --- /dev/null +++ b/examples/svelte/src/routes/examples/node-toolbar/CustomNode.svelte @@ -0,0 +1,31 @@ + + + + + + + +
+
{data.label}
+ + +
+ + diff --git a/examples/svelte/src/routes/examples/node-toolbar/SelectedNodesToolbar.svelte b/examples/svelte/src/routes/examples/node-toolbar/SelectedNodesToolbar.svelte new file mode 100644 index 00000000..df62b856 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-toolbar/SelectedNodesToolbar.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/examples/svelte/src/routes/examples/overview/+page.svelte b/examples/svelte/src/routes/examples/overview/+page.svelte index dddbf398..d8293666 100644 --- a/examples/svelte/src/routes/examples/overview/+page.svelte +++ b/examples/svelte/src/routes/examples/overview/+page.svelte @@ -12,7 +12,8 @@ type EdgeTypes, type Node, type Edge, - ConnectionMode + ConnectionMode, + useSvelteFlow } from '@xyflow/svelte'; import CustomNode from './CustomNode.svelte'; @@ -150,9 +151,9 @@ on:nodemouseenter={(event) => console.log('on node enter', event)} on:nodemouseleave={(event) => console.log('on node leave', event)} on:edgeclick={(event) => console.log('edge click', event)} - on:connectstart={(event) => console.log('on connect start', event)} - on:connect={(event) => console.log('on connect', event)} - on:connectend={(event) => console.log('on connect end', event)} + onconnectstart={(event) => console.log('on connect start', event)} + onconnect={(event) => console.log('on connect', event)} + onconnectend={(event) => console.log('on connect end', event)} on:paneclick={(event) => console.log('on pane click', event)} on:panecontextmenu={(event) => { console.log('on pane contextmenu', event); @@ -174,6 +175,8 @@ event.preventDefault(); console.log('on edge contextmenu', edge); }} + on:selectionclick={(event) => console.log('on selection click', event)} + on:selectioncontextmenu={(event) => console.log('on selection contextmenu', event)} autoPanOnConnect autoPanOnNodeDrag connectionMode={ConnectionMode.Strict} diff --git a/examples/svelte/src/routes/examples/overview/CustomNode.svelte b/examples/svelte/src/routes/examples/overview/CustomNode.svelte index a8f6a226..faf6a2a5 100644 --- a/examples/svelte/src/routes/examples/overview/CustomNode.svelte +++ b/examples/svelte/src/routes/examples/overview/CustomNode.svelte @@ -4,13 +4,13 @@ type $$Props = NodeProps; export let data: { label: string } = { label: 'Node' }; - export let xPos: number = 0; - export let yPos: number = 0; + export let positionAbsoluteX: number = 0; + export let positionAbsoluteY: number = 0;
{data.label}
-
{~~xPos}, {~~yPos}
+
{~~positionAbsoluteX}, {~~positionAbsoluteY}
diff --git a/examples/svelte/src/routes/examples/subflows/DebugNode.svelte b/examples/svelte/src/routes/examples/subflows/DebugNode.svelte index 7b4434be..9676957e 100644 --- a/examples/svelte/src/routes/examples/subflows/DebugNode.svelte +++ b/examples/svelte/src/routes/examples/subflows/DebugNode.svelte @@ -1,24 +1,16 @@
{id}
- x:{Math.round(xPos || 0)} y:{Math.round(yPos || 0)} z:{zIndex} + x:{Math.round(positionAbsolute.x)} y:{Math.round(positionAbsolute.y)} z:{zIndex}
- - diff --git a/examples/svelte/src/routes/examples/usenodesdata/+page.svelte b/examples/svelte/src/routes/examples/usenodesdata/+page.svelte new file mode 100644 index 00000000..7b2d2204 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/+page.svelte @@ -0,0 +1,80 @@ + + + + + + + diff --git a/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte new file mode 100644 index 00000000..9dd98173 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte @@ -0,0 +1,37 @@ + + +
+ +
incoming texts:
+ + {#each $nodeData as data} +
{data.text}
+ {/each} +
+ + diff --git a/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte new file mode 100644 index 00000000..c1642da2 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte @@ -0,0 +1,29 @@ + + +
+
node {id}
+
+ updateNodeData(id, { text: evt.currentTarget.value })} + /> +
+ +
+ + diff --git a/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte new file mode 100644 index 00000000..2dcf3bde --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte @@ -0,0 +1,40 @@ + + +
+ +
uppercase transform
+ +
+ + diff --git a/examples/svelte/src/routes/examples/useupdatenodeinternals/CustomNode.svelte b/examples/svelte/src/routes/examples/useupdatenodeinternals/CustomNode.svelte index f4d306ac..fbe9c8d7 100644 --- a/examples/svelte/src/routes/examples/useupdatenodeinternals/CustomNode.svelte +++ b/examples/svelte/src/routes/examples/useupdatenodeinternals/CustomNode.svelte @@ -15,15 +15,9 @@ }; - + {#each Array.from({ length: handleCount }) as handle, i} - + {/each} diff --git a/examples/svelte/src/routes/examples/useupdatenodeinternals/Flow.svelte b/examples/svelte/src/routes/examples/useupdatenodeinternals/Flow.svelte index 13b48cb3..42c76cd0 100644 --- a/examples/svelte/src/routes/examples/useupdatenodeinternals/Flow.svelte +++ b/examples/svelte/src/routes/examples/useupdatenodeinternals/Flow.svelte @@ -6,7 +6,9 @@ Background, BackgroundVariant, MiniMap, - type NodeTypes + type NodeTypes, + useSvelteFlow, + Panel } from '@xyflow/svelte'; import CustomNode from './CustomNode.svelte'; @@ -50,6 +52,12 @@ target: '3' } ]); + + const { updateNode } = useSvelteFlow(); + + const updateNodePosition = () => { + updateNode('1', (node) => ({ position: { x: node.position.x + 10, y: node.position.y } })); + };
@@ -57,6 +65,8 @@ + +
diff --git a/packages/react/src/additional-components/Background/Background.tsx b/packages/react/src/additional-components/Background/Background.tsx index 843daf19..3e71d723 100644 --- a/packages/react/src/additional-components/Background/Background.tsx +++ b/packages/react/src/additional-components/Background/Background.tsx @@ -55,8 +55,8 @@ function Background({ { ...style, ...containerStyle, - '--background-color-props': bgColor, - '--background-pattern-color-props': color, + '--xy-background-color-props': bgColor, + '--xy-background-pattern-color-props': color, } as CSSProperties } ref={ref} diff --git a/packages/react/src/additional-components/Background/Patterns.tsx b/packages/react/src/additional-components/Background/Patterns.tsx index 1fc71ae5..61715c5a 100644 --- a/packages/react/src/additional-components/Background/Patterns.tsx +++ b/packages/react/src/additional-components/Background/Patterns.tsx @@ -26,6 +26,6 @@ type DotPatternProps = { export function DotPattern({ radius, className }: DotPatternProps) { return ( - + ); } diff --git a/packages/react/src/additional-components/MiniMap/MiniMap.tsx b/packages/react/src/additional-components/MiniMap/MiniMap.tsx index f56ae57f..8d1f0538 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMap.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMap.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { memo, useEffect, useRef, type MouseEvent, useCallback } from 'react'; +import { memo, useEffect, useRef, type MouseEvent, useCallback, CSSProperties } from 'react'; import cc from 'classcat'; import { shallow } from 'zustand/shallow'; import { getNodesBounds, getBoundsOfRects, XYMinimap, type Rect, type XYMinimapInstance } from '@xyflow/system'; @@ -40,15 +40,15 @@ const ARIA_LABEL_KEY = 'react-flow__minimap-desc'; function MiniMap({ style, className, - nodeStrokeColor = 'transparent', - nodeColor = '#e2e2e2', + nodeStrokeColor, + nodeColor, nodeClassName = '', nodeBorderRadius = 5, - nodeStrokeWidth = 2, + nodeStrokeWidth, // We need to rename the prop to be `CapitalCase` so that JSX will render it as // a component properly. nodeComponent, - maskColor = 'rgb(240, 240, 240, 0.6)', + maskColor, maskStrokeColor = 'none', maskStrokeWidth = 1, position = 'bottom-right', @@ -126,7 +126,15 @@ function MiniMap({ return ( @@ -153,7 +161,6 @@ function MiniMap({ className="react-flow__minimap-mask" d={`M${x - offset},${y - offset}h${width + offset * 2}v${height + offset * 2}h${-width - offset * 2}z M${viewBB.x},${viewBB.y}h${viewBB.width}v${viewBB.height}h${-viewBB.width}z`} - fill={maskColor} fillRule="evenodd" stroke={maskStrokeColor} strokeWidth={maskStrokeWidth} diff --git a/packages/react/src/additional-components/MiniMap/MiniMapNode.tsx b/packages/react/src/additional-components/MiniMap/MiniMapNode.tsx index 5fe70462..76edf5e6 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMapNode.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMapNode.tsx @@ -31,9 +31,11 @@ function MiniMapNode({ ry={borderRadius} width={width} height={height} - fill={fill} - stroke={strokeColor} - strokeWidth={strokeWidth} + style={{ + fill, + stroke: strokeColor, + strokeWidth, + }} shapeRendering={shapeRendering} onClick={onClick ? (event) => onClick(event, id) : undefined} /> diff --git a/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx b/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx index 21bb8fc5..162d0b41 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx @@ -1,32 +1,32 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { memo } from 'react'; +import { ComponentType, memo } from 'react'; +import { NodeOrigin, getNodePositionWithOrigin } from '@xyflow/system'; import { shallow } from 'zustand/shallow'; -import { getNodePositionWithOrigin } from '@xyflow/system'; import { useStore } from '../../hooks/useStore'; import type { ReactFlowState } from '../../types'; import MiniMapNode from './MiniMapNode'; -import type { MiniMapNodes, GetMiniMapNodeAttribute } from './types'; +import type { MiniMapNodes as MiniMapNodesProps, GetMiniMapNodeAttribute, MiniMapNodeProps } from './types'; declare const window: any; const selector = (s: ReactFlowState) => s.nodeOrigin; -const selectorNodes = (s: ReactFlowState) => s.nodes.filter((node) => !node.hidden && node.width && node.height); +const selectorNodeIds = (s: ReactFlowState) => s.nodes.map((node) => node.id); const getAttrFunction = (func: any): GetMiniMapNodeAttribute => (func instanceof Function ? func : () => func); function MiniMapNodes({ - nodeStrokeColor = 'transparent', - nodeColor = '#e2e2e2', + nodeStrokeColor, + nodeColor, nodeClassName = '', nodeBorderRadius = 5, - nodeStrokeWidth = 2, + nodeStrokeWidth, // We need to rename the prop to be `CapitalCase` so that JSX will render it as // a component properly. nodeComponent: NodeComponent = MiniMapNode, onClick, -}: MiniMapNodes) { - const nodes = useStore(selectorNodes, shallow); +}: MiniMapNodesProps) { + const nodeIds = useStore(selectorNodeIds, shallow); const nodeOrigin = useStore(selector); const nodeColorFunc = getAttrFunction(nodeColor); const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor); @@ -36,31 +36,78 @@ function MiniMapNodes({ return ( <> - {nodes.map((node) => { - const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute; - - return ( - - ); - })} + {nodeIds.map((nodeId) => ( + // The split of responsibilities between MiniMapNodes and + // NodeComponentWrapper may appear weird. However, it’s designed to + // minimize the cost of updates when individual nodes change. + // + // For more details, see a similar commit in `NodeRenderer/index.tsx`. + + ))} ); } +const NodeComponentWrapper = memo(function NodeComponentWrapper({ + id, + nodeOrigin, + nodeColorFunc, + nodeStrokeColorFunc, + nodeClassNameFunc, + nodeBorderRadius, + nodeStrokeWidth, + shapeRendering, + NodeComponent, + onClick, +}: { + id: string; + nodeOrigin: NodeOrigin; + nodeColorFunc: GetMiniMapNodeAttribute; + nodeStrokeColorFunc: GetMiniMapNodeAttribute; + nodeClassNameFunc: GetMiniMapNodeAttribute; + nodeBorderRadius: number; + nodeStrokeWidth?: number; + NodeComponent: ComponentType; + onClick: MiniMapNodesProps['onClick']; + shapeRendering: string; +}) { + const node = useStore((s) => s.nodeLookup.get(id)); + if (!node || node.hidden || !(node.computed?.width || node.width) || !(node.computed?.height || node.height)) { + return null; + } + + const positionOrigin = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute; + + return ( + + ); +}); + export default memo(MiniMapNodes); diff --git a/packages/react/src/additional-components/MiniMap/types.ts b/packages/react/src/additional-components/MiniMap/types.ts index 323374cf..5667f7a8 100644 --- a/packages/react/src/additional-components/MiniMap/types.ts +++ b/packages/react/src/additional-components/MiniMap/types.ts @@ -42,10 +42,10 @@ export type MiniMapNodeProps = { height: number; borderRadius: number; className: string; - color: string; + color?: string; shapeRendering: string; - strokeColor: string; - strokeWidth: number; + strokeColor?: string; + strokeWidth?: number; style?: CSSProperties; selected: boolean; onClick?: (event: MouseEvent, id: string) => void; diff --git a/packages/react/src/additional-components/NodeResizer/ResizeControl.tsx b/packages/react/src/additional-components/NodeResizer/ResizeControl.tsx index 36783fcf..1df32e06 100644 --- a/packages/react/src/additional-components/NodeResizer/ResizeControl.tsx +++ b/packages/react/src/additional-components/NodeResizer/ResizeControl.tsx @@ -70,8 +70,8 @@ function ResizeControl({ const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid }); prevValues.current = { - width: node?.width ?? 0, - height: node?.height ?? 0, + width: node?.computed?.width ?? 0, + height: node?.computed?.height ?? 0, x: node?.position.x ?? 0, y: node?.position.y ?? 0, }; diff --git a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx index ad581bcf..6f4ad13c 100644 --- a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx +++ b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx @@ -1,74 +1,40 @@ import { useCallback, CSSProperties } from 'react'; import cc from 'classcat'; import { shallow } from 'zustand/shallow'; -import { getNodesBounds, Transform, Rect, Position, internalsSymbol } from '@xyflow/system'; +import { getNodesBounds, Rect, Position, internalsSymbol, getNodeToolbarTransform } from '@xyflow/system'; import { Node, ReactFlowState } from '../../types'; import { useStore } from '../../hooks/useStore'; import { useNodeId } from '../../contexts/NodeIdContext'; import NodeToolbarPortal from './NodeToolbarPortal'; -import { Align, NodeToolbarProps } from './types'; +import { NodeToolbarProps } from './types'; -const nodeEqualityFn = (a: Node | undefined, b: Node | undefined) => - a?.positionAbsolute?.x === b?.positionAbsolute?.x && - a?.positionAbsolute?.y === b?.positionAbsolute?.y && - a?.width === b?.width && - a?.height === b?.height && - a?.selected === b?.selected && - a?.[internalsSymbol]?.z === b?.[internalsSymbol]?.z; +const nodeEqualityFn = (a?: Node, b?: Node) => + a?.computed?.positionAbsolute?.x !== b?.computed?.positionAbsolute?.x || + a?.computed?.positionAbsolute?.y !== b?.computed?.positionAbsolute?.y || + a?.computed?.width !== b?.computed?.width || + a?.computed?.height !== b?.computed?.height || + a?.selected !== b?.selected || + a?.[internalsSymbol]?.z !== b?.[internalsSymbol]?.z; const nodesEqualityFn = (a: Node[], b: Node[]) => { - return a.length === b.length && a.every((node, i) => nodeEqualityFn(node, b[i])); + if (a.length !== b.length) { + return false; + } + + return !a.some((node, i) => nodeEqualityFn(node, b[i])); }; const storeSelector = (state: ReactFlowState) => ({ - transform: state.transform, + viewport: { + x: state.transform[0], + y: state.transform[1], + zoom: state.transform[2], + }, nodeOrigin: state.nodeOrigin, selectedNodesCount: state.nodes.filter((node) => node.selected).length, }); -function getTransform(nodeRect: Rect, transform: Transform, position: Position, offset: number, align: Align): string { - let alignmentOffset = 0.5; - - if (align === 'start') { - alignmentOffset = 0; - } else if (align === 'end') { - alignmentOffset = 1; - } - - // position === Position.Top - // we set the x any y position of the toolbar based on the nodes position - let pos = [ - (nodeRect.x + nodeRect.width * alignmentOffset) * transform[2] + transform[0], - nodeRect.y * transform[2] + transform[1] - offset, - ]; - // and than shift it based on the alignment. The shift values are in %. - let shift = [-100 * alignmentOffset, -100]; - - switch (position) { - case Position.Right: - pos = [ - (nodeRect.x + nodeRect.width) * transform[2] + transform[0] + offset, - (nodeRect.y + nodeRect.height * alignmentOffset) * transform[2] + transform[1], - ]; - shift = [0, -100 * alignmentOffset]; - break; - case Position.Bottom: - pos[1] = (nodeRect.y + nodeRect.height) * transform[2] + transform[1] + offset; - shift[1] = 0; - break; - case Position.Left: - pos = [ - nodeRect.x * transform[2] + transform[0] - offset, - (nodeRect.y + nodeRect.height * alignmentOffset) * transform[2] + transform[1], - ]; - shift = [-100, -100 * alignmentOffset]; - break; - } - - return `translate(${pos[0]}px, ${pos[1]}px) translate(${shift[0]}%, ${shift[1]}%)`; -} - function NodeToolbar({ nodeId, children, @@ -97,7 +63,9 @@ function NodeToolbar({ [nodeId, contextNodeId] ); const nodes = useStore(nodesSelector, nodesEqualityFn); - const { transform, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow); + const { viewport, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow); + + // if isVisible is not set, we show the toolbar only if its node is selected and no other node is selected const isActive = typeof isVisible === 'boolean' ? isVisible : nodes.length === 1 && nodes[0].selected && selectedNodesCount === 1; @@ -110,14 +78,19 @@ function NodeToolbar({ const wrapperStyle: CSSProperties = { position: 'absolute', - transform: getTransform(nodeRect, transform, position, offset, align), + transform: getNodeToolbarTransform(nodeRect, viewport, position, offset, align), zIndex, ...style, }; return ( -
+
`${acc}${node.id} `, '').trim()} + > {children}
diff --git a/packages/react/src/additional-components/NodeToolbar/types.ts b/packages/react/src/additional-components/NodeToolbar/types.ts index 29478576..f08859dc 100644 --- a/packages/react/src/additional-components/NodeToolbar/types.ts +++ b/packages/react/src/additional-components/NodeToolbar/types.ts @@ -1,5 +1,5 @@ import type { HTMLAttributes } from 'react'; -import type { Position } from '@xyflow/system'; +import type { Position, Align } from '@xyflow/system'; export type NodeToolbarProps = HTMLAttributes & { nodeId?: string | string[]; @@ -8,5 +8,3 @@ export type NodeToolbarProps = HTMLAttributes & { offset?: number; align?: Align; }; - -export type Align = 'center' | 'start' | 'end'; diff --git a/packages/react/src/components/ConnectionLine/index.tsx b/packages/react/src/components/ConnectionLine/index.tsx index ed1a2115..bdf73089 100644 --- a/packages/react/src/components/ConnectionLine/index.tsx +++ b/packages/react/src/components/ConnectionLine/index.tsx @@ -65,10 +65,10 @@ const ConnectionLine = ({ } const fromHandle = handleId ? handleBounds.find((d) => d.id === handleId) : handleBounds[0]; - const fromHandleX = fromHandle ? fromHandle.x + fromHandle.width / 2 : (fromNode.width ?? 0) / 2; - const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode.height ?? 0; - const fromX = (fromNode.positionAbsolute?.x ?? 0) + fromHandleX; - const fromY = (fromNode.positionAbsolute?.y ?? 0) + fromHandleY; + const fromHandleX = fromHandle ? fromHandle.x + fromHandle.width / 2 : (fromNode.computed?.width ?? 0) / 2; + const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode.computed?.height ?? 0; + const fromX = (fromNode.computed?.positionAbsolute?.x ?? 0) + fromHandleX; + const fromY = (fromNode.computed?.positionAbsolute?.y ?? 0) + fromHandleY; const fromPosition = fromHandle?.position; const toPosition = fromPosition ? oppositePosition[fromPosition] : null; diff --git a/packages/react/src/components/EdgeWrapper/index.tsx b/packages/react/src/components/EdgeWrapper/index.tsx new file mode 100644 index 00000000..de387ce5 --- /dev/null +++ b/packages/react/src/components/EdgeWrapper/index.tsx @@ -0,0 +1,310 @@ +import { memo, useState, useMemo, useRef, type KeyboardEvent, useCallback } from 'react'; +import cc from 'classcat'; +import { shallow } from 'zustand/shallow'; +import { + getMarkerId, + elementSelectionKeys, + XYHandle, + type Connection, + getEdgePosition, + errorMessages, +} from '@xyflow/system'; + +import { useStoreApi, useStore } from '../../hooks/useStore'; +import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions'; +import { EdgeAnchor } from '../Edges/EdgeAnchor'; +import { getMouseHandler } from '../Edges/utils'; +import type { EdgeWrapperProps } from '../../types'; +import { builtinEdgeTypes } from './utils'; + +function EdgeWrapper({ + id, + className, + type, + data, + onClick, + onEdgeDoubleClick, + selected, + animated, + label, + labelStyle, + labelShowBg, + labelBgStyle, + labelBgPadding, + labelBgBorderRadius, + style, + source, + target, + isSelectable, + hidden, + sourceHandleId, + targetHandleId, + onContextMenu, + onMouseEnter, + onMouseMove, + onMouseLeave, + edgeUpdaterRadius, + onEdgeUpdate, + onEdgeUpdateStart, + onEdgeUpdateEnd, + markerEnd, + markerStart, + rfId, + ariaLabel, + isFocusable, + isUpdatable, + pathOptions, + interactionWidth, + edgeTypes, + zIndex, + onError, +}: EdgeWrapperProps): JSX.Element | null { + let edgeType = type || 'default'; + let EdgeComponent = edgeTypes?.[edgeType] || builtinEdgeTypes[edgeType]; + + if (EdgeComponent === undefined) { + onError?.('011', errorMessages['error011'](edgeType)); + edgeType = 'default'; + EdgeComponent = builtinEdgeTypes.default; + } + + const edgeRef = useRef(null); + const [updateHover, setUpdateHover] = useState(false); + const [updating, setUpdating] = useState(false); + const store = useStoreApi(); + const edgePosition = useStore( + useCallback( + (state) => { + const sourceNode = state.nodeLookup.get(source); + const targetNode = state.nodeLookup.get(target); + + if (!sourceNode || !targetNode) { + return null; + } + + return getEdgePosition({ + id, + sourceNode, + targetNode, + sourceHandle: sourceHandleId || null, + targetHandle: targetHandleId || null, + connectionMode: state.connectionMode, + onError: state.onError, + }); + }, + [source, target] + ), + shallow + ); + + const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]); + const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]); + + if (hidden || !edgePosition) { + return null; + } + + const onEdgeClick = (event: React.MouseEvent): void => { + const { edges, addSelectedEdges, unselectNodesAndEdges, multiSelectionActive } = store.getState(); + const edge = edges.find((e) => e.id === id); + + if (!edge) { + return; + } + + if (isSelectable) { + store.setState({ nodesSelectionActive: false }); + + if (edge.selected && multiSelectionActive) { + unselectNodesAndEdges({ nodes: [], edges: [edge] }); + edgeRef.current?.blur(); + } else { + addSelectedEdges([id]); + } + } + + if (onClick) { + onClick(event, edge); + } + }; + + const onEdgeDoubleClickHandler = getMouseHandler(id, store.getState, onEdgeDoubleClick); + const onEdgeContextMenu = getMouseHandler(id, store.getState, onContextMenu); + const onEdgeMouseEnter = getMouseHandler(id, store.getState, onMouseEnter); + const onEdgeMouseMove = getMouseHandler(id, store.getState, onMouseMove); + const onEdgeMouseLeave = getMouseHandler(id, store.getState, onMouseLeave); + + const handleEdgeUpdater = (event: React.MouseEvent, isSourceHandle: boolean) => { + // avoid triggering edge updater if mouse btn is not left + if (event.button !== 0) { + return; + } + + const { + autoPanOnConnect, + domNode, + edges, + isValidConnection, + connectionMode, + connectionRadius, + lib, + onConnectStart, + onConnectEnd, + cancelConnection, + nodes, + panBy, + updateConnection, + } = store.getState(); + const nodeId = isSourceHandle ? target : source; + const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null; + const handleType = isSourceHandle ? 'target' : 'source'; + + const isTarget = isSourceHandle; + const edge = edges.find((e) => e.id === id)!; + + setUpdating(true); + onEdgeUpdateStart?.(event, edge, handleType); + + const _onEdgeUpdateEnd = (evt: MouseEvent | TouchEvent) => { + setUpdating(false); + onEdgeUpdateEnd?.(evt, edge, handleType); + }; + + const onConnectEdge = (connection: Connection) => onEdgeUpdate?.(edge, connection); + + XYHandle.onPointerDown(event.nativeEvent, { + autoPanOnConnect, + connectionMode, + connectionRadius, + domNode, + handleId, + nodeId, + nodes, + isTarget, + edgeUpdaterType: handleType, + lib, + cancelConnection, + panBy, + isValidConnection, + onConnect: onConnectEdge, + onConnectStart, + onConnectEnd, + onEdgeUpdateEnd: _onEdgeUpdateEnd, + updateConnection, + getTransform: () => store.getState().transform, + }); + }; + + const onEdgeUpdaterSourceMouseDown = (event: React.MouseEvent): void => + handleEdgeUpdater(event, true); + const onEdgeUpdaterTargetMouseDown = (event: React.MouseEvent): void => + handleEdgeUpdater(event, false); + + const onEdgeUpdaterMouseEnter = () => setUpdateHover(true); + const onEdgeUpdaterMouseOut = () => setUpdateHover(false); + + const inactive = !isSelectable && !onClick; + + const onKeyDown = (event: KeyboardEvent) => { + if (elementSelectionKeys.includes(event.key) && isSelectable) { + const { unselectNodesAndEdges, addSelectedEdges, edges } = store.getState(); + const unselect = event.key === 'Escape'; + + if (unselect) { + edgeRef.current?.blur(); + unselectNodesAndEdges({ edges: [edges.find((e) => e.id === id)!] }); + } else { + addSelectedEdges([id]); + } + } + }; + + return ( + + + {!updating && ( + + )} + {isUpdatable && ( + <> + {(isUpdatable === 'source' || isUpdatable === true) && ( + + )} + {(isUpdatable === 'target' || isUpdatable === true) && ( + + )} + + )} + + + ); +} + +EdgeWrapper.displayName = 'EdgeWrapper'; + +export default memo(EdgeWrapper); diff --git a/packages/react/src/components/EdgeWrapper/utils.ts b/packages/react/src/components/EdgeWrapper/utils.ts new file mode 100644 index 00000000..294592c9 --- /dev/null +++ b/packages/react/src/components/EdgeWrapper/utils.ts @@ -0,0 +1,17 @@ +import type { ComponentType } from 'react'; +import type { EdgeProps, EdgeTypes } from '../../types'; +import { + BezierEdgeInternal, + StraightEdgeInternal, + StepEdgeInternal, + SmoothStepEdgeInternal, + SimpleBezierEdgeInternal, +} from '../Edges'; + +export const builtinEdgeTypes: EdgeTypes = { + default: BezierEdgeInternal as ComponentType, + straight: StraightEdgeInternal as ComponentType, + step: StepEdgeInternal as ComponentType, + smoothstep: SmoothStepEdgeInternal as ComponentType, + simplebezier: SimpleBezierEdgeInternal as ComponentType, +}; diff --git a/packages/react/src/components/Edges/BezierEdge.tsx b/packages/react/src/components/Edges/BezierEdge.tsx index 8f446393..2a0b54a2 100644 --- a/packages/react/src/components/Edges/BezierEdge.tsx +++ b/packages/react/src/components/Edges/BezierEdge.tsx @@ -4,56 +4,67 @@ import { Position, getBezierPath } from '@xyflow/system'; import BaseEdge from './BaseEdge'; import type { BezierEdgeProps } from '../../types'; -const BezierEdge = memo( - ({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition = Position.Bottom, - targetPosition = Position.Top, - label, - labelStyle, - labelShowBg, - labelBgStyle, - labelBgPadding, - labelBgBorderRadius, - style, - markerEnd, - markerStart, - pathOptions, - interactionWidth, - }: BezierEdgeProps) => { - const [path, labelX, labelY] = getBezierPath({ +function createBezierEdge(params: { isInternal: boolean }) { + // eslint-disable-next-line react/display-name + return memo( + ({ + id, sourceX, sourceY, - sourcePosition, targetX, targetY, - targetPosition, - curvature: pathOptions?.curvature, - }); + sourcePosition = Position.Bottom, + targetPosition = Position.Top, + label, + labelStyle, + labelShowBg, + labelBgStyle, + labelBgPadding, + labelBgBorderRadius, + style, + markerEnd, + markerStart, + pathOptions, + interactionWidth, + }: BezierEdgeProps) => { + const [path, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + curvature: pathOptions?.curvature, + }); - return ( - - ); - } -); + const _id = params.isInternal ? undefined : id; + + return ( + + ); + } + ); +} + +const BezierEdge = createBezierEdge({ isInternal: false }); +const BezierEdgeInternal = createBezierEdge({ isInternal: true }); BezierEdge.displayName = 'BezierEdge'; +BezierEdgeInternal.displayName = 'BezierEdgeInternal'; -export default BezierEdge; +export { BezierEdge, BezierEdgeInternal }; diff --git a/packages/react/src/components/Edges/SimpleBezierEdge.tsx b/packages/react/src/components/Edges/SimpleBezierEdge.tsx index d9fa50c4..09c7a752 100644 --- a/packages/react/src/components/Edges/SimpleBezierEdge.tsx +++ b/packages/react/src/components/Edges/SimpleBezierEdge.tsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import { Position, getBezierEdgeCenter } from '@xyflow/system'; import BaseEdge from './BaseEdge'; -import type { EdgeProps } from '../../types'; +import type { SimpleBezierEdgeProps } from '../../types'; export interface GetSimpleBezierPathParams { sourceX: number; @@ -71,54 +71,65 @@ export function getSimpleBezierPath({ ]; } -const SimpleBezierEdge = memo( - ({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition = Position.Bottom, - targetPosition = Position.Top, - label, - labelStyle, - labelShowBg, - labelBgStyle, - labelBgPadding, - labelBgBorderRadius, - style, - markerEnd, - markerStart, - interactionWidth, - }: EdgeProps) => { - const [path, labelX, labelY] = getSimpleBezierPath({ +function createSimpleBezierEdge(params: { isInternal: boolean }) { + // eslint-disable-next-line react/display-name + return memo( + ({ + id, sourceX, sourceY, - sourcePosition, targetX, targetY, - targetPosition, - }); + sourcePosition = Position.Bottom, + targetPosition = Position.Top, + label, + labelStyle, + labelShowBg, + labelBgStyle, + labelBgPadding, + labelBgBorderRadius, + style, + markerEnd, + markerStart, + interactionWidth, + }: SimpleBezierEdgeProps) => { + const [path, labelX, labelY] = getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); - return ( - - ); - } -); + const _id = params.isInternal ? undefined : id; + + return ( + + ); + } + ); +} + +const SimpleBezierEdge = createSimpleBezierEdge({ isInternal: false }); +const SimpleBezierEdgeInternal = createSimpleBezierEdge({ isInternal: true }); SimpleBezierEdge.displayName = 'SimpleBezierEdge'; +SimpleBezierEdgeInternal.displayName = 'SimpleBezierEdgeInternal'; -export default SimpleBezierEdge; +export { SimpleBezierEdge, SimpleBezierEdgeInternal }; diff --git a/packages/react/src/components/Edges/SmoothStepEdge.tsx b/packages/react/src/components/Edges/SmoothStepEdge.tsx index be635a4e..8eb10d89 100644 --- a/packages/react/src/components/Edges/SmoothStepEdge.tsx +++ b/packages/react/src/components/Edges/SmoothStepEdge.tsx @@ -4,57 +4,68 @@ import { Position, getSmoothStepPath } from '@xyflow/system'; import BaseEdge from './BaseEdge'; import type { SmoothStepEdgeProps } from '../../types'; -const SmoothStepEdge = memo( - ({ - sourceX, - sourceY, - targetX, - targetY, - label, - labelStyle, - labelShowBg, - labelBgStyle, - labelBgPadding, - labelBgBorderRadius, - style, - sourcePosition = Position.Bottom, - targetPosition = Position.Top, - markerEnd, - markerStart, - pathOptions, - interactionWidth, - }: SmoothStepEdgeProps) => { - const [path, labelX, labelY] = getSmoothStepPath({ +function createSmoothStepEdge(params: { isInternal: boolean }) { + // eslint-disable-next-line react/display-name + return memo( + ({ + id, sourceX, sourceY, - sourcePosition, targetX, targetY, - targetPosition, - borderRadius: pathOptions?.borderRadius, - offset: pathOptions?.offset, - }); + label, + labelStyle, + labelShowBg, + labelBgStyle, + labelBgPadding, + labelBgBorderRadius, + style, + sourcePosition = Position.Bottom, + targetPosition = Position.Top, + markerEnd, + markerStart, + pathOptions, + interactionWidth, + }: SmoothStepEdgeProps) => { + const [path, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: pathOptions?.borderRadius, + offset: pathOptions?.offset, + }); - return ( - - ); - } -); + const _id = params.isInternal ? undefined : id; + + return ( + + ); + } + ); +} + +const SmoothStepEdge = createSmoothStepEdge({ isInternal: false }); +const SmoothStepEdgeInternal = createSmoothStepEdge({ isInternal: true }); SmoothStepEdge.displayName = 'SmoothStepEdge'; +SmoothStepEdgeInternal.displayName = 'SmoothStepEdgeInternal'; -export default SmoothStepEdge; +export { SmoothStepEdge, SmoothStepEdgeInternal }; diff --git a/packages/react/src/components/Edges/StepEdge.tsx b/packages/react/src/components/Edges/StepEdge.tsx index df2646a7..6385e8f0 100644 --- a/packages/react/src/components/Edges/StepEdge.tsx +++ b/packages/react/src/components/Edges/StepEdge.tsx @@ -1,15 +1,30 @@ import { memo, useMemo } from 'react'; -import SmoothStepEdge from './SmoothStepEdge'; -import type { SmoothStepEdgeProps } from '../../types'; +import { SmoothStepEdge } from './SmoothStepEdge'; +import type { StepEdgeProps } from '../../types'; -const StepEdge = memo((props: SmoothStepEdgeProps) => ( - ({ borderRadius: 0, offset: props.pathOptions?.offset }), [props.pathOptions?.offset])} - /> -)); +function createStepEdge(params: { isInternal: boolean }) { + // eslint-disable-next-line react/display-name + return memo(({ id, ...props }: StepEdgeProps) => { + const _id = params.isInternal ? undefined : id; + + return ( + ({ borderRadius: 0, offset: props.pathOptions?.offset }), + [props.pathOptions?.offset] + )} + /> + ); + }); +} + +const StepEdge = createStepEdge({ isInternal: false }); +const StepEdgeInternal = createStepEdge({ isInternal: true }); StepEdge.displayName = 'StepEdge'; +StepEdgeInternal.displayName = 'StepEdgeInternal'; -export default StepEdge; +export { StepEdge, StepEdgeInternal }; diff --git a/packages/react/src/components/Edges/StraightEdge.tsx b/packages/react/src/components/Edges/StraightEdge.tsx index 06f4a161..5a3c824d 100644 --- a/packages/react/src/components/Edges/StraightEdge.tsx +++ b/packages/react/src/components/Edges/StraightEdge.tsx @@ -2,47 +2,58 @@ import { memo } from 'react'; import { getStraightPath } from '@xyflow/system'; import BaseEdge from './BaseEdge'; -import type { EdgeProps } from '../../types'; +import type { StraightEdgeProps } from '../../types'; -const StraightEdge = memo( - ({ - sourceX, - sourceY, - targetX, - targetY, - label, - labelStyle, - labelShowBg, - labelBgStyle, - labelBgPadding, - labelBgBorderRadius, - style, - markerEnd, - markerStart, - interactionWidth, - }: EdgeProps) => { - const [path, labelX, labelY] = getStraightPath({ sourceX, sourceY, targetX, targetY }); +function createStraightEdge(params: { isInternal: boolean }) { + // eslint-disable-next-line react/display-name + return memo( + ({ + id, + sourceX, + sourceY, + targetX, + targetY, + label, + labelStyle, + labelShowBg, + labelBgStyle, + labelBgPadding, + labelBgBorderRadius, + style, + markerEnd, + markerStart, + interactionWidth, + }: StraightEdgeProps) => { + const [path, labelX, labelY] = getStraightPath({ sourceX, sourceY, targetX, targetY }); - return ( - - ); - } -); + const _id = params.isInternal ? undefined : id; + + return ( + + ); + } + ); +} + +const StraightEdge = createStraightEdge({ isInternal: false }); +const StraightEdgeInternal = createStraightEdge({ isInternal: true }); StraightEdge.displayName = 'StraightEdge'; +StraightEdgeInternal.displayName = 'StraightEdgeInternal'; -export default StraightEdge; +export { StraightEdge, StraightEdgeInternal }; diff --git a/packages/react/src/components/Edges/index.ts b/packages/react/src/components/Edges/index.ts index e4d30109..21af9667 100644 --- a/packages/react/src/components/Edges/index.ts +++ b/packages/react/src/components/Edges/index.ts @@ -1,5 +1,9 @@ -export { default as SimpleBezierEdge } from './SimpleBezierEdge'; -export { default as SmoothStepEdge } from './SmoothStepEdge'; -export { default as StepEdge } from './StepEdge'; -export { default as StraightEdge } from './StraightEdge'; -export { default as BezierEdge } from './BezierEdge'; +// We distinguish between internal and exported edges +// The internal edges are used directly like custom edges and always get an id, source and target props +// If you import an edge from the library, the id is optional and source and target are not used at all + +export { SimpleBezierEdge, SimpleBezierEdgeInternal } from './SimpleBezierEdge'; +export { SmoothStepEdge, SmoothStepEdgeInternal } from './SmoothStepEdge'; +export { StepEdge, StepEdgeInternal } from './StepEdge'; +export { StraightEdge, StraightEdgeInternal } from './StraightEdge'; +export { BezierEdge, BezierEdgeInternal } from './BezierEdge'; diff --git a/packages/react/src/components/Edges/wrapEdge.tsx b/packages/react/src/components/Edges/wrapEdge.tsx deleted file mode 100644 index 7d3f92e6..00000000 --- a/packages/react/src/components/Edges/wrapEdge.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { memo, useState, useMemo, useRef, type ComponentType, type KeyboardEvent, useCallback } from 'react'; -import cc from 'classcat'; -import { shallow } from 'zustand/shallow'; -import { getMarkerId, elementSelectionKeys, XYHandle, type Connection, getEdgePosition } from '@xyflow/system'; - -import { useStoreApi, useStore } from '../../hooks/useStore'; -import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions'; -import { EdgeAnchor } from './EdgeAnchor'; -import { getMouseHandler } from './utils'; -import type { EdgeProps, WrapEdgeProps } from '../../types'; - -export default (EdgeComponent: ComponentType) => { - const EdgeWrapper = ({ - id, - className, - type, - data, - onClick, - onEdgeDoubleClick, - selected, - animated, - label, - labelStyle, - labelShowBg, - labelBgStyle, - labelBgPadding, - labelBgBorderRadius, - style, - zIndex, - source, - target, - isSelectable, - hidden, - sourceHandleId, - targetHandleId, - onContextMenu, - onMouseEnter, - onMouseMove, - onMouseLeave, - edgeUpdaterRadius, - onEdgeUpdate, - onEdgeUpdateStart, - onEdgeUpdateEnd, - markerEnd, - markerStart, - rfId, - ariaLabel, - isFocusable, - isUpdatable, - pathOptions, - interactionWidth, - }: WrapEdgeProps): JSX.Element | null => { - const edgeRef = useRef(null); - const [updateHover, setUpdateHover] = useState(false); - const [updating, setUpdating] = useState(false); - const store = useStoreApi(); - const edgePosition = useStore( - useCallback( - (state) => { - const sourceNode = state.nodeLookup.get(source); - const targetNode = state.nodeLookup.get(target); - - if (!sourceNode || !targetNode) { - return null; - } - - return getEdgePosition({ - id, - sourceNode, - targetNode, - sourceHandle: sourceHandleId || null, - targetHandle: targetHandleId || null, - connectionMode: state.connectionMode, - onError: state.onError, - }); - }, - [source, target] - ), - shallow - ); - - const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]); - const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]); - - if (hidden || !edgePosition) { - return null; - } - - const onEdgeClick = (event: React.MouseEvent): void => { - const { edges, addSelectedEdges, unselectNodesAndEdges, multiSelectionActive } = store.getState(); - const edge = edges.find((e) => e.id === id); - - if (!edge) { - return; - } - - if (isSelectable) { - store.setState({ nodesSelectionActive: false }); - - if (edge.selected && multiSelectionActive) { - unselectNodesAndEdges({ nodes: [], edges: [edge] }); - edgeRef.current?.blur(); - } else { - addSelectedEdges([id]); - } - } - - if (onClick) { - onClick(event, edge); - } - }; - - const onEdgeDoubleClickHandler = getMouseHandler(id, store.getState, onEdgeDoubleClick); - const onEdgeContextMenu = getMouseHandler(id, store.getState, onContextMenu); - const onEdgeMouseEnter = getMouseHandler(id, store.getState, onMouseEnter); - const onEdgeMouseMove = getMouseHandler(id, store.getState, onMouseMove); - const onEdgeMouseLeave = getMouseHandler(id, store.getState, onMouseLeave); - - const handleEdgeUpdater = (event: React.MouseEvent, isSourceHandle: boolean) => { - // avoid triggering edge updater if mouse btn is not left - if (event.button !== 0) { - return; - } - - const { - autoPanOnConnect, - domNode, - edges, - isValidConnection, - connectionMode, - connectionRadius, - lib, - onConnectStart, - onConnectEnd, - cancelConnection, - nodes, - panBy, - updateConnection, - } = store.getState(); - const nodeId = isSourceHandle ? target : source; - const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null; - const handleType = isSourceHandle ? 'target' : 'source'; - - const isTarget = isSourceHandle; - const edge = edges.find((e) => e.id === id)!; - - setUpdating(true); - onEdgeUpdateStart?.(event, edge, handleType); - - const _onEdgeUpdateEnd = (evt: MouseEvent | TouchEvent) => { - setUpdating(false); - onEdgeUpdateEnd?.(evt, edge, handleType); - }; - - const onConnectEdge = (connection: Connection) => onEdgeUpdate?.(edge, connection); - - XYHandle.onPointerDown(event.nativeEvent, { - autoPanOnConnect, - connectionMode, - connectionRadius, - domNode, - handleId, - nodeId, - nodes, - isTarget, - edgeUpdaterType: handleType, - lib, - cancelConnection, - panBy, - isValidConnection, - onConnect: onConnectEdge, - onConnectStart, - onConnectEnd, - onEdgeUpdateEnd: _onEdgeUpdateEnd, - updateConnection, - getTransform: () => store.getState().transform, - }); - }; - - const onEdgeUpdaterSourceMouseDown = (event: React.MouseEvent): void => - handleEdgeUpdater(event, true); - const onEdgeUpdaterTargetMouseDown = (event: React.MouseEvent): void => - handleEdgeUpdater(event, false); - - const onEdgeUpdaterMouseEnter = () => setUpdateHover(true); - const onEdgeUpdaterMouseOut = () => setUpdateHover(false); - - const inactive = !isSelectable && !onClick; - - const onKeyDown = (event: KeyboardEvent) => { - if (elementSelectionKeys.includes(event.key) && isSelectable) { - const { unselectNodesAndEdges, addSelectedEdges, edges } = store.getState(); - const unselect = event.key === 'Escape'; - - if (unselect) { - edgeRef.current?.blur(); - unselectNodesAndEdges({ edges: [edges.find((e) => e.id === id)!] }); - } else { - addSelectedEdges([id]); - } - } - }; - - return ( - - - {!updating && ( - - )} - {isUpdatable && ( - <> - {(isUpdatable === 'source' || isUpdatable === true) && ( - - )} - {(isUpdatable === 'target' || isUpdatable === true) && ( - - )} - - )} - - - ); - }; - - EdgeWrapper.displayName = 'EdgeWrapper'; - - return memo(EdgeWrapper); -}; diff --git a/packages/react/src/components/Handle/index.tsx b/packages/react/src/components/Handle/index.tsx index 29231b71..a2c0101f 100644 --- a/packages/react/src/components/Handle/index.tsx +++ b/packages/react/src/components/Handle/index.tsx @@ -171,7 +171,7 @@ const Handle = forwardRef( lib, }); - if (isValid) { + if (isValid && connection) { onConnectExtended(connection); } diff --git a/packages/react/src/components/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx new file mode 100644 index 00000000..de7ce1d0 --- /dev/null +++ b/packages/react/src/components/NodeWrapper/index.tsx @@ -0,0 +1,256 @@ +import { useEffect, useRef, memo, type MouseEvent, type KeyboardEvent } from 'react'; +import cc from 'classcat'; +import { + clampPosition, + elementSelectionKeys, + errorMessages, + getPositionWithOrigin, + internalsSymbol, + isInputDOMNode, +} from '@xyflow/system'; + +import { useStore, useStoreApi } from '../../hooks/useStore'; +import { Provider } from '../../contexts/NodeIdContext'; +import { ARIA_NODE_DESC_KEY } from '../A11yDescriptions'; +import useDrag from '../../hooks/useDrag'; +import useUpdateNodePositions from '../../hooks/useUpdateNodePositions'; +import { handleNodeClick } from '../Nodes/utils'; +import type { NodeWrapperProps } from '../../types'; +import { arrowKeyDiffs, builtinNodeTypes } from './utils'; + +const NodeWrapper = ({ + id, + onClick, + onMouseEnter, + onMouseMove, + onMouseLeave, + onContextMenu, + onDoubleClick, + nodesDraggable, + elementsSelectable, + nodesConnectable, + nodesFocusable, + resizeObserver, + noDragClassName, + noPanClassName, + disableKeyboardA11y, + rfId, + nodeTypes, + nodeExtent, + nodeOrigin, + onError, +}: NodeWrapperProps) => { + const node = useStore((s) => s.nodeLookup.get(id)!); + + let nodeType = node.type || 'default'; + let NodeComponent = nodeTypes?.[nodeType] || builtinNodeTypes[nodeType]; + + if (NodeComponent === undefined) { + onError?.('003', errorMessages['error003'](nodeType)); + nodeType = 'default'; + NodeComponent = builtinNodeTypes.default; + } + + const isDraggable = !!(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined')); + const isSelectable = !!(node.selectable || (elementsSelectable && typeof node.selectable === 'undefined')); + const isConnectable = !!(node.connectable || (nodesConnectable && typeof node.connectable === 'undefined')); + const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined')); + + const store = useStoreApi(); + const nodeRef = useRef(null); + const prevSourcePosition = useRef(node.sourcePosition); + const prevTargetPosition = useRef(node.targetPosition); + const prevType = useRef(nodeType); + + const updatePositions = useUpdateNodePositions(); + + useEffect(() => { + if (nodeRef.current && !node.hidden) { + const currNode = nodeRef.current; + resizeObserver?.observe(currNode); + + return () => resizeObserver?.unobserve(currNode); + } + }, [node.hidden]); + + useEffect(() => { + // when the user programmatically changes the source or handle position, we re-initialize the node + const typeChanged = prevType.current !== nodeType; + const sourcePosChanged = prevSourcePosition.current !== node.sourcePosition; + const targetPosChanged = prevTargetPosition.current !== node.targetPosition; + + if (nodeRef.current && (typeChanged || sourcePosChanged || targetPosChanged)) { + if (typeChanged) { + prevType.current = nodeType; + } + if (sourcePosChanged) { + prevSourcePosition.current = node.sourcePosition; + } + if (targetPosChanged) { + prevTargetPosition.current = node.targetPosition; + } + store.getState().updateNodeDimensions(new Map([[id, { id, nodeElement: nodeRef.current, forceUpdate: true }]])); + } + }, [id, nodeType, node.sourcePosition, node.targetPosition]); + + const dragging = useDrag({ + nodeRef, + disabled: node.hidden || !isDraggable, + noDragClassName, + handleSelector: node.dragHandle, + nodeId: id, + isSelectable, + }); + + if (node.hidden) { + return null; + } + + const width = node.width ?? undefined; + const height = node.height ?? undefined; + const computedWidth = node.computed?.width; + const computedHeight = node.computed?.height; + const positionAbsolute = nodeExtent + ? clampPosition(node.computed?.positionAbsolute, nodeExtent) + : node.computed?.positionAbsolute || { x: 0, y: 0 }; + const positionAbsoluteOrigin = getPositionWithOrigin({ + x: positionAbsolute.x, + y: positionAbsolute.y, + width: computedWidth ?? width ?? 0, + height: computedHeight ?? height ?? 0, + origin: node.origin || nodeOrigin, + }); + const initialized = (!!computedWidth && !!computedHeight) || (!!width && !!height); + const zIndex = node[internalsSymbol]?.z ?? 0; + const isParent = !!node[internalsSymbol]?.isParent; + const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave; + + const onMouseEnterHandler = + onMouseEnter === undefined ? undefined : (event: MouseEvent) => onMouseEnter(event, { ...node }); + const onMouseMoveHandler = + onMouseMove === undefined ? undefined : (event: MouseEvent) => onMouseMove(event, { ...node }); + const onMouseLeaveHandler = + onMouseLeave === undefined ? undefined : (event: MouseEvent) => onMouseLeave(event, { ...node }); + const onContextMenuHandler = + onContextMenu === undefined ? undefined : (event: MouseEvent) => onContextMenu(event, { ...node }); + const onDoubleClickHandler = + onDoubleClick === undefined ? undefined : (event: MouseEvent) => onDoubleClick(event, { ...node }); + + const onSelectNodeHandler = (event: MouseEvent) => { + const { selectNodesOnDrag, nodeDragThreshold } = store.getState(); + + if (isSelectable && (!selectNodesOnDrag || !isDraggable || nodeDragThreshold > 0)) { + // this handler gets called by XYDrag on drag start when selectNodesOnDrag=true + // here we only need to call it when selectNodesOnDrag=false + handleNodeClick({ + id, + store, + nodeRef, + }); + } + + if (onClick) { + onClick(event, { ...node }); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (isInputDOMNode(event.nativeEvent)) { + return; + } + + if (elementSelectionKeys.includes(event.key) && isSelectable) { + const unselect = event.key === 'Escape'; + + handleNodeClick({ + id, + store, + unselect, + nodeRef, + }); + } else if ( + !disableKeyboardA11y && + isDraggable && + node.selected && + Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key) + ) { + store.setState({ + ariaLiveMessage: `Moved selected node ${event.key + .replace('Arrow', '') + .toLowerCase()}. New position, x: ${~~positionAbsolute.x}, y: ${~~positionAbsolute.y}`, + }); + + updatePositions({ + x: arrowKeyDiffs[event.key].x, + y: arrowKeyDiffs[event.key].y, + isShiftPressed: event.shiftKey, + }); + } + }; + + return ( +
+ + + +
+ ); +}; + +NodeWrapper.displayName = 'NodeWrapper'; + +export default memo(NodeWrapper); diff --git a/packages/react/src/components/NodeWrapper/utils.tsx b/packages/react/src/components/NodeWrapper/utils.tsx new file mode 100644 index 00000000..6ae5f994 --- /dev/null +++ b/packages/react/src/components/NodeWrapper/utils.tsx @@ -0,0 +1,22 @@ +import type { ComponentType } from 'react'; +import type { NodeProps, XYPosition } from '@xyflow/system'; + +import InputNode from '../Nodes/InputNode'; +import DefaultNode from '../Nodes/DefaultNode'; +import GroupNode from '../Nodes/GroupNode'; +import OutputNode from '../Nodes/OutputNode'; +import type { NodeTypes } from '../../types'; + +export const arrowKeyDiffs: Record = { + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 }, + ArrowLeft: { x: -1, y: 0 }, + ArrowRight: { x: 1, y: 0 }, +}; + +export const builtinNodeTypes: NodeTypes = { + input: InputNode as ComponentType, + default: DefaultNode as ComponentType, + output: OutputNode as ComponentType, + group: GroupNode as ComponentType, +}; diff --git a/packages/react/src/components/Nodes/utils.ts b/packages/react/src/components/Nodes/utils.ts index b9a82189..26b1f246 100644 --- a/packages/react/src/components/Nodes/utils.ts +++ b/packages/react/src/components/Nodes/utils.ts @@ -1,22 +1,9 @@ -import type { MouseEvent, RefObject } from 'react'; +import type { RefObject } from 'react'; import type { StoreApi } from 'zustand'; -import type { Node, ReactFlowState } from '../../types'; +import type { ReactFlowState } from '../../types'; import { errorMessages } from '@xyflow/system'; -export function getMouseHandler( - id: string, - getState: StoreApi['getState'], - handler?: (event: MouseEvent, node: Node) => void -) { - return handler === undefined - ? handler - : (event: MouseEvent) => { - const node = getState().nodeLookup.get(id)!; - handler(event, { ...node }); - }; -} - // this handler is called by // 1. the click handler when node is not draggable or selectNodesOnDrag = false // or diff --git a/packages/react/src/components/Nodes/wrapNode.tsx b/packages/react/src/components/Nodes/wrapNode.tsx deleted file mode 100644 index e50ffcf7..00000000 --- a/packages/react/src/components/Nodes/wrapNode.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { useEffect, useRef, memo, type ComponentType, type MouseEvent, type KeyboardEvent } from 'react'; -import cc from 'classcat'; -import { elementSelectionKeys, isInputDOMNode, type NodeProps, type XYPosition } from '@xyflow/system'; - -import { useStoreApi } from '../../hooks/useStore'; -import { Provider } from '../../contexts/NodeIdContext'; -import { ARIA_NODE_DESC_KEY } from '../A11yDescriptions'; -import useDrag from '../../hooks/useDrag'; -import useUpdateNodePositions from '../../hooks/useUpdateNodePositions'; -import { getMouseHandler, handleNodeClick } from './utils'; -import type { WrapNodeProps } from '../../types'; - -export const arrowKeyDiffs: Record = { - ArrowUp: { x: 0, y: -1 }, - ArrowDown: { x: 0, y: 1 }, - ArrowLeft: { x: -1, y: 0 }, - ArrowRight: { x: 1, y: 0 }, -}; - -export default (NodeComponent: ComponentType) => { - const NodeWrapper = ({ - id, - type, - data, - xPos, - yPos, - xPosOrigin, - yPosOrigin, - selected, - onClick, - onMouseEnter, - onMouseMove, - onMouseLeave, - onContextMenu, - onDoubleClick, - style, - className, - isDraggable, - isSelectable, - isConnectable, - isFocusable, - sourcePosition, - targetPosition, - hidden, - resizeObserver, - dragHandle, - zIndex, - isParent, - noDragClassName, - noPanClassName, - initialized, - disableKeyboardA11y, - ariaLabel, - rfId, - sizeWidth, - sizeHeight, - }: WrapNodeProps) => { - const store = useStoreApi(); - const nodeRef = useRef(null); - const prevSourcePosition = useRef(sourcePosition); - const prevTargetPosition = useRef(targetPosition); - const prevType = useRef(type); - const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave; - const updatePositions = useUpdateNodePositions(); - - const onMouseEnterHandler = getMouseHandler(id, store.getState, onMouseEnter); - const onMouseMoveHandler = getMouseHandler(id, store.getState, onMouseMove); - const onMouseLeaveHandler = getMouseHandler(id, store.getState, onMouseLeave); - const onContextMenuHandler = getMouseHandler(id, store.getState, onContextMenu); - const onDoubleClickHandler = getMouseHandler(id, store.getState, onDoubleClick); - const onSelectNodeHandler = (event: MouseEvent) => { - const { selectNodesOnDrag, nodeDragThreshold } = store.getState(); - - if (isSelectable && (!selectNodesOnDrag || !isDraggable || nodeDragThreshold > 0)) { - // this handler gets called by XYDrag on drag start when selectNodesOnDrag=true - // here we only need to call it when selectNodesOnDrag=false - handleNodeClick({ - id, - store, - nodeRef, - }); - } - - if (onClick) { - const node = store.getState().nodes.find((n) => n.id === id)!; - onClick(event, { ...node }); - } - }; - - const onKeyDown = (event: KeyboardEvent) => { - if (isInputDOMNode(event.nativeEvent)) { - return; - } - - if (elementSelectionKeys.includes(event.key) && isSelectable) { - const unselect = event.key === 'Escape'; - - handleNodeClick({ - id, - store, - unselect, - nodeRef, - }); - } else if ( - !disableKeyboardA11y && - isDraggable && - selected && - Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key) - ) { - store.setState({ - ariaLiveMessage: `Moved selected node ${event.key - .replace('Arrow', '') - .toLowerCase()}. New position, x: ${~~xPos}, y: ${~~yPos}`, - }); - - updatePositions({ - x: arrowKeyDiffs[event.key].x, - y: arrowKeyDiffs[event.key].y, - isShiftPressed: event.shiftKey, - }); - } - }; - - useEffect(() => { - if (nodeRef.current && !hidden) { - const currNode = nodeRef.current; - resizeObserver?.observe(currNode); - - return () => resizeObserver?.unobserve(currNode); - } - }, [hidden]); - - useEffect(() => { - // when the user programmatically changes the source or handle position, we re-initialize the node - const typeChanged = prevType.current !== type; - const sourcePosChanged = prevSourcePosition.current !== sourcePosition; - const targetPosChanged = prevTargetPosition.current !== targetPosition; - - if (nodeRef.current && (typeChanged || sourcePosChanged || targetPosChanged)) { - if (typeChanged) { - prevType.current = type; - } - if (sourcePosChanged) { - prevSourcePosition.current = sourcePosition; - } - if (targetPosChanged) { - prevTargetPosition.current = targetPosition; - } - store.getState().updateNodeDimensions(new Map([[id, { id, nodeElement: nodeRef.current, forceUpdate: true }]])); - } - }, [id, type, sourcePosition, targetPosition]); - - const dragging = useDrag({ - nodeRef, - disabled: hidden || !isDraggable, - noDragClassName, - handleSelector: dragHandle, - nodeId: id, - isSelectable, - }); - - if (hidden) { - return null; - } - - return ( -
- - - -
- ); - }; - - NodeWrapper.displayName = 'NodeWrapper'; - - return memo(NodeWrapper); -}; diff --git a/packages/react/src/components/NodesSelection/index.tsx b/packages/react/src/components/NodesSelection/index.tsx index ec88fccd..e79ed782 100644 --- a/packages/react/src/components/NodesSelection/index.tsx +++ b/packages/react/src/components/NodesSelection/index.tsx @@ -10,9 +10,9 @@ import { getNodesBounds } from '@xyflow/system'; import { useStore, useStoreApi } from '../../hooks/useStore'; import useDrag from '../../hooks/useDrag'; -import { arrowKeyDiffs } from '../Nodes/wrapNode'; import useUpdateNodePositions from '../../hooks/useUpdateNodePositions'; import type { Node, ReactFlowState } from '../../types'; +import { arrowKeyDiffs } from '../NodeWrapper/utils'; export type NodesSelectionProps = { onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void; diff --git a/packages/react/src/components/StoreUpdater/index.tsx b/packages/react/src/components/StoreUpdater/index.tsx index 65f1bb3c..f49dec43 100644 --- a/packages/react/src/components/StoreUpdater/index.tsx +++ b/packages/react/src/components/StoreUpdater/index.tsx @@ -3,67 +3,72 @@ * We distinguish between values we can update directly with `useDirectStoreUpdater` (like `snapGrid`) * and values that have a dedicated setter function in the store (like `setNodes`). */ -import { useEffect } from 'react'; -import { StoreApi } from 'zustand'; +import { useEffect, useRef } from 'react'; import { shallow } from 'zustand/shallow'; -import { devWarn, type CoordinateExtent } from '@xyflow/system'; +import { type CoordinateExtent } from '@xyflow/system'; import { useStore, useStoreApi } from '../../hooks/useStore'; -import type { Node, Edge, ReactFlowState, ReactFlowProps, ReactFlowStore } from '../../types'; +import type { Node, Edge, ReactFlowState, ReactFlowProps, FitViewOptions } from '../../types'; -type StoreUpdaterProps = Pick< - ReactFlowProps, - | 'nodes' - | 'edges' - | 'defaultNodes' - | 'defaultEdges' - | 'onConnect' - | 'onConnectStart' - | 'onConnectEnd' - | 'onClickConnectStart' - | 'onClickConnectEnd' - | 'nodesDraggable' - | 'nodesConnectable' - | 'nodesFocusable' - | 'edgesFocusable' - | 'edgesUpdatable' - | 'minZoom' - | 'maxZoom' - | 'nodeExtent' - | 'onNodesChange' - | 'onEdgesChange' - | 'elementsSelectable' - | 'connectionMode' - | 'snapToGrid' - | 'snapGrid' - | 'translateExtent' - | 'connectOnClick' - | 'defaultEdgeOptions' - | 'fitView' - | 'fitViewOptions' - | 'onNodesDelete' - | 'onEdgesDelete' - | 'onDelete' - | 'onNodeDragStart' - | 'onNodeDrag' - | 'onNodeDragStop' - | 'onSelectionDragStart' - | 'onSelectionDrag' - | 'onSelectionDragStop' - | 'onMove' - | 'onMoveStart' - | 'onMoveEnd' - | 'noPanClassName' - | 'nodeOrigin' - | 'elevateNodesOnSelect' - | 'autoPanOnConnect' - | 'autoPanOnNodeDrag' - | 'onError' - | 'connectionRadius' - | 'isValidConnection' - | 'selectNodesOnDrag' - | 'nodeDragThreshold' -> & { rfId: string }; +// these fields exist in the global store and we need to keep them up to date +const reactFlowFieldsToTrack = [ + 'nodes', + 'edges', + 'defaultNodes', + 'defaultEdges', + 'onConnect', + 'onConnectStart', + 'onConnectEnd', + 'onClickConnectStart', + 'onClickConnectEnd', + 'nodesDraggable', + 'nodesConnectable', + 'nodesFocusable', + 'edgesFocusable', + 'edgesUpdatable', + 'elevateNodesOnSelect', + 'minZoom', + 'maxZoom', + 'nodeExtent', + 'onNodesChange', + 'onEdgesChange', + 'elementsSelectable', + 'connectionMode', + 'snapGrid', + 'snapToGrid', + 'translateExtent', + 'connectOnClick', + 'defaultEdgeOptions', + 'fitView', + 'fitViewOptions', + 'onNodesDelete', + 'onEdgesDelete', + 'onDelete', + 'onNodeDrag', + 'onNodeDragStart', + 'onNodeDragStop', + 'onSelectionDrag', + 'onSelectionDragStart', + 'onSelectionDragStop', + 'onMoveStart', + 'onMove', + 'onMoveEnd', + 'noPanClassName', + 'nodeOrigin', + 'autoPanOnConnect', + 'autoPanOnNodeDrag', + 'onError', + 'connectionRadius', + 'isValidConnection', + 'selectNodesOnDrag', + 'nodeDragThreshold', +] as const; + +type ReactFlowFieldsToTrack = (typeof reactFlowFieldsToTrack)[number]; +type StoreUpdaterProps = Pick & { rfId: string }; + +// rfId doesn't exist in ReactFlowProps, but it's one of the fields we want to update +const fieldsToTrack = [...reactFlowFieldsToTrack, 'rfId'] as const; const selector = (s: ReactFlowState) => ({ setNodes: s.setNodes, @@ -76,80 +81,7 @@ const selector = (s: ReactFlowState) => ({ reset: s.reset, }); -function useStoreUpdater(value: T | undefined, setStoreAction: (param: T) => void) { - useEffect(() => { - if (typeof value !== 'undefined') { - setStoreAction(value); - } - }, [value]); -} - -// updates with values in store that don't have a dedicated setter function -function useDirectStoreUpdater( - key: keyof ReactFlowStore, - value: unknown, - setState: StoreApi['setState'] -) { - useEffect(() => { - if (typeof value !== 'undefined') { - setState({ [key]: value }); - } - }, [value]); -} - -const StoreUpdater = ({ - nodes, - edges, - defaultNodes, - defaultEdges, - onConnect, - onConnectStart, - onConnectEnd, - onClickConnectStart, - onClickConnectEnd, - nodesDraggable, - nodesConnectable, - nodesFocusable, - edgesFocusable, - edgesUpdatable, - elevateNodesOnSelect, - minZoom, - maxZoom, - nodeExtent, - onNodesChange, - onEdgesChange, - elementsSelectable, - connectionMode, - snapGrid, - snapToGrid, - translateExtent, - connectOnClick, - defaultEdgeOptions, - fitView, - fitViewOptions, - onNodesDelete, - onEdgesDelete, - onDelete, - onNodeDrag, - onNodeDragStart, - onNodeDragStop, - onSelectionDrag, - onSelectionDragStart, - onSelectionDragStop, - onMoveStart, - onMove, - onMoveEnd, - noPanClassName, - nodeOrigin, - rfId, - autoPanOnConnect, - autoPanOnNodeDrag, - onError, - connectionRadius, - isValidConnection, - selectNodesOnDrag, - nodeDragThreshold, -}: StoreUpdaterProps) => { +const StoreUpdater = (props: StoreUpdaterProps) => { const { setNodes, setEdges, @@ -163,64 +95,44 @@ const StoreUpdater = ({ const store = useStoreApi(); useEffect(() => { - const edgesWithDefaults = defaultEdges?.map((e) => ({ ...e, ...defaultEdgeOptions })); - setDefaultNodesAndEdges(defaultNodes, edgesWithDefaults); + const edgesWithDefaults = props.defaultEdges?.map((e) => ({ ...e, ...props.defaultEdgeOptions })); + setDefaultNodesAndEdges(props.defaultNodes, edgesWithDefaults); return () => { reset(); }; }, []); - useDirectStoreUpdater('defaultEdgeOptions', defaultEdgeOptions, store.setState); - useDirectStoreUpdater('connectionMode', connectionMode, store.setState); - useDirectStoreUpdater('onConnect', onConnect, store.setState); - useDirectStoreUpdater('onConnectStart', onConnectStart, store.setState); - useDirectStoreUpdater('onConnectEnd', onConnectEnd, store.setState); - useDirectStoreUpdater('onClickConnectStart', onClickConnectStart, store.setState); - useDirectStoreUpdater('onClickConnectEnd', onClickConnectEnd, store.setState); - useDirectStoreUpdater('nodesDraggable', nodesDraggable, store.setState); - useDirectStoreUpdater('nodesConnectable', nodesConnectable, store.setState); - useDirectStoreUpdater('nodesFocusable', nodesFocusable, store.setState); - useDirectStoreUpdater('edgesFocusable', edgesFocusable, store.setState); - useDirectStoreUpdater('edgesUpdatable', edgesUpdatable, store.setState); - useDirectStoreUpdater('elementsSelectable', elementsSelectable, store.setState); - useDirectStoreUpdater('elevateNodesOnSelect', elevateNodesOnSelect, store.setState); - useDirectStoreUpdater('snapToGrid', snapToGrid, store.setState); - useDirectStoreUpdater('snapGrid', snapGrid, store.setState); - useDirectStoreUpdater('onNodesChange', onNodesChange, store.setState); - useDirectStoreUpdater('onEdgesChange', onEdgesChange, store.setState); - useDirectStoreUpdater('connectOnClick', connectOnClick, store.setState); - useDirectStoreUpdater('fitViewOnInit', fitView, store.setState); - useDirectStoreUpdater('fitViewOnInitOptions', fitViewOptions, store.setState); - useDirectStoreUpdater('onNodesDelete', onNodesDelete, store.setState); - useDirectStoreUpdater('onEdgesDelete', onEdgesDelete, store.setState); - useDirectStoreUpdater('onDelete', onDelete, store.setState); - useDirectStoreUpdater('onNodeDrag', onNodeDrag, store.setState); - useDirectStoreUpdater('onNodeDragStart', onNodeDragStart, store.setState); - useDirectStoreUpdater('onNodeDragStop', onNodeDragStop, store.setState); - useDirectStoreUpdater('onSelectionDrag', onSelectionDrag, store.setState); - useDirectStoreUpdater('onSelectionDragStart', onSelectionDragStart, store.setState); - useDirectStoreUpdater('onSelectionDragStop', onSelectionDragStop, store.setState); - useDirectStoreUpdater('onMove', onMove, store.setState); - useDirectStoreUpdater('onMoveStart', onMoveStart, store.setState); - useDirectStoreUpdater('onMoveEnd', onMoveEnd, store.setState); - useDirectStoreUpdater('noPanClassName', noPanClassName, store.setState); - useDirectStoreUpdater('nodeOrigin', nodeOrigin, store.setState); - useDirectStoreUpdater('rfId', rfId, store.setState); - useDirectStoreUpdater('autoPanOnConnect', autoPanOnConnect, store.setState); - useDirectStoreUpdater('autoPanOnNodeDrag', autoPanOnNodeDrag, store.setState); - useDirectStoreUpdater('onError', onError || devWarn, store.setState); - useDirectStoreUpdater('connectionRadius', connectionRadius, store.setState); - useDirectStoreUpdater('isValidConnection', isValidConnection, store.setState); - useDirectStoreUpdater('selectNodesOnDrag', selectNodesOnDrag, store.setState); - useDirectStoreUpdater('nodeDragThreshold', nodeDragThreshold, store.setState); + const previousFields = useRef>({}); - useStoreUpdater(nodes, setNodes); - useStoreUpdater(edges, setEdges); - useStoreUpdater(minZoom, setMinZoom); - useStoreUpdater(maxZoom, setMaxZoom); - useStoreUpdater(translateExtent, setTranslateExtent); - useStoreUpdater(nodeExtent, setNodeExtent); + useEffect( + () => { + for (const fieldName of fieldsToTrack) { + const fieldValue = props[fieldName]; + const previousFieldValue = previousFields.current[fieldName]; + + if (fieldValue === previousFieldValue) continue; + if (typeof props[fieldName] === 'undefined') continue; + + // Custom handling with dedicated setters for some fields + if (fieldName === 'nodes') setNodes(fieldValue as Node[]); + else if (fieldName === 'edges') setEdges(fieldValue as Edge[]); + else if (fieldName === 'minZoom') setMinZoom(fieldValue as number); + else if (fieldName === 'maxZoom') setMaxZoom(fieldValue as number); + else if (fieldName === 'translateExtent') setTranslateExtent(fieldValue as CoordinateExtent); + else if (fieldName === 'nodeExtent') setNodeExtent(fieldValue as CoordinateExtent); + // Renamed fields + else if (fieldName === 'fitView') store.setState({ fitViewOnInit: fieldValue as boolean }); + else if (fieldName === 'fitViewOptions') store.setState({ fitViewOnInitOptions: fieldValue as FitViewOptions }); + // General case + else store.setState({ [fieldName]: fieldValue }); + } + + previousFields.current = props; + }, + // Only re-run the effect if one of the fields we track changes + fieldsToTrack.map((fieldName) => props[fieldName]) + ); return null; }; diff --git a/packages/react/src/container/EdgeRenderer/index.tsx b/packages/react/src/container/EdgeRenderer/index.tsx index 701248fc..a5e591f1 100644 --- a/packages/react/src/container/EdgeRenderer/index.tsx +++ b/packages/react/src/container/EdgeRenderer/index.tsx @@ -1,13 +1,13 @@ import { memo, ReactNode } from 'react'; import { shallow } from 'zustand/shallow'; import cc from 'classcat'; -import { errorMessages } from '@xyflow/system'; import { useStore } from '../../hooks/useStore'; import useVisibleEdges from '../../hooks/useVisibleEdges'; import MarkerDefinitions from './MarkerDefinitions'; import { GraphViewProps } from '../GraphView'; -import type { EdgeTypesWrapped, ReactFlowState } from '../../types'; +import type { ReactFlowState } from '../../types'; +import EdgeWrapper from '../../components/EdgeWrapper'; type EdgeRendererProps = Pick< GraphViewProps, @@ -27,8 +27,8 @@ type EdgeRendererProps = Pick< | 'elevateEdgesOnSelect' | 'rfId' | 'disableKeyboardA11y' + | 'edgeTypes' > & { - edgeTypes: EdgeTypesWrapped; elevateEdgesOnSelect: boolean; children: ReactNode; }; @@ -72,14 +72,6 @@ const EdgeRenderer = ({ {edges.map((edge) => { - let edgeType = edge.type || 'default'; - - if (!edgeTypes[edgeType]) { - onError?.('011', errorMessages['error011'](edgeType)); - edgeType = 'default'; - } - - const EdgeComponent = edgeTypes[edgeType]; const isFocusable = !!(edge.focusable || (edgesFocusable && typeof edge.focusable === 'undefined')); const isUpdatable = typeof onEdgeUpdate !== 'undefined' && @@ -87,15 +79,16 @@ const EdgeRenderer = ({ const isSelectable = !!(edge.selectable || (elementsSelectable && typeof edge.selectable === 'undefined')); return ( -
); diff --git a/packages/react/src/container/EdgeRenderer/utils.ts b/packages/react/src/container/EdgeRenderer/utils.ts deleted file mode 100644 index 0e8f39d3..00000000 --- a/packages/react/src/container/EdgeRenderer/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ComponentType } from 'react'; - -import { BezierEdge, SmoothStepEdge, StepEdge, StraightEdge, SimpleBezierEdge } from '../../components/Edges'; -import wrapEdge from '../../components/Edges/wrapEdge'; -import type { EdgeProps, EdgeTypes, EdgeTypesWrapped } from '../../types'; - -export type CreateEdgeTypes = (edgeTypes: EdgeTypes) => EdgeTypesWrapped; - -export function createEdgeTypes(edgeTypes: EdgeTypes): EdgeTypesWrapped { - const standardTypes: EdgeTypesWrapped = { - default: wrapEdge((edgeTypes.default || BezierEdge) as ComponentType), - straight: wrapEdge((edgeTypes.bezier || StraightEdge) as ComponentType), - step: wrapEdge((edgeTypes.step || StepEdge) as ComponentType), - smoothstep: wrapEdge((edgeTypes.step || SmoothStepEdge) as ComponentType), - simplebezier: wrapEdge((edgeTypes.simplebezier || SimpleBezierEdge) as ComponentType), - }; - - const wrappedTypes = {} as EdgeTypesWrapped; - const specialTypes: EdgeTypesWrapped = Object.keys(edgeTypes) - .filter((k) => !['default', 'bezier'].includes(k)) - .reduce((res, key) => { - res[key] = wrapEdge((edgeTypes[key] || BezierEdge) as ComponentType); - - return res; - }, wrappedTypes); - - return { - ...standardTypes, - ...specialTypes, - }; -} diff --git a/packages/react/src/container/GraphView/index.tsx b/packages/react/src/container/GraphView/index.tsx index 014d754f..116b4d26 100644 --- a/packages/react/src/container/GraphView/index.tsx +++ b/packages/react/src/container/GraphView/index.tsx @@ -8,19 +8,15 @@ import useOnInitHandler from '../../hooks/useOnInitHandler'; import useViewportSync from '../../hooks/useViewportSync'; import ConnectionLine from '../../components/ConnectionLine'; import type { ReactFlowProps } from '../../types'; -import { createNodeTypes } from '../NodeRenderer/utils'; -import { createEdgeTypes } from '../EdgeRenderer/utils'; -import { useNodeOrEdgeTypes } from './utils'; +import useNodeOrEdgeTypesWarning from './useNodeOrEdgeTypesWarning'; export type GraphViewProps = Omit< ReactFlowProps, - 'onSelectionChange' | 'nodes' | 'edges' | 'nodeTypes' | 'edgeTypes' | 'onMove' | 'onMoveStart' | 'onMoveEnd' + 'onSelectionChange' | 'nodes' | 'edges' | 'onMove' | 'onMoveStart' | 'onMoveEnd' > & Required< Pick< ReactFlowProps, - | 'nodeTypes' - | 'edgeTypes' | 'selectionKeyCode' | 'deleteKeyCode' | 'multiSelectionKeyCode' @@ -108,8 +104,8 @@ const GraphView = ({ viewport, onViewportChange, }: GraphViewProps) => { - const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes); - const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes); + useNodeOrEdgeTypesWarning(nodeTypes); + useNodeOrEdgeTypesWarning(edgeTypes); useOnInitHandler(onInit); useViewportSync(viewport); @@ -154,7 +150,7 @@ const GraphView = ({ > { + if (process.env.NODE_ENV === 'development') { + if (updateCount.current > 1) { + store.getState().onError?.('002', errorMessages['error002']()); + } + updateCount.current += 1; + } + }, [nodeOrEdgeTypes]); +} diff --git a/packages/react/src/container/GraphView/utils.ts b/packages/react/src/container/GraphView/utils.ts deleted file mode 100644 index 15dbaafe..00000000 --- a/packages/react/src/container/GraphView/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo, useRef } from 'react'; -import { shallow } from 'zustand/shallow'; -import { errorMessages } from '@xyflow/system'; - -import { CreateEdgeTypes } from '../EdgeRenderer/utils'; -import { CreateNodeTypes } from '../NodeRenderer/utils'; -import type { EdgeTypes, EdgeTypesWrapped, NodeTypes, NodeTypesWrapped } from '../../types'; -import { useStoreApi } from '../../hooks/useStore'; - -export function useNodeOrEdgeTypes(nodeOrEdgeTypes: NodeTypes, createTypes: CreateNodeTypes): NodeTypesWrapped; -export function useNodeOrEdgeTypes(nodeOrEdgeTypes: EdgeTypes, createTypes: CreateEdgeTypes): EdgeTypesWrapped; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function useNodeOrEdgeTypes(nodeOrEdgeTypes: any, createTypes: any): any { - const typesKeysRef = useRef(null); - const store = useStoreApi(); - - const typesParsed = useMemo(() => { - if (process.env.NODE_ENV === 'development') { - const typeKeys = Object.keys(nodeOrEdgeTypes); - - if (shallow(typesKeysRef.current, typeKeys)) { - store.getState().onError?.('002', errorMessages['error002']()); - } - - typesKeysRef.current = typeKeys; - } - return createTypes(nodeOrEdgeTypes); - }, [nodeOrEdgeTypes]); - - return typesParsed; -} diff --git a/packages/react/src/container/NodeRenderer/index.tsx b/packages/react/src/container/NodeRenderer/index.tsx index 98f9fc39..fa877984 100644 --- a/packages/react/src/container/NodeRenderer/index.tsx +++ b/packages/react/src/container/NodeRenderer/index.tsx @@ -1,14 +1,15 @@ -import { memo, useMemo, useEffect, useRef, type ComponentType } from 'react'; +import { memo } from 'react'; import { shallow } from 'zustand/shallow'; -import { internalsSymbol, errorMessages, Position, clampPosition, getPositionWithOrigin } from '@xyflow/system'; -import useVisibleNodes from '../../hooks/useVisibleNodes'; +import useVisibleNodesIds from '../../hooks/useVisibleNodes'; import { useStore } from '../../hooks/useStore'; import { containerStyle } from '../../styles/utils'; import { GraphViewProps } from '../GraphView'; -import type { NodeTypesWrapped, ReactFlowState, WrapNodeProps } from '../../types'; +import type { ReactFlowState } from '../../types'; +import useResizeObserver from './useResizeObserver'; +import NodeWrapper from '../../components/NodeWrapper'; -type NodeRendererProps = Pick< +export type NodeRendererProps = Pick< GraphViewProps, | 'onNodeClick' | 'onNodeDoubleClick' @@ -23,126 +24,71 @@ type NodeRendererProps = Pick< | 'disableKeyboardA11y' | 'nodeOrigin' | 'nodeExtent' -> & { - nodeTypes: NodeTypesWrapped; -}; + | 'nodeTypes' +>; const selector = (s: ReactFlowState) => ({ nodesDraggable: s.nodesDraggable, nodesConnectable: s.nodesConnectable, nodesFocusable: s.nodesFocusable, elementsSelectable: s.elementsSelectable, - updateNodeDimensions: s.updateNodeDimensions, onError: s.onError, }); const NodeRenderer = (props: NodeRendererProps) => { - const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, updateNodeDimensions, onError } = - useStore(selector, shallow); - const nodes = useVisibleNodes(props.onlyRenderVisibleElements); - const resizeObserverRef = useRef(); - - const resizeObserver = useMemo(() => { - if (typeof ResizeObserver === 'undefined') { - return null; - } - - const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { - const updates = new Map(); - - entries.forEach((entry: ResizeObserverEntry) => { - const id = entry.target.getAttribute('data-id') as string; - updates.set(id, { - id, - nodeElement: entry.target as HTMLDivElement, - forceUpdate: true, - }); - }); - - updateNodeDimensions(updates); - }); - - resizeObserverRef.current = observer; - - return observer; - }, []); - - useEffect(() => { - return () => { - resizeObserverRef?.current?.disconnect(); - }; - }, []); + const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, onError } = useStore(selector, shallow); + const nodeIds = useVisibleNodesIds(props.onlyRenderVisibleElements); + const resizeObserver = useResizeObserver(); return (
- {nodes.map((node) => { - let nodeType = node.type || 'default'; - - if (!props.nodeTypes[nodeType]) { - onError?.('003', errorMessages['error003'](nodeType)); - - nodeType = 'default'; - } - - const NodeComponent = (props.nodeTypes[nodeType] || props.nodeTypes.default) as ComponentType; - const isDraggable = !!(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined')); - const isSelectable = !!(node.selectable || (elementsSelectable && typeof node.selectable === 'undefined')); - const isConnectable = !!(node.connectable || (nodesConnectable && typeof node.connectable === 'undefined')); - const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined')); - - const clampedPosition = props.nodeExtent - ? clampPosition(node.positionAbsolute, props.nodeExtent) - : node.positionAbsolute; - - const posX = clampedPosition?.x ?? 0; - const posY = clampedPosition?.y ?? 0; - const posOrigin = getPositionWithOrigin({ - x: posX, - y: posY, - width: node.width ?? 0, - height: node.height ?? 0, - origin: node.origin || props.nodeOrigin, - }); - const initialized = (!!node.width && !!node.height) || (!!node.size?.width && !!node.size?.height); - + {nodeIds.map((nodeId) => { return ( -