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 2665abfa..d38f5449 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,7 +43,9 @@ import CancelConnection from '../examples/CancelConnection'; import InteractiveMinimap from '../examples/InteractiveMinimap'; import UseOnSelectionChange from '../examples/UseOnSelectionChange'; import NodeToolbar from '../examples/NodeToolbar'; -import useNodesInitialized from '../examples/UseNodesInit'; +import UseNodesInitialized from '../examples/UseNodesInit'; +import UseNodesData from '../examples/UseNodesData'; +import UseHandleConnections from '../examples/UseHandleConnections'; export interface IRoute { name: string; @@ -183,11 +184,6 @@ const routes: IRoute[] = [ path: 'multiflows', component: MultiFlows, }, - { - name: 'Nested Nodes', - path: 'nested-nodes', - component: NestedNodes, - }, { name: 'Node Type Change', path: 'nodetype-change', @@ -261,7 +257,7 @@ const routes: IRoute[] = [ { name: 'useNodesInitialized', path: 'use-nodes-initialized', - component: useNodesInitialized, + component: UseNodesInitialized, }, { name: 'useOnSelectionChange', @@ -273,6 +269,16 @@ const routes: IRoute[] = [ path: 'usereactflow', component: UseReactFlow, }, + { + name: 'useHandleConnections', + path: 'usehandleconnections', + component: UseHandleConnections, + }, + { + name: 'useNodesData', + path: 'usenodesdata', + component: UseNodesData, + }, { name: 'useUpdateNodeInternals', path: 'useupdatenodeinternals', diff --git a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx index aebc8e0e..6857e1ac 100644 --- a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx +++ b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx @@ -1,4 +1,4 @@ -import React, { memo, FC, CSSProperties, useCallback } from 'react'; +import React, { memo, FC, CSSProperties, useCallback, useEffect } from 'react'; import { Handle, Position, NodeProps, Connection, Edge, useOnViewportChange, Viewport } from '@xyflow/react'; const targetHandleStyle: CSSProperties = { background: '#555' }; diff --git a/examples/react/src/examples/EasyConnect/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/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/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 7c320606..c32fca34 100644 --- a/examples/react/src/examples/UpdateNode/index.tsx +++ b/examples/react/src/examples/UpdateNode/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { ReactFlow, Node, Edge, useNodesState, useEdgesState } from '@xyflow/react'; +import { ReactFlow, Node, Edge, useNodesState, useEdgesState, ReactFlowProvider, useReactFlow } from '@xyflow/react'; import styles from './updatenode.module.css'; @@ -13,6 +13,7 @@ const initialEdges: Edge[] = [{ id: 'e1-2', source: '1', target: '2' }]; const UpdateNode = () => { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const { updateNode } = useReactFlow(); const [nodeName, setNodeName] = useState('Node 1'); const [nodeBg, setNodeBg] = useState('#eee'); @@ -23,9 +24,12 @@ const UpdateNode = () => { nds.map((n) => { if (n.id === '1') { // it's important that you create a new object here in order to notify react flow about the change - n.data = { - ...n.data, - label: nodeName, + return { + ...n, + data: { + ...n.data, + label: nodeName, + }, }; } @@ -39,7 +43,10 @@ const UpdateNode = () => { nds.map((n) => { if (n.id === '1') { // it's important that you create a new object here in order to notify react flow about the change - n.style = { ...n.style, backgroundColor: nodeBg }; + return { + ...n, + style: { ...n.style, backgroundColor: nodeBg }, + }; } return n; @@ -51,8 +58,10 @@ const UpdateNode = () => { setNodes((nds) => nds.map((n) => { if (n.id === '1' || n.id === 'e1-2') { - // when you update a simple type you can just update the value - n.hidden = nodeHidden; + return { + ...n, + hidden: nodeHidden, + }; } return n; @@ -80,9 +89,19 @@ const UpdateNode = () => { setNodeHidden(evt.target.checked)} /> + + ); }; -export default UpdateNode; +export default () => ( + + + +); diff --git a/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx new file mode 100644 index 00000000..4301e647 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx @@ -0,0 +1,39 @@ +import { memo, FC, useEffect, useCallback } from 'react'; +import { Handle, Position, NodeProps, useHandleConnections, Connection, HandleComponentProps } from '@xyflow/react'; + +function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) { + const onConnect = useCallback( + (connections: Connection[]) => console.log('onConnect handler, node id:', nodeId, connections), + [nodeId] + ); + + const onDisconnect = useCallback( + (connections: Connection[]) => console.log('onDisconnect handler, node id:', nodeId, connections), + [nodeId] + ); + const connections = useHandleConnections({ + type: handleProps.type, + id: handleProps.id, + onConnect, + onDisconnect, + }); + + useEffect(() => { + console.log('useEffect, node id:', nodeId, handleProps.type, connections); + }, [connections]); + + return ; +} + +const CustomNode: FC = ({ id }) => { + return ( +
+ +
node {id}
+ + +
+ ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx new file mode 100644 index 00000000..6163f108 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx @@ -0,0 +1,41 @@ +import { memo, FC, useEffect, useCallback } from 'react'; +import { Handle, Position, NodeProps, useHandleConnections, Connection, HandleComponentProps } from '@xyflow/react'; + +function CustomHandle({ nodeId, ...handleProps }: HandleComponentProps & { nodeId: string }) { + const onConnect = useCallback( + (connections: Connection[]) => { + console.log('onConnect handler, node id:', nodeId, connections); + }, + [nodeId] + ); + const onDisconnect = useCallback( + (connections: Connection[]) => { + console.log('onDisconnect handler, node id:', nodeId, connections); + }, + [nodeId] + ); + const connections = useHandleConnections({ + type: handleProps.type, + id: handleProps.id, + onConnect, + onDisconnect, + }); + + useEffect(() => { + console.log('useEffect, node id:', nodeId, handleProps.type, connections); + }, [connections]); + + return ; +} + +const CustomNode: FC = ({ id }) => { + return ( +
+ +
node {id}
+ +
+ ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/examples/UseHandleConnections/index.tsx b/examples/react/src/examples/UseHandleConnections/index.tsx new file mode 100644 index 00000000..c6aa4a68 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnections/index.tsx @@ -0,0 +1,118 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + MiniMap, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, +} from '@xyflow/react'; + +import MultiHandleNode from './MultiHandleNode'; +import SingleHandleNode from './SingleHandleNode'; + +const nodeTypes = { + multi: MultiHandleNode, + single: SingleHandleNode, +}; + +const initNodes = [ + { + id: '1', + type: 'single', + data: {}, + position: { x: 0, y: 0 }, + }, + { + id: '2', + type: 'single', + data: {}, + position: { x: 200, y: -100 }, + }, + { + id: '3', + type: 'single', + data: {}, + position: { x: 200, y: 100 }, + }, + + { + id: '4', + type: 'multi', + data: {}, + position: { x: 400, y: 0 }, + }, + { + id: '5', + type: 'multi', + data: {}, + position: { x: 600, y: -100 }, + }, + { + id: '6', + type: 'multi', + data: {}, + position: { x: 600, y: 100 }, + }, +]; + +const initEdges = [ + { + id: 'e1-2', + source: '1', + target: '2', + }, + { + id: 'e1-3', + source: '1', + target: '3', + }, + + { + id: 'e4a-5', + source: '4', + sourceHandle: 'a', + target: '5', + }, + { + id: 'e4b-5', + source: '4', + sourceHandle: 'b', + target: '6', + }, +]; + +const defaultEdgeOptions = { + animated: true, +}; + +const CustomNodeFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + return ( + + + + + + ); +}; + +export default CustomNodeFlow; diff --git a/examples/react/src/examples/UseNodesData/ResultNode.tsx b/examples/react/src/examples/UseNodesData/ResultNode.tsx new file mode 100644 index 00000000..ac9777dd --- /dev/null +++ b/examples/react/src/examples/UseNodesData/ResultNode.tsx @@ -0,0 +1,22 @@ +import { memo } from 'react'; +import { Handle, Position, useHandleConnections, useNodesData } from '@xyflow/react'; + +function ResultNode() { + const connections = useHandleConnections({ + type: 'target', + }); + const nodesData = useNodesData(connections.map((connection) => connection.source)); + + return ( +
+ +
+ incoming texts:{' '} + {nodesData?.filter((nodeData) => nodeData.text !== undefined).map(({ text }, i) =>
{text}
) || + 'none'} +
+
+ ); +} + +export default memo(ResultNode); diff --git a/examples/react/src/examples/UseNodesData/TextNode.tsx b/examples/react/src/examples/UseNodesData/TextNode.tsx new file mode 100644 index 00000000..6ca424e6 --- /dev/null +++ b/examples/react/src/examples/UseNodesData/TextNode.tsx @@ -0,0 +1,20 @@ +import { memo, ChangeEventHandler } from 'react'; +import { Position, NodeProps, Handle, useReactFlow } from '@xyflow/react'; + +function TextNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); + + const onChange: ChangeEventHandler = (evt) => updateNodeData(id, { text: evt.target.value }); + + return ( +
+
node {id}
+
+ +
+ +
+ ); +} + +export default memo(TextNode); diff --git a/examples/react/src/examples/UseNodesData/UppercaseNode.tsx b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx new file mode 100644 index 00000000..699dc37e --- /dev/null +++ b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx @@ -0,0 +1,24 @@ +import { memo, useEffect } from 'react'; +import { Position, NodeProps, useReactFlow, Handle, useHandleConnections, useNodesData } from '@xyflow/react'; + +function UppercaseNode({ id }: NodeProps) { + const { updateNodeData } = useReactFlow(); + const connections = useHandleConnections({ + type: 'target', + }); + const nodeData = useNodesData(connections[0]?.source); + + useEffect(() => { + updateNodeData(id, { text: nodeData?.text.toUpperCase() }); + }, [nodeData]); + + return ( +
+ +
uppercase transform
+ +
+ ); +} + +export default memo(UppercaseNode); diff --git a/examples/react/src/examples/UseNodesData/index.tsx b/examples/react/src/examples/UseNodesData/index.tsx new file mode 100644 index 00000000..7bb1a94e --- /dev/null +++ b/examples/react/src/examples/UseNodesData/index.tsx @@ -0,0 +1,102 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, + Node, + Edge, +} from '@xyflow/react'; + +import TextNode from './TextNode'; +import ResultNode from './ResultNode'; +import UppercaseNode from './UppercaseNode'; + +export type TextNode = Node<{ text: string }, 'text'>; +export type ResultNode = Node<{}, 'result'>; +export type UppercaseNode = Node<{}, 'uppercase'>; +export type MyNode = Node<{ text: string }, 'text'> | Node<{}, 'result'> | Node<{}, 'uppercase'>; + +const nodeTypes = { + text: TextNode, + result: ResultNode, + uppercase: UppercaseNode, +}; + +const initNodes: MyNode[] = [ + { + id: '1', + type: 'text', + data: { + text: 'hello', + }, + position: { x: -100, y: -50 }, + }, + { + id: '1a', + type: 'uppercase', + data: {}, + position: { x: 100, y: 0 }, + }, + + { + id: '2', + type: 'text', + data: { + text: 'world', + }, + position: { x: 0, y: 100 }, + }, + + { + id: '3', + type: 'result', + data: {}, + position: { x: 300, y: 50 }, + }, +]; + +const initEdges: Edge[] = [ + { + id: 'e1-1a', + source: '1', + target: '1a', + }, + { + id: 'e1a-3', + source: '1a', + target: '3', + }, + { + id: 'e2-3', + source: '2', + target: '3', + }, +]; + +const CustomNodeFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + return ( + + + + + ); +}; + +export default CustomNodeFlow; diff --git a/examples/svelte/src/components/Header/Header.svelte b/examples/svelte/src/components/Header/Header.svelte index ad34d289..9d67f049 100644 --- a/examples/svelte/src/components/Header/Header.svelte +++ b/examples/svelte/src/components/Header/Header.svelte @@ -11,6 +11,7 @@ 'drag-n-drop', 'edges', 'figma', + 'handle-connect', 'interaction', 'intersections', 'node-toolbar', @@ -18,6 +19,7 @@ 'stress', 'subflows', 'two-way-viewport', + 'usenodesdata', 'usesvelteflow', 'useupdatenodeinternals', 'validation' diff --git a/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte b/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte index 5556ca80..809ae82c 100644 --- a/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte +++ b/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte @@ -1,6 +1,12 @@ @@ -69,11 +77,11 @@ {edges} fitView fitViewOptions={{ padding: 2 }} - on:connectstart={({ detail: { nodeId } }) => { + onconnectstart={(_, { nodeId }) => { // Memorize the nodeId you start draggin a connection line from a node connectingNodeId = nodeId; }} - on:connectend={handleConnectEnd} + onconnectend={handleConnectEnd} /> diff --git a/examples/svelte/src/routes/examples/customnode/+page.svelte b/examples/svelte/src/routes/examples/customnode/+page.svelte index 123932f9..525b0706 100644 --- a/examples/svelte/src/routes/examples/customnode/+page.svelte +++ b/examples/svelte/src/routes/examples/customnode/+page.svelte @@ -87,7 +87,7 @@ {nodeTypes} style="--xy-background-color: {$bgColor}" fitView - on:connect={onConnect} + onconnect={onConnect} > diff --git a/examples/svelte/src/routes/examples/customnode/CustomNode.svelte b/examples/svelte/src/routes/examples/customnode/CustomNode.svelte index 51ba33ea..ca01be5e 100644 --- a/examples/svelte/src/routes/examples/customnode/CustomNode.svelte +++ b/examples/svelte/src/routes/examples/customnode/CustomNode.svelte @@ -1,6 +1,6 @@
- +
Custom Color Picker Node: {$colorStore}
@@ -20,14 +20,8 @@ on:input={(evt) => colorStore.set(evt.currentTarget.value)} value={$colorStore} /> - - + +
diff --git a/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte b/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte new file mode 100644 index 00000000..7c1e026d --- /dev/null +++ b/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte @@ -0,0 +1,77 @@ + + +
+ +
node {id}
+ +
+ + diff --git a/examples/svelte/src/routes/examples/overview/+page.svelte b/examples/svelte/src/routes/examples/overview/+page.svelte index 68fd4aaa..d8293666 100644 --- a/examples/svelte/src/routes/examples/overview/+page.svelte +++ b/examples/svelte/src/routes/examples/overview/+page.svelte @@ -12,7 +12,8 @@ type EdgeTypes, type Node, type Edge, - ConnectionMode + ConnectionMode, + useSvelteFlow } from '@xyflow/svelte'; import CustomNode from './CustomNode.svelte'; @@ -150,9 +151,9 @@ on:nodemouseenter={(event) => console.log('on node enter', event)} on:nodemouseleave={(event) => console.log('on node leave', event)} on:edgeclick={(event) => console.log('edge click', event)} - on:connectstart={(event) => console.log('on connect start', event)} - on:connect={(event) => console.log('on connect', event)} - on:connectend={(event) => console.log('on connect end', event)} + onconnectstart={(event) => console.log('on connect start', event)} + onconnect={(event) => console.log('on connect', event)} + onconnectend={(event) => console.log('on connect end', event)} on:paneclick={(event) => console.log('on pane click', event)} on:panecontextmenu={(event) => { console.log('on pane contextmenu', event); 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/examples/svelte/src/routes/examples/usenodesdata/+page.svelte b/examples/svelte/src/routes/examples/usenodesdata/+page.svelte new file mode 100644 index 00000000..7b2d2204 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/+page.svelte @@ -0,0 +1,80 @@ + + + + + + + diff --git a/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte new file mode 100644 index 00000000..9dd98173 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte @@ -0,0 +1,37 @@ + + +
+ +
incoming texts:
+ + {#each $nodeData as data} +
{data.text}
+ {/each} +
+ + diff --git a/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte new file mode 100644 index 00000000..c1642da2 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte @@ -0,0 +1,29 @@ + + +
+
node {id}
+
+ updateNodeData(id, { text: evt.currentTarget.value })} + /> +
+ +
+ + diff --git a/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte new file mode 100644 index 00000000..2dcf3bde --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte @@ -0,0 +1,40 @@ + + +
+ +
uppercase transform
+ +
+ + diff --git a/examples/svelte/src/routes/examples/useupdatenodeinternals/CustomNode.svelte b/examples/svelte/src/routes/examples/useupdatenodeinternals/CustomNode.svelte index f4d306ac..fbe9c8d7 100644 --- a/examples/svelte/src/routes/examples/useupdatenodeinternals/CustomNode.svelte +++ b/examples/svelte/src/routes/examples/useupdatenodeinternals/CustomNode.svelte @@ -15,15 +15,9 @@ }; - + {#each Array.from({ length: handleCount }) as handle, i} - + {/each} diff --git a/examples/svelte/src/routes/examples/useupdatenodeinternals/Flow.svelte b/examples/svelte/src/routes/examples/useupdatenodeinternals/Flow.svelte index 13b48cb3..42c76cd0 100644 --- a/examples/svelte/src/routes/examples/useupdatenodeinternals/Flow.svelte +++ b/examples/svelte/src/routes/examples/useupdatenodeinternals/Flow.svelte @@ -6,7 +6,9 @@ Background, BackgroundVariant, MiniMap, - type NodeTypes + type NodeTypes, + useSvelteFlow, + Panel } from '@xyflow/svelte'; import CustomNode from './CustomNode.svelte'; @@ -50,6 +52,12 @@ target: '3' } ]); + + const { updateNode } = useSvelteFlow(); + + const updateNodePosition = () => { + updateNode('1', (node) => ({ position: { x: node.position.x + 10, y: node.position.y } })); + };
@@ -57,6 +65,8 @@ + +
diff --git a/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx b/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx index 80ac09be..162d0b41 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,78 @@ 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 = useStore((s) => s.nodeLookup.get(id)); + if (!node || node.hidden || !(node.computed?.width || node.width) || !(node.computed?.height || node.height)) { + return null; + } + + const positionOrigin = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute; + + return ( + + ); +}); + export default memo(MiniMapNodes); diff --git a/packages/react/src/additional-components/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/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/Handle/index.tsx b/packages/react/src/components/Handle/index.tsx index 29231b71..a2c0101f 100644 --- a/packages/react/src/components/Handle/index.tsx +++ b/packages/react/src/components/Handle/index.tsx @@ -171,7 +171,7 @@ const Handle = forwardRef( lib, }); - if (isValid) { + if (isValid && connection) { onConnectExtended(connection); } diff --git a/packages/react/src/components/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx new file mode 100644 index 00000000..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..948c50fc 100644 --- a/packages/react/src/components/StoreUpdater/index.tsx +++ b/packages/react/src/components/StoreUpdater/index.tsx @@ -3,67 +3,74 @@ * 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', +] 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 +83,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 +97,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/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..217e90c2 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 = ({ > @@ -182,7 +176,7 @@ const GraphView = ({
{ + if (process.env.NODE_ENV === 'development') { + if (updateCount.current > 1) { + store.getState().onError?.('002', errorMessages['error002']()); + } + updateCount.current += 1; + } + }, [nodeOrEdgeTypes]); +} diff --git a/packages/react/src/container/GraphView/utils.ts b/packages/react/src/container/GraphView/utils.ts deleted file mode 100644 index 15dbaafe..00000000 --- a/packages/react/src/container/GraphView/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo, useRef } from 'react'; -import { shallow } from 'zustand/shallow'; -import { errorMessages } from '@xyflow/system'; - -import { CreateEdgeTypes } from '../EdgeRenderer/utils'; -import { CreateNodeTypes } from '../NodeRenderer/utils'; -import type { EdgeTypes, EdgeTypesWrapped, NodeTypes, NodeTypesWrapped } from '../../types'; -import { useStoreApi } from '../../hooks/useStore'; - -export function useNodeOrEdgeTypes(nodeOrEdgeTypes: NodeTypes, createTypes: CreateNodeTypes): NodeTypesWrapped; -export function useNodeOrEdgeTypes(nodeOrEdgeTypes: EdgeTypes, createTypes: CreateEdgeTypes): EdgeTypesWrapped; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function useNodeOrEdgeTypes(nodeOrEdgeTypes: any, createTypes: any): any { - const typesKeysRef = useRef(null); - const store = useStoreApi(); - - const typesParsed = useMemo(() => { - if (process.env.NODE_ENV === 'development') { - const typeKeys = Object.keys(nodeOrEdgeTypes); - - if (shallow(typesKeysRef.current, typeKeys)) { - store.getState().onError?.('002', errorMessages['error002']()); - } - - typesKeysRef.current = typeKeys; - } - return createTypes(nodeOrEdgeTypes); - }, [nodeOrEdgeTypes]); - - return typesParsed; -} diff --git a/packages/react/src/container/NodeRenderer/index.tsx b/packages/react/src/container/NodeRenderer/index.tsx index 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 ( -