diff --git a/.npmrc b/.npmrc index 8d10578a..acb88cc7 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,2 @@ -registry="https://registry.npmjs.com/" legacy-peer-deps=true strict-peer-dependencies=false \ No newline at end of file diff --git a/examples/astro-xyflow/src/components/ReactFlowExample/CustomNode.tsx b/examples/astro-xyflow/src/components/ReactFlowExample/CustomNode.tsx index 86aef137..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, positionAbsolute }) => { +const CustomNode: FC = ({ data, positionAbsoluteX, positionAbsoluteY }) => { return ( <> @@ -18,7 +18,7 @@ const CustomNode: FC = ({ data, positionAbsolute }) => {
Position:{' '} - {positionAbsolute.x.toFixed(2)},{positionAbsolute.y.toFixed(2)} + {positionAbsoluteX.toFixed(2)},{positionAbsoluteY.toFixed(2)}
diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 4bab00f5..2f3d55ba 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -21,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'; @@ -44,6 +43,7 @@ import CancelConnection from '../examples/CancelConnection'; import InteractiveMinimap from '../examples/InteractiveMinimap'; import UseOnSelectionChange from '../examples/UseOnSelectionChange'; import NodeToolbar from '../examples/NodeToolbar'; +import UseConnection from '../examples/UseConnection'; import UseNodesInitialized from '../examples/UseNodesInit'; import UseNodesData from '../examples/UseNodesData'; import UseHandleConnections from '../examples/UseHandleConnections'; @@ -185,11 +185,6 @@ const routes: IRoute[] = [ path: 'multiflows', component: MultiFlows, }, - { - name: 'Nested Nodes', - path: 'nested-nodes', - component: NestedNodes, - }, { name: 'Node Type Change', path: 'nodetype-change', @@ -260,6 +255,11 @@ const routes: IRoute[] = [ path: 'update-node', component: UpdateNode, }, + { + name: 'useConnection', + path: 'use-connection', + component: UseConnection, + }, { name: 'useNodesInitialized', path: 'use-nodes-initialized', diff --git a/examples/react/src/examples/CustomMiniMapNode/index.tsx b/examples/react/src/examples/CustomMiniMapNode/index.tsx index d8f584b6..0b62196f 100644 --- a/examples/react/src/examples/CustomMiniMapNode/index.tsx +++ b/examples/react/src/examples/CustomMiniMapNode/index.tsx @@ -32,8 +32,8 @@ const CustomMiniMapNode = ({ x, y, width, height, color }: MiniMapNodeProps) => ); const CustomMiniMapNodeFlow = () => { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnect = useCallback((params: Connection | Edge) => setEdges((els) => addEdge(params, els)), [setEdges]); const addRandomNode = () => { diff --git a/examples/react/src/examples/CustomNode/index.tsx b/examples/react/src/examples/CustomNode/index.tsx index 9c62f46a..ce7d0f62 100644 --- a/examples/react/src/examples/CustomNode/index.tsx +++ b/examples/react/src/examples/CustomNode/index.tsx @@ -12,6 +12,7 @@ import { useNodesState, useEdgesState, Background, + Edge, } from '@xyflow/react'; import ColorSelectorNode from './ColorSelectorNode'; @@ -33,8 +34,8 @@ const nodeTypes = { }; const CustomNodeFlow = () => { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [bgColor, setBgColor] = useState(initBgColor); diff --git a/examples/react/src/examples/DragNDrop/index.tsx b/examples/react/src/examples/DragNDrop/index.tsx index d11cd902..83c7bb4e 100644 --- a/examples/react/src/examples/DragNDrop/index.tsx +++ b/examples/react/src/examples/DragNDrop/index.tsx @@ -39,7 +39,7 @@ const nodeOrigin: NodeOrigin = [0.5, 0.5]; const DnDFlow = () => { const [reactFlowInstance, setReactFlowInstance] = useState(); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnect = (params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)); const onInit = (rfi: ReactFlowInstance) => setReactFlowInstance(rfi); diff --git a/examples/react/src/examples/EasyConnect/utils.tsx b/examples/react/src/examples/EasyConnect/utils.tsx index d7f3e689..6d87795b 100644 --- a/examples/react/src/examples/EasyConnect/utils.tsx +++ b/examples/react/src/examples/EasyConnect/utils.tsx @@ -4,8 +4,12 @@ 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 } = intersectionNode; - const intersectionNodePosition = intersectionNode.computed?.positionAbsolute!; + + const { + width: intersectionNodeWidth, + height: intersectionNodeHeight, + positionAbsolute: intersectionNodePosition, + } = intersectionNode.computed || {}; const targetPosition = targetNode.computed?.positionAbsolute!; const w = intersectionNodeWidth! / 2; diff --git a/examples/react/src/examples/Empty/index.tsx b/examples/react/src/examples/Empty/index.tsx index e162daad..4dcd996e 100644 --- a/examples/react/src/examples/Empty/index.tsx +++ b/examples/react/src/examples/Empty/index.tsx @@ -26,8 +26,8 @@ const buttonStyle: CSSProperties = { }; const EmptyFlow = () => { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnect = useCallback((params: Connection | Edge) => setEdges((els) => addEdge(params, els)), [setEdges]); const addRandomNode = () => { diff --git a/examples/react/src/examples/FloatingEdges/utils.ts b/examples/react/src/examples/FloatingEdges/utils.ts index 0a2d6c03..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; 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/NodeTypesObjectChange/index.tsx b/examples/react/src/examples/NodeTypesObjectChange/index.tsx index 01a0d993..46b5cd5e 100644 --- a/examples/react/src/examples/NodeTypesObjectChange/index.tsx +++ b/examples/react/src/examples/NodeTypesObjectChange/index.tsx @@ -66,7 +66,7 @@ const nodeTypesObjects: NodeTypesObject = { const NodeTypeChangeFlow = () => { const [nodeTypesId, setNodeTypesId] = useState('a'); const [nodes, , onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]); const changeType = () => setNodeTypesId((nt) => (nt === 'a' ? 'b' : 'a')); diff --git a/examples/react/src/examples/Overview/index.tsx b/examples/react/src/examples/Overview/index.tsx index e4879ace..3f5c72d0 100644 --- a/examples/react/src/examples/Overview/index.tsx +++ b/examples/react/src/examples/Overview/index.tsx @@ -15,6 +15,8 @@ import { Background, MiniMap, ConnectionMode, + OnBeforeDelete, + OnDelete, } from '@xyflow/react'; const onNodeDragStart = (_: ReactMouseEvent, node: Node, nodes: Node[]) => console.log('drag start', node, nodes); @@ -45,8 +47,12 @@ const onEdgeMouseEnter = (_: ReactMouseEvent, edge: Edge) => console.log('edge m const onEdgeMouseMove = (_: ReactMouseEvent, edge: Edge) => console.log('edge mouse move', edge); const onEdgeMouseLeave = (_: ReactMouseEvent, edge: Edge) => console.log('edge mouse leave', edge); const onEdgeDoubleClick = (_: ReactMouseEvent, edge: Edge) => console.log('edge double click', edge); -const onNodesDelete = (nodes: Node[]) => console.log('nodes delete', nodes); -const onEdgesDelete = (edges: Edge[]) => console.log('edges delete', edges); +const onBeforeDelete: OnBeforeDelete = async ({ nodes, edges }) => { + console.log('on before delete', nodes, edges); + const deleteElements = confirm('Do you want to remove the selected elements?'); + return deleteElements; +}; +const onDelete: OnDelete = ({ nodes, edges }) => console.log('on delete', nodes, edges); const onPaneMouseMove = (e: ReactMouseEvent) => console.log('pane move', e.clientX, e.clientY); const initialNodes: Node[] = [ @@ -226,8 +232,8 @@ const OverviewFlow = () => { fitViewOptions={{ padding: 0.1 /*nodes: [{ id: '1' }]*/ }} attributionPosition="top-right" maxZoom={Infinity} - onNodesDelete={onNodesDelete} - onEdgesDelete={onEdgesDelete} + onBeforeDelete={onBeforeDelete} + onDelete={onDelete} 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 46bae224..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, positionAbsolute, id }) => { +const DebugNode: FC = ({ zIndex, positionAbsoluteX, positionAbsoluteY, id }) => { return ( <>
{id}
- x:{Math.round(positionAbsolute.x)} y:{Math.round(positionAbsolute.y)} z:{zIndex} + x:{Math.round(positionAbsoluteX)} y:{Math.round(positionAbsoluteY)} z:{zIndex}
diff --git a/examples/react/src/examples/UpdatableEdge/index.tsx b/examples/react/src/examples/UpdatableEdge/index.tsx index 754efa56..5dacdc9c 100644 --- a/examples/react/src/examples/UpdatableEdge/index.tsx +++ b/examples/react/src/examples/UpdatableEdge/index.tsx @@ -6,7 +6,6 @@ import { addEdge, applyNodeChanges, applyEdgeChanges, - ReactFlowInstance, Connection, Edge, Node, @@ -97,7 +96,6 @@ const initialEdges: Edge[] = [ { id: 'e5-6', source: '5', target: '6', label: 'This edge can be updated from both sides' }, ]; -const onInit = (reactFlowInstance: ReactFlowInstance) => reactFlowInstance.fitView(); const onEdgeUpdateStart = (_: ReactMouseEvent, edge: Edge, handleType: HandleType) => console.log(`start update ${handleType} handle`, edge); const onEdgeUpdateEnd = (_: MouseEvent | TouchEvent, edge: Edge, handleType: HandleType) => @@ -111,7 +109,6 @@ const UpdatableEdge = () => { const onConnect = (connection: Connection) => setEdges((els) => addEdge(connection, els)); const onNodesChange = useCallback((changes: NodeChange[]) => { - console.log(changes); setNodes((ns) => applyNodeChanges(changes, ns)); }, []); @@ -125,12 +122,12 @@ const UpdatableEdge = () => { edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} - onInit={onInit} snapToGrid={true} onEdgeUpdate={onEdgeUpdate} onConnect={onConnect} onEdgeUpdateStart={onEdgeUpdateStart} onEdgeUpdateEnd={onEdgeUpdateEnd} + fitView >
diff --git a/examples/react/src/examples/UpdateNode/index.tsx b/examples/react/src/examples/UpdateNode/index.tsx index d6e8a67a..c32fca34 100644 --- a/examples/react/src/examples/UpdateNode/index.tsx +++ b/examples/react/src/examples/UpdateNode/index.tsx @@ -24,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, + }, }; } @@ -40,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; @@ -52,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; diff --git a/examples/react/src/examples/UseConnection/index.tsx b/examples/react/src/examples/UseConnection/index.tsx new file mode 100644 index 00000000..cc90f11f --- /dev/null +++ b/examples/react/src/examples/UseConnection/index.tsx @@ -0,0 +1,75 @@ +import { useCallback, useEffect } from 'react'; +import { + ReactFlow, + Background, + MiniMap, + Node, + addEdge, + ReactFlowProvider, + Edge, + useNodesState, + useEdgesState, + OnConnect, + useConnection, +} from '@xyflow/react'; + +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', + }, + { + id: '3', + data: { label: 'Node 3' }, + position: { x: 400, y: 100 }, + className: 'light', + }, +]; + +const initialEdges: Edge[] = [ + { id: 'e1-2', source: '1', target: '2' }, + { id: 'e1-3', source: '1', target: '3' }, +]; + +const UseZoomPanHelperFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const onConnect: OnConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []); + const connection = useConnection(); + + useEffect(() => { + console.log('connection', connection); + }, [connection]); + + return ( + + + + + ); +}; + +const WrappedFlow = () => ( + + + +); + +export default WrappedFlow; diff --git a/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx index e04f3448..4301e647 100644 --- a/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx +++ b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx @@ -12,8 +12,8 @@ function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeI [nodeId] ); const connections = useHandleConnections({ - handleType: handleProps.type, - handleId: handleProps.id, + type: handleProps.type, + id: handleProps.id, onConnect, onDisconnect, }); diff --git a/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx index 444499fb..6163f108 100644 --- a/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx +++ b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx @@ -15,8 +15,8 @@ function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeI [nodeId] ); const connections = useHandleConnections({ - handleType: handleProps.type, - handleId: handleProps.id, + type: handleProps.type, + id: handleProps.id, onConnect, onDisconnect, }); diff --git a/examples/react/src/examples/UseUpdateNodeInternals/index.tsx b/examples/react/src/examples/UseUpdateNodeInternals/index.tsx index 371d58b6..2cd405b7 100644 --- a/examples/react/src/examples/UseUpdateNodeInternals/index.tsx +++ b/examples/react/src/examples/UseUpdateNodeInternals/index.tsx @@ -33,7 +33,7 @@ const getId = (): string => `${id++}`; const UpdateNodeInternalsFlow = () => { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnect = useCallback((params: Edge | Connection) => setEdges((els) => addEdge(params, els)), [setEdges]); const { screenToFlowPosition } = useReactFlow(); diff --git a/examples/react/src/examples/Validation/index.tsx b/examples/react/src/examples/Validation/index.tsx index 864c3c08..936e1396 100644 --- a/examples/react/src/examples/Validation/index.tsx +++ b/examples/react/src/examples/Validation/index.tsx @@ -16,6 +16,7 @@ import { updateEdge, Edge, IsValidConnection, + OnBeforeDelete, } from '@xyflow/react'; import ConnectionStatus from './ConnectionStatus'; @@ -54,7 +55,7 @@ const nodeTypes: NodeTypes = { const ValidationFlow = () => { const [value, setValue] = useState(0); const [nodes, , onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnectStart: OnConnectStart = useCallback( (event, params) => { @@ -85,6 +86,10 @@ const ValidationFlow = () => { [setEdges] ); + const onBeforeDelete: OnBeforeDelete = useCallback(async () => { + return true; + }, []); + return ( { onConnectEnd={onConnectEnd} onEdgeUpdate={onEdgeUpdate} isValidConnection={isValidConnection} + onBeforeDelete={onBeforeDelete} fitView > diff --git a/examples/react/src/generic-tests/nodes/general.ts b/examples/react/src/generic-tests/nodes/general.ts index 0fcb975c..d45091dd 100644 --- a/examples/react/src/generic-tests/nodes/general.ts +++ b/examples/react/src/generic-tests/nodes/general.ts @@ -6,6 +6,7 @@ export default { nodeTypes: { DragHandleNode, }, + nodeDragThreshold: 0, nodes: [ { id: 'Node-1', diff --git a/examples/svelte/src/routes/examples/overview/+page.svelte b/examples/svelte/src/routes/examples/overview/+page.svelte index d8293666..44d54544 100644 --- a/examples/svelte/src/routes/examples/overview/+page.svelte +++ b/examples/svelte/src/routes/examples/overview/+page.svelte @@ -177,6 +177,11 @@ }} on:selectionclick={(event) => console.log('on selection click', event)} on:selectioncontextmenu={(event) => console.log('on selection contextmenu', event)} + onbeforedelete={async ({ nodes, edges }) => { + console.log('on before delete', nodes, edges); + const deleteElements = confirm('Are you sure you want to delete the selected elements?'); + return deleteElements; + }} 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 1197ce62..faf6a2a5 100644 --- a/examples/svelte/src/routes/examples/overview/CustomNode.svelte +++ b/examples/svelte/src/routes/examples/overview/CustomNode.svelte @@ -1,15 +1,16 @@
{data.label}
-
{~~positionAbsolute.x}, {~~positionAbsolute.y}
+
{~~positionAbsoluteX}, {~~positionAbsoluteY}
diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 091d2b91..fb1ff106 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,304 +1,97 @@ -# @reactflow/core - -## 11.7.2 - -### Patch Changes - -- [#3060](https://github.com/wbkd/react-flow/pull/3060) [`70ec97f7`](https://github.com/wbkd/react-flow/commit/70ec97f7daec6d5401215cae3edac04aea88a3ba) - fix useNodes and useEdges bug with infinite re-renderings -- [#3064](https://github.com/wbkd/react-flow/pull/3064) [`d2d1aebc`](https://github.com/wbkd/react-flow/commit/d2d1aebc0f7fea4183406e7d1915b7fcd6995f48) - refactor(useUpdateNodeInternals): only call updateNodeDimensions once -- [#3059](https://github.com/wbkd/react-flow/pull/3059) [`4374459e`](https://github.com/wbkd/react-flow/commit/4374459ef9fec797bbc0407231f09a1acacd245b) - fix useUpdateNodeInternals type - -## 11.7.1 - -### Patch Changes - -- [#3043](https://github.com/wbkd/react-flow/pull/3043) [`cf7a7d3d`](https://github.com/wbkd/react-flow/commit/cf7a7d3dad1e73215a72a5dc72e21fd50208cdbb) - handles: handles on top of each other, reduce re-renderings -- [#3046](https://github.com/wbkd/react-flow/pull/3046) [`07b975bb`](https://github.com/wbkd/react-flow/commit/07b975bbee3580249e36a19582213b250f78093c) - base-edge: pass id to base edge path -- [#3007](https://github.com/wbkd/react-flow/pull/3007) [`c80d269b`](https://github.com/wbkd/react-flow/commit/c80d269b85a0054221f4639c328fc36a3befbe70) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - allow array of ids as updateNodeInternals arg -- [#3029](https://github.com/wbkd/react-flow/pull/3029) [`a3fa164c`](https://github.com/wbkd/react-flow/commit/a3fa164c34cc820c79bb031c9fd97b72a3546614) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - autopan: only update nodes when transform change happen - -## 11.7.0 - -Most notable updates: - -- Handles: `isConnectableStart` and `isConnectableEnd` props to configure if you can start or end a connection at a certain handle -- Edges: `updatable` option to enable updates for specific edges -- useNodesInitialized: options to configure if hidden nodes should be included (false by default) - -### Minor Changes - -- [#2960](https://github.com/wbkd/react-flow/pull/2960) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - edges: add `updatable` option -- [#2958](https://github.com/wbkd/react-flow/pull/2958) [`4d97a0ed`](https://github.com/wbkd/react-flow/commit/4d97a0ed168ce643fc0c99fa6b47cf1296d66065) - handles: add `isConnectableStart` and `isConnectableEnd` props -- [#2956](https://github.com/wbkd/react-flow/pull/2956) [`923a54c4`](https://github.com/wbkd/react-flow/commit/923a54c481b90954806202817ba844cfa7203a38) - add options for `useNodesInitialized`, ignore hidden nodes by default - -### Patch Changes - -- [#2926](https://github.com/wbkd/react-flow/pull/2926) Thanks [@Elringus](https://github.com/Elringus)! - fix non-passive wheel event listener violation -- [#2933](https://github.com/wbkd/react-flow/pull/2933) [`fe8cac0a`](https://github.com/wbkd/react-flow/commit/fe8cac0adb359109e0e9eafe8b9261ba354076bb) - prefix error keys with "error" -- [#2939](https://github.com/wbkd/react-flow/pull/2939) [`4a4ca171`](https://github.com/wbkd/react-flow/commit/4a4ca171955f5c8d58b23e3ad48406f1a21dc402) - add connection result to store - -## 11.6.1 - -### Patch Changes - -- Always create new edge object (fixes an issue with Redux toolkit and other immutable helper libs) - -## 11.6.0 - -### Minor Changes - -- [#2877](https://github.com/wbkd/react-flow/pull/2877) [`b8886514`](https://github.com/wbkd/react-flow/commit/b88865140c72fa7e92a883498768000cb2cc96a7) - add `isValidConnection` prop for ReactFlow component -- [#2847](https://github.com/wbkd/react-flow/pull/2847) [`16bf89f2`](https://github.com/wbkd/react-flow/commit/16bf89f2b7bbf8449c00d0e2c07c19c3ff6d2533) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - Add option to enable/disable replacing edge id when using `updateEdge` - -### Patch Changes - -- [#2895](https://github.com/wbkd/react-flow/pull/2895) [`3d5764ca`](https://github.com/wbkd/react-flow/commit/3d5764cac6548984a30cbf85899024e62fd69425) - add data-testid for controls, minimap and background -- [#2894](https://github.com/wbkd/react-flow/pull/2894) [`83fc4675`](https://github.com/wbkd/react-flow/commit/83fc467545527729633e817dbccfe59d0040da4b) - fix(nodes): blur when node gets unselected -- [#2892](https://github.com/wbkd/react-flow/pull/2892) [`5fabd272`](https://github.com/wbkd/react-flow/commit/5fabd2720f6367f75f79a45822d8f675a3b8e1cf) Thanks [@danielgek](https://github.com/danielgek) - track modifier keys on useKeypress -- [#2893](https://github.com/wbkd/react-flow/pull/2893) [`8f080bd5`](https://github.com/wbkd/react-flow/commit/8f080bd5e0e7e6c71f51eee9c9f2bc4b25182861) - fix: check if handle is connectable - -## 11.5.5 - -### Patch Changes - -- [#2834](https://github.com/wbkd/react-flow/pull/2834) [`23424ea6`](https://github.com/wbkd/react-flow/commit/23424ea6750f092210f83df17a00c89adb910d96) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - Add `nodes` to fit view options to allow fitting view only around specified set of nodes -- [#2836](https://github.com/wbkd/react-flow/pull/2836) [`959b1114`](https://github.com/wbkd/react-flow/commit/959b111448bba4686040473e46988be9e7befbe6) - Fix: connections for handles with bigger handles than connection radius -- [#2819](https://github.com/wbkd/react-flow/pull/2819) [`0d259b02`](https://github.com/wbkd/react-flow/commit/0d259b028558aab650546f3371a85f3bce45252f) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - Avoid triggering edge update if not using left mouse button -- [#2832](https://github.com/wbkd/react-flow/pull/2832) [`f3de9335`](https://github.com/wbkd/react-flow/commit/f3de9335af6cd96cd77dc77f24a944eef85384e5) - fitView: return type boolean -- [#2838](https://github.com/wbkd/react-flow/pull/2838) [`021f5a92`](https://github.com/wbkd/react-flow/commit/021f5a9210f47a968e50446cd2f9dae1f97880a4) - refactor: use key press handle modifier keys + input -- [#2839](https://github.com/wbkd/react-flow/pull/2839) [`72216ff6`](https://github.com/wbkd/react-flow/commit/72216ff62014acd2d73999053c72bd7aeed351f6) - fix PropsWithChildren: pass default generic for v17 types - -## 11.5.4 - -### Patch Changes - -- [`383a074a`](https://github.com/wbkd/react-flow/commit/383a074aeae6dbec8437fa08c7c8d8240838a84e) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - Check if prevClosestHandle exists in onPointerUp. Fixes connections getting stuck on last handle and connecting, even when out of connectionRadius - -## 11.5.3 - -This release fixes some issues with the newly introduced connection radius feature. We are now not only checking the radius but the handle itself too (like in the old version). That means that you can connect to a handle that is bigger than the connection radius. We are also not snapping connections anymore when they are not valid and pass a status class to the connection line that says if the current connection is valid or not. More over we fixed a connection issue with iOS. - -### Patch Changes - -- [#2800](https://github.com/wbkd/react-flow/pull/2800) [`be8097ac`](https://github.com/wbkd/react-flow/commit/be8097acadca3054c3b236ce4296fc516010ef8c) - When node is not draggable, you can't move it with a selection either -- [#2803](https://github.com/wbkd/react-flow/pull/2803) [`1527795d`](https://github.com/wbkd/react-flow/commit/1527795d18c3af38c8ec7059436ea0fbf6c27bbd) - connection: add status class (valid or invalid) while in connection radius -- [#2801](https://github.com/wbkd/react-flow/pull/2801) [`3b6348a8`](https://github.com/wbkd/react-flow/commit/3b6348a8d1573afb39576327318bc172e33393c2) - fix(ios): connection error + dont snap invalid connection lines, check handle and connection radius - -## 11.5.2 - -### Patch Changes - -- [#2792](https://github.com/wbkd/react-flow/pull/2792) [`d8c679b4`](https://github.com/wbkd/react-flow/commit/d8c679b4c90c5b57d4b51e4aaa988243d6eaff5a) - Accept React 17 types as dev dependency - -## 11.5.1 - -### Patch Changes - -- [#2783](https://github.com/wbkd/react-flow/pull/2783) [`71153534`](https://github.com/wbkd/react-flow/commit/7115353418ebc7f7c81ab0e861200972bbf7dbd5) - connections: check handle below mouse before using connection radius - -## 11.5.0 - -Lot's of improvements are coming with this release! - -- **Connecting radius**: No need to drop a connection line on top of handle anymore. You only need to be close to the handle. That radius can be configured with the `connectionRadius` prop. -- **Auto pan**: When you drag a node, a selection or the connection line to the border of the pane, it will pan into that direction. That makes it easier to connect far away nodes for example. If you don't like it you can set `autoPnaOnNodeDrag` and `autoPanOnConnect` to false. -- **Touch devices**: It's finally possibleto connect nodes with the connection line on touch devices. In combination with the new auto pan and connection radius the overall UX is way better. -- **Errors**: We added an `onError` prop to get notified when an error like "couldn't find source handle" happens. This is useful if you want to log errors for example. -- **Node type**: We added a second param to the generic `Node` type. You can not only pass `NodeData` but also the type as a second param: - -```ts -type MyCustomNode = Node; -``` - -This makes it easier to work with different custom nodes and data types. - -### Minor Changes - -- [#2754](https://github.com/wbkd/react-flow/pull/2754) [`e96309b6`](https://github.com/wbkd/react-flow/commit/e96309b6a57b1071faeebf7b0547fef7fd418694) - Add auto pan for connecting and node dragging and `connectionRadius` -- [#2773](https://github.com/wbkd/react-flow/pull/2773) - Add `onError` prop to get notified when an error happens - -### Patch Changes - -- [#2763](https://github.com/wbkd/react-flow/pull/2763) [`85003b01`](https://github.com/wbkd/react-flow/commit/85003b01add71ea852bd5b0d2f1e7496050a6b52) - Connecting nodes: Enable connections on touch devices -- [#2620](https://github.com/wbkd/react-flow/pull/2620) - Thanks [RichSchulz](https://github.com/RichSchulz)! - Types: improve typing for node type - -## 11.4.2 - -### Patch Changes - -- [#2741](https://github.com/wbkd/react-flow/pull/2741) [`e2aff6c1`](https://github.com/wbkd/react-flow/commit/e2aff6c1e4ce54b57b724b2624367ee5fefd1c39) - chore(dependencies): update and cleanup - -## 11.4.1 - -### Patch Changes - -- [#2738](https://github.com/wbkd/react-flow/pull/2738) [`82988485`](https://github.com/wbkd/react-flow/commit/82988485b730a9e32acbdae1ddcc81b33ddccaba) - fix: fitView for subflows, context menu on right mouse pan -- [#2740](https://github.com/wbkd/react-flow/pull/2740) [`d91e619a`](https://github.com/wbkd/react-flow/commit/d91e619a70a95db99a621ede59bc05b5a7766086) Thanks [@michaelspiss](https://github.com/michaelspiss)! - EdgeRenderer: check all handles for connection mode loose - -## 11.4.0 - -## New Features - -New props for the ReactFlow component to customize the controls of the viewport and the selection box better: - -1. `selectionOnDrag` prop: Selection box without extra button press (need to set `panOnDrag={false}` or `panOnDrag={[1, 2]}`) -2. `panOnDrag={[0, 1, 2]}` option to configure specific mouse buttons for panning -3. `panActivationKeyCode="Space"` key code for activating dragging (useful when using `selectionOnDrag`) -4. `selectionMode={SelectionMode.Full}`: you can chose if the selection box needs to contain a node fully (`SelectionMode.Full`) or partially (`SelectionMode.Partial`) to select it -5. `onSelectionStart` and `onSelectionEnd` events -6. `elevateNodesOnSelect`: Defines if z-index should be increased when node is selected -7. New store function `getNodes`. You can now do `store.getState().getNodes()` instead of `Array.from(store.getNodes().nodeInternals.values())`. - -Thanks to @jackfishwick who helped a lot with the new panning and selection options. - -### Minor Changes - -- [#2678](https://github.com/wbkd/react-flow/pull/2678) [`baa8689e`](https://github.com/wbkd/react-flow/commit/baa8689ef629d22da4cbbef955e0c83d21df0493) - - Add new props to configure viewport controls (`selectionOnDrag`, `panActivationKeyCode`, ..) -- [#2661](https://github.com/wbkd/react-flow/pull/2661) [`7ef29108`](https://github.com/wbkd/react-flow/commit/7ef2910808aaaee029894363d52efc0c378a7654) - - panOnDrag: Use numbers for prop ([1,2] = drag via middle or right mouse button) - - selection: do not include hidden nodes - - minimap: fix onNodeClick for nodes outside the viewport - - keys: allow multi select when input is focused - -### Patch Changes - -- [#2695](https://github.com/wbkd/react-flow/pull/2695) [`ab2ff374`](https://github.com/wbkd/react-flow/commit/ab2ff3740618da48bd4350597e816c397f3d78ff) - Add elevateNodesOnSelect prop -- [#2660](https://github.com/wbkd/react-flow/pull/2660) [`50032c3d`](https://github.com/wbkd/react-flow/commit/50032c3d953bd819d0afe48e4b61f77f987cc8d0) - Add `getNodes` function to the store so that you don't need to do `Array.from(store.getState().nodeInternals.values())` anymore. -- [#2659](https://github.com/wbkd/react-flow/pull/2659) [`4244bae2`](https://github.com/wbkd/react-flow/commit/4244bae25a36cb4904dc1fbba26e1c4d5d463cb9) - Use translateExtent correctly -- [#2657](https://github.com/wbkd/react-flow/pull/2657) [`23afb3ab`](https://github.com/wbkd/react-flow/commit/23afb3abebdb42fad284f68bec164afac609563c) - Only trigger drag event when change happened - -## 11.4.0-next.1 - -### Minor Changes - -- panOnDrag: Use numbers for prop ([1,2] = drag via middle or right mouse button) -- selection: do not include hidden nodes -- minimap: fix onNodeClick for nodes outside the viewport -- keys: allow multi select when input is focused - -## 11.4.0-next.0 - -### Minor Changes - -- [#2678](https://github.com/wbkd/react-flow/pull/2678) [`baa8689e`](https://github.com/wbkd/react-flow/commit/baa8689ef629d22da4cbbef955e0c83d21df0493) Thanks [@moklick](https://github.com/moklick)! - ## New Features - - New props for the ReactFlow component to customize the controls of the viewport and the selection box better: - - 1. `selectionOnDrag` prop: Selection box without extra button press (need to set `panOnDrag={false} or `panOnDrag="RightClick"`) - 2. `panOnDrag="RightClick"` option - 3. `panActivationKeyCode="Space"` key code for activating dragging (useful when using `selectionOnDrag`) - 4. `selectionMode={SelectionMode.Full}`: you can chose if the selection box needs to contain a node fully (`SelectionMode.Full`) or partially (`SelectionMode.Partial`) to select it - 5. `onSelectionStart` and `onSelectionEnd` events - -### Patch Changes - -- [#2660](https://github.com/wbkd/react-flow/pull/2660) [`50032c3d`](https://github.com/wbkd/react-flow/commit/50032c3d953bd819d0afe48e4b61f77f987cc8d0) Thanks [@moklick](https://github.com/moklick)! - Add `getNodes` function to the store so that you don't need to do `Array.from(store.getState().nodeInternals.values())` anymore. - -- [#2659](https://github.com/wbkd/react-flow/pull/2659) [`4244bae2`](https://github.com/wbkd/react-flow/commit/4244bae25a36cb4904dc1fbba26e1c4d5d463cb9) Thanks [@moklick](https://github.com/moklick)! - Use translateExtent correctly - -- [#2657](https://github.com/wbkd/react-flow/pull/2657) [`23afb3ab`](https://github.com/wbkd/react-flow/commit/23afb3abebdb42fad284f68bec164afac609563c) Thanks [@moklick](https://github.com/moklick)! - Only trigger drag event when change happened - -## 11.3.2 - -In this update we did some changes so that we could implement the new [``](https://reactflow.dev/docs/api/nodes/node-resizer/) component more smoothly. - -### Patch Changes - -- [#2646](https://github.com/wbkd/react-flow/pull/2646) [`e6b5d90f`](https://github.com/wbkd/react-flow/commit/e6b5d90f61c8ee60e817bba232a162cae2ab3e2a) - Fix getRectOfNodes -- [#2648](https://github.com/wbkd/react-flow/pull/2648) [`6ee44e07`](https://github.com/wbkd/react-flow/commit/6ee44e076eaa6908d07578a757a5187642b732ae) - Allow middle mouse pan over edges -- [#2647](https://github.com/wbkd/react-flow/pull/2647) [`aa69c207`](https://github.com/wbkd/react-flow/commit/aa69c20765e6978f4f9c8cc63ed7110dbf6d9d9d) Thanks [@neo](https://github.com/neo)! - Invalidate node trying to connect itself with the same handle -- [#2626](https://github.com/wbkd/react-flow/pull/2626) [`d29c401d`](https://github.com/wbkd/react-flow/commit/d29c401d598dbf2dcd5609b7adb8d029906a6f18) - Export the useNodeId hook, refactor how changes are applied and create a helper function -- [#2642](https://github.com/wbkd/react-flow/pull/2642) [`0df02f35`](https://github.com/wbkd/react-flow/commit/0df02f35f8d6c54dae36af18278feadc77acb2d6) - Ignore key events for nodes when input is focused - -## 11.3.1 - -### Patch Changes - -- [#2595](https://github.com/wbkd/react-flow/pull/2595) [`c828bfda`](https://github.com/wbkd/react-flow/commit/c828bfda0a8c4774bc43588640c7cca0cfdcb3f4) Thanks [@chrtze](https://github.com/chrtze)! - Fix and improve the behaviour when using nodeOrigin in combination with subflows -- [#2602](https://github.com/wbkd/react-flow/pull/2602) [`b0302ce4`](https://github.com/wbkd/react-flow/commit/b0302ce4261a992bee841bae84af347d03be690f) Thanks [@sdegueldre](https://github.com/sdegueldre)! - Don't use try catch in wrapper for checking if provider is available -- [#2601](https://github.com/wbkd/react-flow/pull/2601) [`b2c72813`](https://github.com/wbkd/react-flow/commit/b2c728137d1b53e38883f044fa447585c377a6af) Thanks [@hoondeveloper](https://github.com/hoondeveloper)! - fix isRectObject function - -## 11.3.0 - -### Minor Changes - -- [#2563](https://github.com/wbkd/react-flow/pull/2563) [`98116d43`](https://github.com/wbkd/react-flow/commit/98116d431f9fcdcc9b23a5b606a94ec0740b64cd) Thanks [@chrtze](https://github.com/chrtze)! - Export a new component "NodeToolbar" that renders a fixed element attached to a node - -### Patch Changes - -- [#2561](https://github.com/wbkd/react-flow/pull/2561) [`92cf497e`](https://github.com/wbkd/react-flow/commit/92cf497eb72f21af592a53f5af9770c9f1e6d940) Thanks [@moklick](https://github.com/moklick)! - Fix multi selection and fitView when nodeOrigin is used -- [#2560](https://github.com/wbkd/react-flow/pull/2560) [`a39224b3`](https://github.com/wbkd/react-flow/commit/a39224b3a80afbdb83fc4490dd5f4f2be23cd4dd) Thanks [@neo](https://github.com/neo)! - Always elevate zIndex when node is selected -- [#2573](https://github.com/wbkd/react-flow/pull/2573) [`5e8b67dd`](https://github.com/wbkd/react-flow/commit/5e8b67dd41f9bb60dcd7f5d14cc34b42c970e967) Thanks [@moklick](https://github.com/moklick)! - Fix disappearing connection line for loose flows -- [#2558](https://github.com/wbkd/react-flow/pull/2558) [`2a1c7db6`](https://github.com/wbkd/react-flow/commit/2a1c7db6b27ac0f4f81dcef2d593f4753c4321c7) Thanks [@moklick](https://github.com/moklick)! - EdgeLabelRenderer: handle multiple instances on a page - -## 11.2.0 - -### Minor Changes - -- [#2535](https://github.com/wbkd/react-flow/pull/2535) [`7902a3ce`](https://github.com/wbkd/react-flow/commit/7902a3ce3188426d5cd07cf0943a68f679e67948) Thanks [@moklick](https://github.com/moklick)! - Feat: Add edge label renderer -- [#2536](https://github.com/wbkd/react-flow/pull/2536) [`b25d499e`](https://github.com/wbkd/react-flow/commit/b25d499ec05b5c6f21ac552d03650eb37433552e) Thanks [@pengfu](https://github.com/pengfu)! - Feat: add deleteElements helper function -- [#2539](https://github.com/wbkd/react-flow/pull/2539) [`4fc1253e`](https://github.com/wbkd/react-flow/commit/4fc1253eadf9b7dd392d8dc2348f44fa8d08f931) Thanks [@moklick](https://github.com/moklick)! - Feat: add intersection helpers -- [#2530](https://github.com/wbkd/react-flow/pull/2530) [`8ba4dd5d`](https://github.com/wbkd/react-flow/commit/8ba4dd5d1d4b2e6f107c148de62aec0b688d8b21) Thanks [@moklick](https://github.com/moklick)! - Feat: Add pan and zoom to mini map - -### Patch Changes - -- [#2538](https://github.com/wbkd/react-flow/pull/2538) [`740659c0`](https://github.com/wbkd/react-flow/commit/740659c0e788c7572d4a1e64e1d33d60712233fc) Thanks [@neo](https://github.com/neo)! - Refactor: put React Flow in isolated stacking context - -## 11.1.2 - -### Patch Changes - -- make pro options acc type optional -- cleanup types -- fix rf id handling -- always render nodes when dragging=true -- don't apply animations to helper edge - -## 11.1.1 - -### Patch Changes - -- [`c44413d`](https://github.com/wbkd/react-flow/commit/c44413d816604ae2d6ad81ed227c3dfde1a7bd8a) Thanks [@moklick](https://github.com/moklick)! - chore(panel): dont break user selection above panel -- [`48c402c`](https://github.com/wbkd/react-flow/commit/48c402c4d3bd9e16dc91cd4c549324e57b6d5c57) Thanks [@moklick](https://github.com/moklick)! - refactor(aria-descriptions): render when disableKeyboardA11y is true -- [`3a1a365`](https://github.com/wbkd/react-flow/commit/3a1a365a63fc4564d9a8d96309908986fcc86f95) Thanks [@moklick](https://github.com/moklick)! - fix(useOnSelectionChange): repair hook closes #2484 -- [`5d35094`](https://github.com/wbkd/react-flow/commit/5d350942d33ded626b3387206f0b0dee368efdfb) Thanks [@neo](https://github.com/neo)! - Add css files as sideEffects - -## 11.1.0 - -### Minor Changes - -- [`def11008`](https://github.com/wbkd/react-flow/commit/def11008d88749fec40e6fcba8bc41eea2511bab) Thanks [@moklick](https://github.com/moklick)! - New props: nodesFocusable and edgesFocusable - -### Patch Changes - -- [`d00faa6b`](https://github.com/wbkd/react-flow/commit/d00faa6b3e77388bfd655d4c02e9a5375bc515e4) Thanks [@moklick](https://github.com/moklick)! - Make nopan class name overwritable with class name option - -## 11.0.0 - -### Major Changes - -- **Better [Accessibility](/docs/guides/accessibility)** - - Nodes and edges are focusable, selectable, moveable and deleteable with the keyboard. - - `aria-` default attributes for all elements and controllable via `ariaLabel` options - - Keyboard controls can be disabled with the new `disableKeyboardA11y` prop -- **Better selectable edges** via new edge option: `interactionWidth` - renders invisible edge that makes it easier to interact -- **Better routing for smoothstep and step edges**: https://twitter.com/reactflowdev/status/1567535405284614145 -- **Nicer edge updating behaviour**: https://twitter.com/reactflowdev/status/1564966917517021184 -- **Node origin**: The new `nodeOrigin` prop lets you control the origin of a node. Useful for layouting. -- **New background pattern**: `BackgroundVariant.Cross` variant -- **[`useOnViewportChange`](/docs/api/hooks/use-on-viewport-change) hook** - handle viewport changes within a component -- **[`useOnSelectionChange`](/docs/api/hooks/use-on-selection-change) hook** - handle selection changes within a component -- **[`useNodesInitialized`](/docs/api/hooks/use-nodes-initialized) hook** - returns true if all nodes are initialized and if there is more than one node -- **Deletable option** for Nodes and edges -- **New Event handlers**: `onPaneMouseEnter`, `onPaneMouseMove` and `onPaneMouseLeave` -- **Edge `pathOptions`** for `smoothstep` and `default` edges -- **Nicer cursor defaults**: Cursor is grabbing, while dragging a node or panning -- **Pane moveable** with middle mouse button -- **Pan over nodes** when they are not draggable (`draggable=false` or `nodesDraggable` false) -- **[``](/docs/api/edges/base-edge) component** that makes it easier to build custom edges -- **[Separately installable packages](/docs/overview/packages/)** - - @reactflow/core - - @reactflow/background - - @reactflow/controls - - @reactflow/minimap +# @xyflow/react + +## 12.0.0-next.1 + +### Minor changes + +- fix edge rendering + +## 12.0.0-next.0 + +React Flow v12 is coming soon! We worked hard over the past months and tried to make as few breaking changes as possible (there are some). We are in no rush to release v12, so we’d be happy to hear any early feedback so we can adjust the API or redefine new features before launching stable v12. 🚀 The big topics for this version are: + +1. **Support for SSG/ SSR**: you can now render flows on the server +2. **Reactive flows**: new hooks and helper functions to simplify data flows +3. **Dark mode**: a new base style and easy way to switch between built in color modes + +Svelte Flow had a big impact on this release as well. While combing through each line of React Flow, we created framework agnostic helpers, found bugs, and made some under the hood improvements. All of these changes are baked into the v12 release as a welcome side-effect of that launch. 🙌🏻 We also improved the performance for larger flows with the help of Ivan. + +### Migrate from 11 to 12 + +Before you can try out the new features, you need to do some minor updates: + +- **A new npm package name:** Our name changed from `reactflow` to `@xyflow/react` and the main component is no longer a default, but a named import: + - v11: `import ReactFlow from 'reactflow';` + - v12: `import { ReactFlow } from '@xyflow/react';` +- **Node attribute “computed”:** All computed node values are now stored in `node.computed` + - v11: `node.width`, `node.height` ,`node.positionAbsolute` + - v12: `node.computed.width`, `node.computed.height` and `node.computed.positionAbsolute` . (`node.width`/ `node.height` can now be used for SSG) +- **Updating nodes:** We are not supporting node updates with object mutations anymore. If you want to update a certain attribute, you need to create a new node. + - v11: + ```js + setNodes(nds => nds.map((node) => { + node.hidden = true; + return node; + })); + ``` + - v12: + ```js + setNodes(nds => nds.map((node) => ({ + ...node, + hidden: true + }))); + ``` +- **NodeProps:** `posX`/`posY` is now called `positionAbsoluteX`/`positionAbsoluteY` +- **Typescript only:** We simplified types and fixed issues about functions where users could pass a `NodeData` generic. The new way is to define your own node type for the whole app and then only use that one. The big advantage of this is, that you can have multiple node types with different data structures and always be able to distinguish by checking the `node.type` attribute. + - v11: `applyNodeChange` + - v12: `type MyNodeType = Node<{ value: number }, ‘number’> | Node<{ value: string }, ‘text’>; applyNodeChange` + - affected functions: `useNodes`, `useNodesState`, `useEdgesState`, `applyNodeChange`, `onInit`, `applyEdgeChanges` , `MiniMapProps` +- **Removal of deprecated functions:** + - `getTransformForBounds` (new name: `getViewportForBounds`), + - `getRectOfNodes` ****(new name: `getNodesBounds`) + - `project` (new name: `screenToFlowPosition`) + - `getMarkerEndId` + +### Main features + +Now that you successfully migrated to v12, you can use all the fancy features. As mentioned above, the biggest updates for v12 are: + +- **SSR / SSG**: you can define `width`, `height` and `handles` for the nodes. This makes it possible to render a flow on the server and hydrate on the client: [codesandbox](https://codesandbox.io/p/devbox/reactflow-v12-next-pr66yh) + - Details: In v11, `width` and `height` were set by the library as soon as the nodes got measured. This still happens, but we are now using `computed.width` and `computed.height` to store this information. The `positionAbsolute` attribute also gets stored in `computed` . In the previous versions there was always a lot of confusion about `width` and `height`. It’s hard to understand, that you can’t use it for passing an actual width or height. It’s also not obvious that those attributes get added by the library. We think that the new implementation solves both of the problems: `width` and `height` are optional attributes that can be used to define dimensions and everything that is set by the library, is stored in `computed`. +- **Reactive Flows:** The new hooks `useHandleConnections` and `useNodesData` and the new `updateNode` and `updateNodeData` functions can be used for managing the data flow between your nodes: [codesandbox](https://codesandbox.io/p/sandbox/reactflow-reactive-flow-sy93yx) + - Details: Working with reactive flows is super common. You update node A and want to react on those changes in the connected node B. Until now everyone had to come up with a custom solution. With this version we want to change this and give you performant helpers to handle this. If you are excited about this, you can check out this example: +- **Dark mode and css variables:** React Flow now comes with a built-in dark mode, that can be toggled by using the new `colorMode` prop (”light”, “dark” or “system”): [codesandbox](https://codesandbox.io/p/sandbox/reactflow-dark-mode-256l99) + - Details: With this version we want to make it easier to switch between dark and light modes and give you a better starting point for dark flows. If you pass colorMode=”dark”, we add the class name “dark” to the wrapper and use it to adjust the styling. To make the implementation for this new feature easier on our ends, we switched to CSS variables for most of the styles. These variables can also be used in user land to customize a flow. + +### More features and updates + +There is more! Besides the new main features, we added some minor things that were on our list for a long time. We also started to use TS docs for better docs. We already started to add some docs for some types and hooks which should improve the developer experience. + +- **`useConnection` hook:** This hook makes it possible to handle an ongoing connection. For example, you can use it for colorizing handles. +- **`onDelete` handler**: We added a combined handler for `onDeleteNodes` and `onDeleteEdges` to make it easier to react to deletions. +- **`isValidConnection` prop:** This makes it possible to implement one validation function for all connections. It also gets called for programatically added edges. +- **Controlled `viewport`:** This is definitely an advanced feature. Possible use cases are to animate the viewport or round the transform for lower res screens for example. This features brings two new props: `viewport` and `onViewportChange`. +- **`ViewportPortal` component:** This makes it possible to render elements in the viewport without the need to implement a custom node. +- **Background component**: add `patternClassName` to be able to style the background pattern by using a class name. This is useful if you want to style the background pattern with Tailwind for example. +- **`onMove` callback** gets triggered for library-invoked viewport updates (like fitView or zoom-in) +- **`deleteElements`** now returns deleted nodes and deleted edges +- add **`origin` attribute** for nodes +- add **`selectable` attribute** for edges +- Correct types for `BezierEdge`, `StepEdge`, `SmoothStepEdge` and `StraightEdge` components +- New edges created by the library only have `sourceHandle` and `targetHandle` attributes when those attributes are set. (We used to pass `sourceHandle: null` and `targetHandle: null`) +- Edges do not mount/unmount when their z-index change + +### Internal changes + +These changes are not really user-facing, but it could be important for folks who are working with the React Flow store: + +- The biggest internal change is that we created a new package **@xyflow/system with framework agnostic helpers** that can be used be React Flow and Svelte Flow + - **XYDrag** for handling dragging node(s) and selection + - **XYPanZoom** for controlling the viewport panning and zooming + - **XYHandle** for managing new connections +- We replaced the `nodeInternals` map with a `nodes` array. We added a new `nodeLookup` map that serves as a lookup, but we are not creating a new map object on any change so it’s really only useful as a lookup. +- We removed `connectionNodeId`, `connectionHandleId`, `connectionHandleType` from the store and added `connectionStartHandle.nodeId`, `connectionStartHandle.handleId`, … +- add `data-id` to edges + + +__With v12 the `reactflow` package was renamed to `@xyflow/react` - you can find the v11 source and the [`reactflow` changelog](https://github.com/xyflow/xyflow/blob/v11/packages/reactflow/CHANGELOG.md) on the v11 branch.__ diff --git a/packages/react/package.json b/packages/react/package.json index 1c3194d5..fbc80b3b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/react", - "version": "12.0.0", + "version": "12.0.0-next.1", "description": "React Flow - A highly customizable React library for building node-based editors and interactive flow charts.", "keywords": [ "react", @@ -16,7 +16,7 @@ ], "source": "src/index.ts", "main": "dist/umd/index.js", - "module": "dist/esm/index.mjs", + "module": "dist/esm/index.js", "types": "dist/esm/index.d.ts", "exports": { ".": { @@ -36,8 +36,8 @@ }, "repository": { "type": "git", - "url": "https://github.com/wbkd/react-flow.git", - "directory": "packages/core" + "url": "https://github.com/xyflow/xyflow.git", + "directory": "packages/react" }, "scripts": { "dev": "concurrently \"rollup --config node:@xyflow/rollup-config --watch\" pnpm:css-watch", @@ -51,12 +51,10 @@ "@types/d3": "^7.4.0", "@types/d3-drag": "^3.0.1", "@types/d3-selection": "^3.0.3", - "@types/d3-zoom": "^3.0.1", "@xyflow/system": "workspace:*", "classcat": "^5.0.3", "d3-drag": "^3.0.0", "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", "zustand": "^4.4.0" }, "peerDependencies": { @@ -73,7 +71,7 @@ "autoprefixer": "^10.4.15", "cssnano": "^6.0.1", "postcss": "^8.4.21", - "postcss-cli": "^10.1.0", + "postcss-cli": "^11.0.0", "postcss-combine-duplicated-selectors": "^10.0.3", "postcss-import": "^15.1.0", "postcss-nested": "^6.0.0", @@ -85,11 +83,10 @@ "globals": { "classcat": "cc", "d3-selection": "d3Selection", - "d3-zoom": "d3Zoom", "d3-drag": "d3Drag", "zustand": "zustand", "zustand/shallow": "zustandShallow" }, - "name": "ReactFlowCore" + "name": "ReactFlow" } } diff --git a/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx b/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx index 80ac09be..71cce50b 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx @@ -1,21 +1,18 @@ /* 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.computed?.width || node.width) && (node.computed?.height || node.height) - ); +const selectorNodeIds = (s: ReactFlowState) => s.nodes.map((node) => node.id); const getAttrFunction = (func: any): GetMiniMapNodeAttribute => (func instanceof Function ? func : () => func); function MiniMapNodes({ @@ -28,8 +25,8 @@ function MiniMapNodes({ // 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); @@ -39,33 +36,85 @@ function MiniMapNodes({ return ( <> - {nodes.map((node) => { - const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute; - const color = nodeColor === undefined ? undefined : nodeColorFunc(node); - const strokeColor = nodeStrokeColor === undefined ? undefined : nodeStrokeColorFunc(node); - - 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, x, y } = useStore((s) => { + const node = s.nodeLookup.get(id); + const { x, y } = getNodePositionWithOrigin(node, node?.origin || nodeOrigin).positionAbsolute; + + return { + node, + x, + y, + }; + }, shallow); + if (!node || node.hidden || !(node.computed?.width || node.width) || !(node.computed?.height || node.height)) { + return null; + } + + 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 5667f7a8..3b24b08d 100644 --- a/packages/react/src/additional-components/MiniMap/types.ts +++ b/packages/react/src/additional-components/MiniMap/types.ts @@ -4,12 +4,12 @@ import type { PanelPosition, XYPosition } from '@xyflow/system'; import type { Node } from '../../types'; -export type GetMiniMapNodeAttribute = (node: Node) => string; +export type GetMiniMapNodeAttribute = (node: NodeType) => string; -export type MiniMapProps = Omit, 'onClick'> & { - nodeColor?: string | GetMiniMapNodeAttribute; - nodeStrokeColor?: string | GetMiniMapNodeAttribute; - nodeClassName?: string | GetMiniMapNodeAttribute; +export type MiniMapProps = Omit, 'onClick'> & { + nodeColor?: string | GetMiniMapNodeAttribute; + nodeStrokeColor?: string | GetMiniMapNodeAttribute; + nodeClassName?: string | GetMiniMapNodeAttribute; nodeBorderRadius?: number; nodeStrokeWidth?: number; nodeComponent?: ComponentType; @@ -18,7 +18,7 @@ export type MiniMapProps = Omit, ' maskStrokeWidth?: number; position?: PanelPosition; onClick?: (event: MouseEvent, position: XYPosition) => void; - onNodeClick?: (event: MouseEvent, node: Node) => void; + onNodeClick?: (event: MouseEvent, node: NodeType) => void; pannable?: boolean; zoomable?: boolean; ariaLabel?: string | null; @@ -27,8 +27,8 @@ export type MiniMapProps = Omit, ' offsetScale?: number; }; -export type MiniMapNodes = Pick< - MiniMapProps, +export type MiniMapNodes = Pick< + MiniMapProps, 'nodeColor' | 'nodeStrokeColor' | 'nodeClassName' | 'nodeBorderRadius' | 'nodeStrokeWidth' | 'nodeComponent' > & { onClick?: (event: MouseEvent, nodeId: string) => void; diff --git a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx index b7028a76..6f4ad13c 100644 --- a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx +++ b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx @@ -9,16 +9,20 @@ import { useNodeId } from '../../contexts/NodeIdContext'; import NodeToolbarPortal from './NodeToolbarPortal'; import { NodeToolbarProps } from './types'; -const nodeEqualityFn = (a: Node | undefined, b: Node | undefined) => - a?.computed?.positionAbsolute?.x === b?.computed?.positionAbsolute?.x && - a?.computed?.positionAbsolute?.y === b?.computed?.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) => ({ diff --git a/packages/react/src/components/EdgeWrapper/EdgeUpdateAnchors.tsx b/packages/react/src/components/EdgeWrapper/EdgeUpdateAnchors.tsx new file mode 100644 index 00000000..8f6ddfe3 --- /dev/null +++ b/packages/react/src/components/EdgeWrapper/EdgeUpdateAnchors.tsx @@ -0,0 +1,137 @@ +// Updatable edges have a anchors around their handles to update the edge. +import { XYHandle, type Connection, EdgePosition } from '@xyflow/system'; + +import { EdgeAnchor } from '../Edges/EdgeAnchor'; +import type { EdgeWrapperProps, Edge } from '../../types/edges'; +import { useStoreApi } from '../../hooks/useStore'; + +type EdgeUpdateAnchorsProps = { + edge: Edge; + isUpdatable: boolean | 'source' | 'target'; + edgeUpdaterRadius: EdgeWrapperProps['edgeUpdaterRadius']; + sourceHandleId: EdgeWrapperProps['sourceHandleId']; + targetHandleId: EdgeWrapperProps['targetHandleId']; + onEdgeUpdate: EdgeWrapperProps['onEdgeUpdate']; + onEdgeUpdateStart: EdgeWrapperProps['onEdgeUpdateStart']; + onEdgeUpdateEnd: EdgeWrapperProps['onEdgeUpdateEnd']; + setUpdateHover: (hover: boolean) => void; + setUpdating: (updating: boolean) => void; +} & EdgePosition; + +function EdgeUpdateAnchors({ + isUpdatable, + edgeUpdaterRadius, + edge, + targetHandleId, + sourceHandleId, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + onEdgeUpdate, + onEdgeUpdateStart, + onEdgeUpdateEnd, + setUpdating, + setUpdateHover, +}: EdgeUpdateAnchorsProps) { + const store = useStoreApi(); + + 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, + isValidConnection, + connectionMode, + connectionRadius, + lib, + onConnectStart, + onConnectEnd, + cancelConnection, + nodes, + panBy, + updateConnection, + } = store.getState(); + const nodeId = isSourceHandle ? edge.target : edge.source; + const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null; + const handleType = isSourceHandle ? 'target' : 'source'; + + const isTarget = isSourceHandle; + + 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); + + return ( + <> + {(isUpdatable === 'source' || isUpdatable === true) && ( + + )} + {(isUpdatable === 'target' || isUpdatable === true) && ( + + )} + + ); +} + +export default EdgeUpdateAnchors; diff --git a/packages/react/src/components/EdgeWrapper/index.tsx b/packages/react/src/components/EdgeWrapper/index.tsx new file mode 100644 index 00000000..2a30116b --- /dev/null +++ b/packages/react/src/components/EdgeWrapper/index.tsx @@ -0,0 +1,266 @@ +import { memo, useState, useMemo, useRef, type KeyboardEvent, useCallback } from 'react'; +import cc from 'classcat'; +import { shallow } from 'zustand/shallow'; +import { + getMarkerId, + elementSelectionKeys, + getEdgePosition, + errorMessages, + getElevatedEdgeZIndex, +} from '@xyflow/system'; + +import { useStoreApi, useStore } from '../../hooks/useStore'; +import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions'; +import type { EdgeWrapperProps } from '../../types'; +import { builtinEdgeTypes, nullPosition } from './utils'; +import EdgeUpdateAnchors from './EdgeUpdateAnchors'; + +function EdgeWrapper({ + id, + edgesFocusable, + edgesUpdatable, + elementsSelectable, + onClick, + onDoubleClick, + sourceHandleId, + targetHandleId, + onContextMenu, + onMouseEnter, + onMouseMove, + onMouseLeave, + edgeUpdaterRadius, + onEdgeUpdate, + onEdgeUpdateStart, + onEdgeUpdateEnd, + rfId, + edgeTypes, + noPanClassName, + onError, +}: EdgeWrapperProps): JSX.Element | null { + let edge = useStore((s) => s.edgeLookup.get(id)!); + const defaultEdgeOptions = useStore((s) => s.defaultEdgeOptions); + edge = defaultEdgeOptions ? { ...defaultEdgeOptions, ...edge } : edge; + + let edgeType = edge.type || 'default'; + let EdgeComponent = edgeTypes?.[edgeType] || builtinEdgeTypes[edgeType]; + + if (EdgeComponent === undefined) { + onError?.('011', errorMessages['error011'](edgeType)); + edgeType = 'default'; + EdgeComponent = builtinEdgeTypes.default; + } + + const isFocusable = !!(edge.focusable || (edgesFocusable && typeof edge.focusable === 'undefined')); + const isUpdatable = + typeof onEdgeUpdate !== 'undefined' && + (edge.updatable || (edgesUpdatable && typeof edge.updatable === 'undefined')); + const isSelectable = !!(edge.selectable || (elementsSelectable && typeof edge.selectable === 'undefined')); + + const edgeRef = useRef(null); + const [updateHover, setUpdateHover] = useState(false); + const [updating, setUpdating] = useState(false); + const store = useStoreApi(); + + const { zIndex, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = useStore( + useCallback( + (store) => { + const sourceNode = store.nodeLookup.get(edge.source); + const targetNode = store.nodeLookup.get(edge.target); + + if (!sourceNode || !targetNode) { + return { + zIndex: edge.zIndex, + ...nullPosition, + }; + } + + const edgePosition = getEdgePosition({ + id, + sourceNode, + targetNode, + sourceHandle: sourceHandleId || null, + targetHandle: targetHandleId || null, + connectionMode: store.connectionMode, + onError, + }); + + const zIndex = getElevatedEdgeZIndex({ + selected: edge.selected, + zIndex: edge.zIndex, + sourceNode, + targetNode, + elevateOnSelect: store.elevateEdgesOnSelect, + }); + + return { + zIndex, + ...(edgePosition || nullPosition), + }; + }, + [edge.source, edge.target, edge.selected, edge.zIndex] + ), + shallow + ); + + const markerStartUrl = useMemo( + () => (edge.markerStart ? `url(#${getMarkerId(edge.markerStart, rfId)})` : undefined), + [edge.markerStart, rfId] + ); + + const markerEndUrl = useMemo( + () => (edge.markerEnd ? `url(#${getMarkerId(edge.markerEnd, rfId)})` : undefined), + [edge.markerEnd, rfId] + ); + + if (edge.hidden || !sourceX || !sourceY || !targetX || !targetY) { + return null; + } + + const onEdgeClick = (event: React.MouseEvent): void => { + const { addSelectedEdges, unselectNodesAndEdges, multiSelectionActive } = store.getState(); + + 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 onEdgeDoubleClick = onDoubleClick + ? (event: React.MouseEvent) => { + onDoubleClick(event, { ...edge }); + } + : undefined; + const onEdgeContextMenu = onContextMenu + ? (event: React.MouseEvent) => { + onContextMenu(event, { ...edge }); + } + : undefined; + const onEdgeMouseEnter = onMouseEnter + ? (event: React.MouseEvent) => { + onMouseEnter(event, { ...edge }); + } + : undefined; + const onEdgeMouseMove = onMouseMove + ? (event: React.MouseEvent) => { + onMouseMove(event, { ...edge }); + } + : undefined; + const onEdgeMouseLeave = onMouseLeave + ? (event: React.MouseEvent) => { + onMouseLeave(event, { ...edge }); + } + : undefined; + + const onKeyDown = (event: KeyboardEvent) => { + if (elementSelectionKeys.includes(event.key) && isSelectable) { + const { unselectNodesAndEdges, addSelectedEdges } = store.getState(); + const unselect = event.key === 'Escape'; + + if (unselect) { + edgeRef.current?.blur(); + unselectNodesAndEdges({ edges: [edge] }); + } else { + addSelectedEdges([id]); + } + } + }; + + return ( + + + {!updating && ( + + )} + {isUpdatable && ( + + )} + + + ); +} + +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..0ac2a2e8 --- /dev/null +++ b/packages/react/src/components/EdgeWrapper/utils.ts @@ -0,0 +1,26 @@ +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, +}; + +export const nullPosition = { + sourceX: null, + sourceY: null, + targetX: null, + targetY: null, + sourcePosition: null, + targetPosition: null, +}; diff --git a/packages/react/src/components/Edges/BaseEdge.tsx b/packages/react/src/components/Edges/BaseEdge.tsx index 91cfd58a..ae14b472 100644 --- a/packages/react/src/components/Edges/BaseEdge.tsx +++ b/packages/react/src/components/Edges/BaseEdge.tsx @@ -2,6 +2,7 @@ import { isNumeric } from '@xyflow/system'; import type { BaseEdgeProps } from '../../types'; import EdgeText from './EdgeText'; +import classcat from 'classcat'; const BaseEdge = ({ id, @@ -17,6 +18,7 @@ const BaseEdge = ({ style, markerEnd, markerStart, + className, interactionWidth = 20, }: BaseEdgeProps) => { return ( @@ -26,7 +28,7 @@ const BaseEdge = ({ style={style} d={path} fill="none" - className="react-flow__edge-path" + className={classcat(['react-flow__edge-path', className])} markerEnd={markerEnd} markerStart={markerStart} /> diff --git a/packages/react/src/components/Edges/EdgeText.tsx b/packages/react/src/components/Edges/EdgeText.tsx index 7926e81d..8cc5c388 100644 --- a/packages/react/src/components/Edges/EdgeText.tsx +++ b/packages/react/src/components/Edges/EdgeText.tsx @@ -1,4 +1,4 @@ -import { memo, useRef, useState, useEffect, type FC, type PropsWithChildren } from 'react'; +import { memo, useState, type FC, type PropsWithChildren, useCallback } from 'react'; import cc from 'classcat'; import type { Rect } from '@xyflow/system'; @@ -17,22 +17,21 @@ const EdgeText: FC> = ({ className, ...rest }) => { - const edgeRef = useRef(null); - const [edgeTextBbox, setEdgeTextBbox] = useState({ x: 0, y: 0, width: 0, height: 0 }); + const [edgeTextBbox, setEdgeTextBbox] = useState({ x: 1, y: 0, width: 0, height: 0 }); const edgeTextClasses = cc(['react-flow__edge-textwrapper', className]); - useEffect(() => { - if (edgeRef.current) { - const textBbox = edgeRef.current.getBBox(); + const onEdgeTextRefChange = useCallback((edgeRef: SVGTextElement) => { + if (edgeRef === null) return; - setEdgeTextBbox({ - x: textBbox.x, - y: textBbox.y, - width: textBbox.width, - height: textBbox.height, - }); - } - }, [label]); + const textBbox = edgeRef.getBBox(); + + setEdgeTextBbox({ + x: textBbox.x, + y: textBbox.y, + width: textBbox.width, + height: textBbox.height, + }); + }, []); if (typeof label === 'undefined' || !label) { return null; @@ -57,7 +56,13 @@ const EdgeText: FC> = ({ ry={labelBgBorderRadius} /> )} - + {label} {children} diff --git a/packages/react/src/components/Edges/utils.ts b/packages/react/src/components/Edges/utils.ts deleted file mode 100644 index a51b7613..00000000 --- a/packages/react/src/components/Edges/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { MouseEvent as ReactMouseEvent } from 'react'; -import type { StoreApi } from 'zustand'; - -import type { Edge, ReactFlowState } from '../../types'; - -export function getMouseHandler( - id: string, - getState: StoreApi['getState'], - handler?: (event: ReactMouseEvent, edge: Edge) => void -) { - return handler === undefined - ? handler - : (event: ReactMouseEvent) => { - const edge = getState().edges.find((e) => e.id === id); - - if (edge) { - handler(event, { ...edge }); - } - }; -} diff --git a/packages/react/src/components/Edges/wrapEdge.tsx b/packages/react/src/components/Edges/wrapEdge.tsx deleted file mode 100644 index b0a876b0..00000000 --- a/packages/react/src/components/Edges/wrapEdge.tsx +++ /dev/null @@ -1,290 +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, - 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/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx new file mode 100644 index 00000000..cc593439 --- /dev/null +++ b/packages/react/src/components/NodeWrapper/index.tsx @@ -0,0 +1,264 @@ +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'; +import { shallow } from 'zustand/shallow'; + +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, positionAbsoluteX, positionAbsoluteY, zIndex, isParent } = useStore((s) => { + const node = s.nodeLookup.get(id)!; + + const positionAbsolute = nodeExtent + ? clampPosition(node.computed?.positionAbsolute, nodeExtent) + : node.computed?.positionAbsolute || { x: 0, y: 0 }; + + return { + node, + // we are mutating positionAbsolute, z and isParent attributes for sub flows + // so we we need to force a re-render when some change + positionAbsoluteX: positionAbsolute.x, + positionAbsoluteY: positionAbsolute.y, + zIndex: node[internalsSymbol]?.z ?? 0, + isParent: !!node[internalsSymbol]?.isParent, + }; + }, shallow); + + 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 positionAbsoluteOrigin = getPositionWithOrigin({ + x: positionAbsoluteX, + y: positionAbsoluteY, + width: computedWidth ?? width ?? 0, + height: computedHeight ?? height ?? 0, + origin: node.origin || nodeOrigin, + }); + const initialized = (!!computedWidth && !!computedHeight) || (!!width && !!height); + const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave; + + const onMouseEnterHandler = onMouseEnter ? (event: MouseEvent) => onMouseEnter(event, { ...node }) : undefined; + const onMouseMoveHandler = onMouseMove ? (event: MouseEvent) => onMouseMove(event, { ...node }) : undefined; + const onMouseLeaveHandler = onMouseLeave ? (event: MouseEvent) => onMouseLeave(event, { ...node }) : undefined; + const onContextMenuHandler = onContextMenu ? (event: MouseEvent) => onContextMenu(event, { ...node }) : undefined; + const onDoubleClickHandler = onDoubleClick ? (event: MouseEvent) => onDoubleClick(event, { ...node }) : undefined; + + 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: ${~~positionAbsoluteX}, y: ${~~positionAbsoluteY}`, + }); + + 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 cc1537d1..00000000 --- a/packages/react/src/components/Nodes/wrapNode.tsx +++ /dev/null @@ -1,232 +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, - positionAbsolute, - width, - height, - }: 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..344c718f 100644 --- a/packages/react/src/components/StoreUpdater/index.tsx +++ b/packages/react/src/components/StoreUpdater/index.tsx @@ -3,67 +3,75 @@ * 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 { infiniteExtent, 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'; +import { initNodeOrigin } from '../../container/ReactFlow'; -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', + 'elevateEdgesOnSelect', + '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', + 'onBeforeDelete', +] 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 +84,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 +98,55 @@ 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>({ + // these are values that are also passed directly to other components + // than the StoreUpdater. We can reduce the number of setStore calls + // by setting the same values here as prev fields. + translateExtent: infiniteExtent, + nodeOrigin: initNodeOrigin, + minZoom: 0.5, + maxZoom: 2, + elementsSelectable: true, + noPanClassName: 'nopan', + rfId: '1', + }); - 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/components/ViewportPortal/index.tsx b/packages/react/src/components/ViewportPortal/index.tsx new file mode 100644 index 00000000..4195a608 --- /dev/null +++ b/packages/react/src/components/ViewportPortal/index.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +import { useStore } from '../../hooks/useStore'; +import type { ReactFlowState } from '../../types'; + +const selector = (s: ReactFlowState) => s.domNode?.querySelector('.react-flow__viewport-portal'); + +function ViewportPortal({ children }: { children: ReactNode }) { + const viewPortalDiv = useStore(selector); + + if (!viewPortalDiv) { + return null; + } + + return createPortal(children, viewPortalDiv); +} + +export default ViewportPortal; diff --git a/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx b/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx index 8a0524ac..a4696e5b 100644 --- a/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx +++ b/packages/react/src/container/EdgeRenderer/MarkerDefinitions.tsx @@ -51,32 +51,38 @@ const markerSelector = return markers; }; +const markersEqual = (a: MarkerProps[], b: MarkerProps[]) => + // the id includes all marker options, so we just need to look at that part of the marker + !(a.length !== b.length || a.some((m, i) => m.id !== b[i].id)); + // when you have multiple flows on a page and you hide the first one, the other ones have no markers anymore // when they do have markers with the same ids. To prevent this the user can pass a unique id to the react flow wrapper // that we can then use for creating our unique marker ids const MarkerDefinitions = ({ defaultColor, rfId }: MarkerDefinitionsProps) => { - const markers = useStore( - useCallback(markerSelector({ defaultColor, rfId }), [defaultColor, rfId]), - // the id includes all marker options, so we just need to look at that part of the marker - (a, b) => !(a.length !== b.length || a.some((m, i) => m.id !== b[i].id)) - ); + const markers = useStore(useCallback(markerSelector({ defaultColor, rfId }), [defaultColor, rfId]), markersEqual); + + if (!markers.length) { + return null; + } return ( - - {markers.map((marker: MarkerProps) => ( - - ))} - + + + {markers.map((marker: MarkerProps) => ( + + ))} + + ); }; diff --git a/packages/react/src/container/EdgeRenderer/index.tsx b/packages/react/src/container/EdgeRenderer/index.tsx index 24825fe6..9c289c87 100644 --- a/packages/react/src/container/EdgeRenderer/index.tsx +++ b/packages/react/src/container/EdgeRenderer/index.tsx @@ -1,13 +1,12 @@ 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 useVisibleEdgeIds from '../../hooks/useVisibleEdgeIds'; import MarkerDefinitions from './MarkerDefinitions'; import { GraphViewProps } from '../GraphView'; -import type { EdgeTypesWrapped, ReactFlowState } from '../../types'; +import EdgeWrapper from '../../components/EdgeWrapper'; +import type { ReactFlowState } from '../../types'; type EdgeRendererProps = Pick< GraphViewProps, @@ -24,12 +23,10 @@ type EdgeRendererProps = Pick< | 'onEdgeUpdateEnd' | 'edgeUpdaterRadius' | 'noPanClassName' - | 'elevateEdgesOnSelect' | 'rfId' | 'disableKeyboardA11y' + | 'edgeTypes' > & { - edgeTypes: EdgeTypesWrapped; - elevateEdgesOnSelect: boolean; children: ReactNode; }; @@ -46,7 +43,6 @@ const selector = (s: ReactFlowState) => ({ const EdgeRenderer = ({ defaultMarkerColor, onlyRenderVisibleElements, - elevateEdgesOnSelect, rfId, edgeTypes, noPanClassName, @@ -63,82 +59,39 @@ const EdgeRenderer = ({ children, }: EdgeRendererProps) => { const { edgesFocusable, edgesUpdatable, elementsSelectable, onError } = useStore(selector, shallow); - // we are grouping edges by zIndex here in order to be able to render them in the correct order - // each zIndex gets its own svg element - const edgeTree = useVisibleEdges(onlyRenderVisibleElements, elevateEdgesOnSelect); + const edgeIds = useVisibleEdgeIds(onlyRenderVisibleElements); return ( - <> - {edgeTree.map(({ level, edges, isMaxLevel }) => ( - - {isMaxLevel && } - <> - {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' && - (edge.updatable || (edgesUpdatable && typeof edge.updatable === 'undefined')); - 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 433bb0f0..00000000 --- a/packages/react/src/container/EdgeRenderer/utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ComponentType } from 'react'; - -import { - BezierEdgeInternal, - SmoothStepEdgeInternal, - StepEdgeInternal, - StraightEdgeInternal, - SimpleBezierEdgeInternal, -} 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 || BezierEdgeInternal) as ComponentType), - straight: wrapEdge((edgeTypes.bezier || StraightEdgeInternal) as ComponentType), - step: wrapEdge((edgeTypes.step || StepEdgeInternal) as ComponentType), - smoothstep: wrapEdge((edgeTypes.step || SmoothStepEdgeInternal) as ComponentType), - simplebezier: wrapEdge((edgeTypes.simplebezier || SimpleBezierEdgeInternal) 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] || BezierEdgeInternal) 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..e311bc7f 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' | 'elevateEdgesOnSelect' > & Required< Pick< ReactFlowProps, - | 'nodeTypes' - | 'edgeTypes' | 'selectionKeyCode' | 'deleteKeyCode' | 'multiSelectionKeyCode' @@ -100,7 +96,6 @@ const GraphView = ({ noDragClassName, noWheelClassName, noPanClassName, - elevateEdgesOnSelect, disableKeyboardA11y, nodeOrigin, nodeExtent, @@ -108,8 +103,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 +149,7 @@ const GraphView = ({ > @@ -180,9 +174,9 @@ 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 73808da1..e61b8bc5 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/useVisibleNodeIds'; 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,127 +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.computed?.positionAbsolute, props.nodeExtent) - : node.computed?.positionAbsolute; - - const posX = clampedPosition?.x ?? 0; - const posY = clampedPosition?.y ?? 0; - const posOrigin = getPositionWithOrigin({ - x: posX, - y: posY, - width: node.computed?.width ?? node.width ?? 0, - height: node.computed?.height ?? node.height ?? 0, - origin: node.origin || props.nodeOrigin, - }); - const initialized = (!!node.computed?.width && !!node.computed?.height) || (!!node.width && !!node.height); - + {nodeIds.map((nodeId) => { return ( -