UNPKG

@xyflow/react

Version:

React Flow - A highly customizable React library for building node-based editors and interactive flow charts.

1,204 lines (1,187 loc) 218 kB
"use client" import { jsxs, Fragment, jsx } from 'react/jsx-runtime'; import { createContext, useContext, useMemo, forwardRef, useEffect, useRef, useState, useLayoutEffect, useCallback, memo } from 'react'; import cc from 'classcat'; import { errorMessages, mergeAriaLabelConfig, infiniteExtent, isInputDOMNode, getViewportForBounds, pointToRendererPoint, rendererPointToPoint, isNodeBase, isEdgeBase, getElementsToRemove, isRectObject, nodeToRect, getOverlappingArea, getNodesBounds, withResolvers, evaluateAbsolutePosition, getDimensions, XYPanZoom, PanOnScrollMode, SelectionMode, getEventPosition, getNodesInside, areSetsEqual, XYDrag, snapPosition, calculateNodePosition, Position, ConnectionMode, isMouseEvent, XYHandle, getHostForElement, addEdge, getInternalNodesBounds, isNumeric, nodeHasDimensions, getNodeDimensions, elementSelectionKeys, isEdgeVisible, MarkerType, createMarkerIds, getBezierEdgeCenter, getSmoothStepPath, getStraightPath, getBezierPath, getEdgePosition, getElevatedEdgeZIndex, getMarkerId, getConnectionStatus, ConnectionLineType, updateConnectionLookup, adoptUserNodes, initialConnection, devWarn, defaultAriaLabelConfig, updateNodeInternals, updateAbsolutePositions, handleExpandParent, panBy, fitViewport, isMacOs, areConnectionMapsEqual, handleConnectionChange, shallowNodeData, XYMinimap, getBoundsOfRects, ResizeControlVariant, XYResizer, XY_RESIZER_LINE_POSITIONS, XY_RESIZER_HANDLE_POSITIONS, getNodeToolbarTransform } from '@xyflow/system'; export { ConnectionLineType, ConnectionMode, MarkerType, PanOnScrollMode, Position, ResizeControlVariant, SelectionMode, addEdge, getBezierEdgeCenter, getBezierPath, getConnectedEdges, getEdgeCenter, getIncomers, getNodesBounds, getOutgoers, getSmoothStepPath, getStraightPath, getViewportForBounds, reconnectEdge } from '@xyflow/system'; import { useStoreWithEqualityFn, createWithEqualityFn } from 'zustand/traditional'; import { shallow } from 'zustand/shallow'; import { createPortal } from 'react-dom'; const StoreContext = createContext(null); const Provider$1 = StoreContext.Provider; const zustandErrorMessage = errorMessages['error001'](); /** * This hook can be used to subscribe to internal state changes of the React Flow * component. The `useStore` hook is re-exported from the [Zustand](https://github.com/pmndrs/zustand) * state management library, so you should check out their docs for more details. * * @public * @param selector - A selector function that returns a slice of the flow's internal state. * Extracting or transforming just the state you need is a good practice to avoid unnecessary * re-renders. * @param equalityFn - A function to compare the previous and next value. This is incredibly useful * for preventing unnecessary re-renders. Good sensible defaults are using `Object.is` or importing * `zustand/shallow`, but you can be as granular as you like. * @returns The selected state slice. * * @example * ```ts * const nodes = useStore((state) => state.nodes); * ``` * * @remarks This hook should only be used if there is no other way to access the internal * state. For many of the common use cases, there are dedicated hooks available * such as {@link useReactFlow}, {@link useViewport}, etc. */ function useStore(selector, equalityFn) { const store = useContext(StoreContext); if (store === null) { throw new Error(zustandErrorMessage); } return useStoreWithEqualityFn(store, selector, equalityFn); } /** * In some cases, you might need to access the store directly. This hook returns the store object which can be used on demand to access the state or dispatch actions. * * @returns The store object. * @example * ```ts * const store = useStoreApi(); * ``` * * @remarks This hook should only be used if there is no other way to access the internal * state. For many of the common use cases, there are dedicated hooks available * such as {@link useReactFlow}, {@link useViewport}, etc. */ function useStoreApi() { const store = useContext(StoreContext); if (store === null) { throw new Error(zustandErrorMessage); } return useMemo(() => ({ getState: store.getState, setState: store.setState, subscribe: store.subscribe, }), [store]); } const style = { display: 'none' }; const ariaLiveStyle = { position: 'absolute', width: 1, height: 1, margin: -1, border: 0, padding: 0, overflow: 'hidden', clip: 'rect(0px, 0px, 0px, 0px)', clipPath: 'inset(100%)', }; const ARIA_NODE_DESC_KEY = 'react-flow__node-desc'; const ARIA_EDGE_DESC_KEY = 'react-flow__edge-desc'; const ARIA_LIVE_MESSAGE = 'react-flow__aria-live'; const ariaLiveSelector = (s) => s.ariaLiveMessage; const ariaLabelConfigSelector = (s) => s.ariaLabelConfig; function AriaLiveMessage({ rfId }) { const ariaLiveMessage = useStore(ariaLiveSelector); return (jsx("div", { id: `${ARIA_LIVE_MESSAGE}-${rfId}`, "aria-live": "assertive", "aria-atomic": "true", style: ariaLiveStyle, children: ariaLiveMessage })); } function A11yDescriptions({ rfId, disableKeyboardA11y }) { const ariaLabelConfig = useStore(ariaLabelConfigSelector); return (jsxs(Fragment, { children: [jsx("div", { id: `${ARIA_NODE_DESC_KEY}-${rfId}`, style: style, children: disableKeyboardA11y ? ariaLabelConfig['node.a11yDescription.default'] : ariaLabelConfig['node.a11yDescription.keyboardDisabled'] }), jsx("div", { id: `${ARIA_EDGE_DESC_KEY}-${rfId}`, style: style, children: ariaLabelConfig['edge.a11yDescription.default'] }), !disableKeyboardA11y && jsx(AriaLiveMessage, { rfId: rfId })] })); } /** * The `<Panel />` component helps you position content above the viewport. * It is used internally by the [`<MiniMap />`](/api-reference/components/minimap) * and [`<Controls />`](/api-reference/components/controls) components. * * @public * * @example * ```jsx *import { ReactFlow, Background, Panel } from '@xyflow/react'; * *export default function Flow() { * return ( * <ReactFlow nodes={[]} fitView> * <Panel position="top-left">top-left</Panel> * <Panel position="top-center">top-center</Panel> * <Panel position="top-right">top-right</Panel> * <Panel position="bottom-left">bottom-left</Panel> * <Panel position="bottom-center">bottom-center</Panel> * <Panel position="bottom-right">bottom-right</Panel> * </ReactFlow> * ); *} *``` */ const Panel = forwardRef(({ position = 'top-left', children, className, style, ...rest }, ref) => { const positionClasses = `${position}`.split('-'); return (jsx("div", { className: cc(['react-flow__panel', className, ...positionClasses]), style: style, ref: ref, ...rest, children: children })); }); Panel.displayName = 'Panel'; function Attribution({ proOptions, position = 'bottom-right' }) { if (proOptions?.hideAttribution) { return null; } return (jsx(Panel, { position: position, className: "react-flow__attribution", "data-message": "Please only hide this attribution when you are subscribed to React Flow Pro: https://pro.reactflow.dev", children: jsx("a", { href: "https://reactflow.dev", target: "_blank", rel: "noopener noreferrer", "aria-label": "React Flow attribution", children: "React Flow" }) })); } const selector$m = (s) => { const selectedNodes = []; const selectedEdges = []; for (const [, node] of s.nodeLookup) { if (node.selected) { selectedNodes.push(node.internals.userNode); } } for (const [, edge] of s.edgeLookup) { if (edge.selected) { selectedEdges.push(edge); } } return { selectedNodes, selectedEdges }; }; const selectId = (obj) => obj.id; function areEqual(a, b) { return (shallow(a.selectedNodes.map(selectId), b.selectedNodes.map(selectId)) && shallow(a.selectedEdges.map(selectId), b.selectedEdges.map(selectId))); } function SelectionListenerInner({ onSelectionChange, }) { const store = useStoreApi(); const { selectedNodes, selectedEdges } = useStore(selector$m, areEqual); useEffect(() => { const params = { nodes: selectedNodes, edges: selectedEdges }; onSelectionChange?.(params); store.getState().onSelectionChangeHandlers.forEach((fn) => fn(params)); }, [selectedNodes, selectedEdges, onSelectionChange]); return null; } const changeSelector = (s) => !!s.onSelectionChangeHandlers; function SelectionListener({ onSelectionChange, }) { const storeHasSelectionChangeHandlers = useStore(changeSelector); if (onSelectionChange || storeHasSelectionChangeHandlers) { return jsx(SelectionListenerInner, { onSelectionChange: onSelectionChange }); } return null; } const defaultNodeOrigin = [0, 0]; const defaultViewport = { x: 0, y: 0, zoom: 1 }; /* * This component helps us to update the store with the values coming from the user. * 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`). */ // 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', 'autoPanOnNodeFocus', 'nodesConnectable', 'nodesFocusable', 'edgesFocusable', 'edgesReconnectable', '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', 'connectionDragThreshold', 'onBeforeDelete', 'debug', 'autoPanSpeed', 'paneClickDistance', 'ariaLabelConfig', ]; // rfId doesn't exist in ReactFlowProps, but it's one of the fields we want to update const fieldsToTrack = [...reactFlowFieldsToTrack, 'rfId']; const selector$l = (s) => ({ setNodes: s.setNodes, setEdges: s.setEdges, setMinZoom: s.setMinZoom, setMaxZoom: s.setMaxZoom, setTranslateExtent: s.setTranslateExtent, setNodeExtent: s.setNodeExtent, reset: s.reset, setDefaultNodesAndEdges: s.setDefaultNodesAndEdges, setPaneClickDistance: s.setPaneClickDistance, }); const initPrevValues = { /* * 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: defaultNodeOrigin, minZoom: 0.5, maxZoom: 2, elementsSelectable: true, noPanClassName: 'nopan', rfId: '1', paneClickDistance: 0, }; function StoreUpdater(props) { const { setNodes, setEdges, setMinZoom, setMaxZoom, setTranslateExtent, setNodeExtent, reset, setDefaultNodesAndEdges, setPaneClickDistance, } = useStore(selector$l, shallow); const store = useStoreApi(); useEffect(() => { setDefaultNodesAndEdges(props.defaultNodes, props.defaultEdges); return () => { // when we reset the store we also need to reset the previous fields previousFields.current = initPrevValues; reset(); }; }, []); const previousFields = useRef(initPrevValues); 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); else if (fieldName === 'edges') setEdges(fieldValue); else if (fieldName === 'minZoom') setMinZoom(fieldValue); else if (fieldName === 'maxZoom') setMaxZoom(fieldValue); else if (fieldName === 'translateExtent') setTranslateExtent(fieldValue); else if (fieldName === 'nodeExtent') setNodeExtent(fieldValue); else if (fieldName === 'paneClickDistance') setPaneClickDistance(fieldValue); else if (fieldName === 'ariaLabelConfig') store.setState({ ariaLabelConfig: mergeAriaLabelConfig(fieldValue) }); // Renamed fields else if (fieldName === 'fitView') store.setState({ fitViewQueued: fieldValue }); else if (fieldName === 'fitViewOptions') store.setState({ fitViewOptions: fieldValue }); // 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; } function getMediaQuery() { if (typeof window === 'undefined' || !window.matchMedia) { return null; } return window.matchMedia('(prefers-color-scheme: dark)'); } /** * Hook for receiving the current color mode class 'dark' or 'light'. * * @internal * @param colorMode - The color mode to use ('dark', 'light' or 'system') */ function useColorModeClass(colorMode) { const [colorModeClass, setColorModeClass] = useState(colorMode === 'system' ? null : colorMode); useEffect(() => { if (colorMode !== 'system') { setColorModeClass(colorMode); return; } const mediaQuery = getMediaQuery(); const updateColorModeClass = () => setColorModeClass(mediaQuery?.matches ? 'dark' : 'light'); updateColorModeClass(); mediaQuery?.addEventListener('change', updateColorModeClass); return () => { mediaQuery?.removeEventListener('change', updateColorModeClass); }; }, [colorMode]); return colorModeClass !== null ? colorModeClass : getMediaQuery()?.matches ? 'dark' : 'light'; } const defaultDoc = typeof document !== 'undefined' ? document : null; /** * This hook lets you listen for specific key codes and tells you whether they are * currently pressed or not. * * @public * @param options - Options * * @example * ```tsx *import { useKeyPress } from '@xyflow/react'; * *export default function () { * const spacePressed = useKeyPress('Space'); * const cmdAndSPressed = useKeyPress(['Meta+s', 'Strg+s']); * * return ( * <div> * {spacePressed && <p>Space pressed!</p>} * {cmdAndSPressed && <p>Cmd + S pressed!</p>} * </div> * ); *} *``` */ function useKeyPress( /** * The key code (string or array of strings) specifies which key(s) should trigger * an action. * * A **string** can represent: * - A **single key**, e.g. `'a'` * - A **key combination**, using `'+'` to separate keys, e.g. `'a+d'` * * An **array of strings** represents **multiple possible key inputs**. For example, `['a', 'd+s']` * means the user can press either the single key `'a'` or the combination of `'d'` and `'s'`. * @default null */ keyCode = null, options = { target: defaultDoc, actInsideInputWithModifier: true }) { const [keyPressed, setKeyPressed] = useState(false); // we need to remember if a modifier key is pressed in order to track it const modifierPressed = useRef(false); // we need to remember the pressed keys in order to support combinations const pressedKeys = useRef(new Set([])); /* * keyCodes = array with single keys [['a']] or key combinations [['a', 's']] * keysToWatch = array with all keys flattened ['a', 'd', 'ShiftLeft'] * used to check if we store event.code or event.key. When the code is in the list of keysToWatch * we use the code otherwise the key. Explainer: When you press the left "command" key, the code is "MetaLeft" * and the key is "Meta". We want users to be able to pass keys and codes so we assume that the key is meant when * we can't find it in the list of keysToWatch. */ const [keyCodes, keysToWatch] = useMemo(() => { if (keyCode !== null) { const keyCodeArr = Array.isArray(keyCode) ? keyCode : [keyCode]; const keys = keyCodeArr .filter((kc) => typeof kc === 'string') /* * we first replace all '+' with '\n' which we will use to split the keys on * then we replace '\n\n' with '\n+', this way we can also support the combination 'key++' * in the end we simply split on '\n' to get the key array */ .map((kc) => kc.replace('+', '\n').replace('\n\n', '\n+').split('\n')); const keysFlat = keys.reduce((res, item) => res.concat(...item), []); return [keys, keysFlat]; } return [[], []]; }, [keyCode]); useEffect(() => { const target = options?.target ?? defaultDoc; const actInsideInputWithModifier = options?.actInsideInputWithModifier ?? true; if (keyCode !== null) { const downHandler = (event) => { modifierPressed.current = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey; const preventAction = (!modifierPressed.current || (modifierPressed.current && !actInsideInputWithModifier)) && isInputDOMNode(event); if (preventAction) { return false; } const keyOrCode = useKeyOrCode(event.code, keysToWatch); pressedKeys.current.add(event[keyOrCode]); if (isMatchingKey(keyCodes, pressedKeys.current, false)) { const target = (event.composedPath?.()?.[0] || event.target); const isInteractiveElement = target?.nodeName === 'BUTTON' || target?.nodeName === 'A'; if (options.preventDefault !== false && (modifierPressed.current || !isInteractiveElement)) { event.preventDefault(); } setKeyPressed(true); } }; const upHandler = (event) => { const keyOrCode = useKeyOrCode(event.code, keysToWatch); if (isMatchingKey(keyCodes, pressedKeys.current, true)) { setKeyPressed(false); pressedKeys.current.clear(); } else { pressedKeys.current.delete(event[keyOrCode]); } // fix for Mac: when cmd key is pressed, keyup is not triggered for any other key, see: https://stackoverflow.com/questions/27380018/when-cmd-key-is-kept-pressed-keyup-is-not-triggered-for-any-other-key if (event.key === 'Meta') { pressedKeys.current.clear(); } modifierPressed.current = false; }; const resetHandler = () => { pressedKeys.current.clear(); setKeyPressed(false); }; target?.addEventListener('keydown', downHandler); target?.addEventListener('keyup', upHandler); window.addEventListener('blur', resetHandler); window.addEventListener('contextmenu', resetHandler); return () => { target?.removeEventListener('keydown', downHandler); target?.removeEventListener('keyup', upHandler); window.removeEventListener('blur', resetHandler); window.removeEventListener('contextmenu', resetHandler); }; } }, [keyCode, setKeyPressed]); return keyPressed; } // utils function isMatchingKey(keyCodes, pressedKeys, isUp) { return (keyCodes /* * we only want to compare same sizes of keyCode definitions * and pressed keys. When the user specified 'Meta' as a key somewhere * this would also be truthy without this filter when user presses 'Meta' + 'r' */ .filter((keys) => isUp || keys.length === pressedKeys.size) /* * since we want to support multiple possibilities only one of the * combinations need to be part of the pressed keys */ .some((keys) => keys.every((k) => pressedKeys.has(k)))); } function useKeyOrCode(eventCode, keysToWatch) { return keysToWatch.includes(eventCode) ? 'code' : 'key'; } /** * Hook for getting viewport helper functions. * * @internal * @returns viewport helper functions */ const useViewportHelper = () => { const store = useStoreApi(); return useMemo(() => { return { zoomIn: (options) => { const { panZoom } = store.getState(); return panZoom ? panZoom.scaleBy(1.2, { duration: options?.duration }) : Promise.resolve(false); }, zoomOut: (options) => { const { panZoom } = store.getState(); return panZoom ? panZoom.scaleBy(1 / 1.2, { duration: options?.duration }) : Promise.resolve(false); }, zoomTo: (zoomLevel, options) => { const { panZoom } = store.getState(); return panZoom ? panZoom.scaleTo(zoomLevel, { duration: options?.duration }) : Promise.resolve(false); }, getZoom: () => store.getState().transform[2], setViewport: async (viewport, options) => { const { transform: [tX, tY, tZoom], panZoom, } = store.getState(); if (!panZoom) { return Promise.resolve(false); } await panZoom.setViewport({ x: viewport.x ?? tX, y: viewport.y ?? tY, zoom: viewport.zoom ?? tZoom, }, options); return Promise.resolve(true); }, getViewport: () => { const [x, y, zoom] = store.getState().transform; return { x, y, zoom }; }, setCenter: async (x, y, options) => { return store.getState().setCenter(x, y, options); }, fitBounds: async (bounds, options) => { const { width, height, minZoom, maxZoom, panZoom } = store.getState(); const viewport = getViewportForBounds(bounds, width, height, minZoom, maxZoom, options?.padding ?? 0.1); if (!panZoom) { return Promise.resolve(false); } await panZoom.setViewport(viewport, { duration: options?.duration, ease: options?.ease, interpolate: options?.interpolate, }); return Promise.resolve(true); }, screenToFlowPosition: (clientPosition, options = {}) => { const { transform, snapGrid, snapToGrid, domNode } = store.getState(); if (!domNode) { return clientPosition; } const { x: domX, y: domY } = domNode.getBoundingClientRect(); const correctedPosition = { x: clientPosition.x - domX, y: clientPosition.y - domY, }; const _snapGrid = options.snapGrid ?? snapGrid; const _snapToGrid = options.snapToGrid ?? snapToGrid; return pointToRendererPoint(correctedPosition, transform, _snapToGrid, _snapGrid); }, flowToScreenPosition: (flowPosition) => { const { transform, domNode } = store.getState(); if (!domNode) { return flowPosition; } const { x: domX, y: domY } = domNode.getBoundingClientRect(); const rendererPosition = rendererPointToPoint(flowPosition, transform); return { x: rendererPosition.x + domX, y: rendererPosition.y + domY, }; }, }; }, []); }; /* * This function applies changes to nodes or edges that are triggered by React Flow internally. * When you drag a node for example, React Flow will send a position change update. * This function then applies the changes and returns the updated elements. */ function applyChanges(changes, elements) { const updatedElements = []; /* * By storing a map of changes for each element, we can a quick lookup as we * iterate over the elements array! */ const changesMap = new Map(); const addItemChanges = []; for (const change of changes) { if (change.type === 'add') { addItemChanges.push(change); continue; } else if (change.type === 'remove' || change.type === 'replace') { /* * For a 'remove' change we can safely ignore any other changes queued for * the same element, it's going to be removed anyway! */ changesMap.set(change.id, [change]); } else { const elementChanges = changesMap.get(change.id); if (elementChanges) { /* * If we have some changes queued already, we can do a mutable update of * that array and save ourselves some copying. */ elementChanges.push(change); } else { changesMap.set(change.id, [change]); } } } for (const element of elements) { const changes = changesMap.get(element.id); /* * When there are no changes for an element we can just push it unmodified, * no need to copy it. */ if (!changes) { updatedElements.push(element); continue; } // If we have a 'remove' change queued, it'll be the only change in the array if (changes[0].type === 'remove') { continue; } if (changes[0].type === 'replace') { updatedElements.push({ ...changes[0].item }); continue; } /** * For other types of changes, we want to start with a shallow copy of the * object so React knows this element has changed. Sequential changes will * each _mutate_ this object, so there's only ever one copy. */ const updatedElement = { ...element }; for (const change of changes) { applyChange(change, updatedElement); } updatedElements.push(updatedElement); } /* * we need to wait for all changes to be applied before adding new items * to be able to add them at the correct index */ if (addItemChanges.length) { addItemChanges.forEach((change) => { if (change.index !== undefined) { updatedElements.splice(change.index, 0, { ...change.item }); } else { updatedElements.push({ ...change.item }); } }); } return updatedElements; } // Applies a single change to an element. This is a *mutable* update. function applyChange(change, element) { switch (change.type) { case 'select': { element.selected = change.selected; break; } case 'position': { if (typeof change.position !== 'undefined') { element.position = change.position; } if (typeof change.dragging !== 'undefined') { element.dragging = change.dragging; } break; } case 'dimensions': { if (typeof change.dimensions !== 'undefined') { element.measured ??= {}; element.measured.width = change.dimensions.width; element.measured.height = change.dimensions.height; if (change.setAttributes) { if (change.setAttributes === true || change.setAttributes === 'width') { element.width = change.dimensions.width; } if (change.setAttributes === true || change.setAttributes === 'height') { element.height = change.dimensions.height; } } } if (typeof change.resizing === 'boolean') { element.resizing = change.resizing; } break; } } } /** * Drop in function that applies node changes to an array of nodes. * @public * @param changes - Array of changes to apply. * @param nodes - Array of nodes to apply the changes to. * @returns Array of updated nodes. * @example *```tsx *import { useState, useCallback } from 'react'; *import { ReactFlow, applyNodeChanges, type Node, type Edge, type OnNodesChange } from '@xyflow/react'; * *export default function Flow() { * const [nodes, setNodes] = useState<Node[]>([]); * const [edges, setEdges] = useState<Edge[]>([]); * const onNodesChange: OnNodesChange = useCallback( * (changes) => { * setNodes((oldNodes) => applyNodeChanges(changes, oldNodes)); * }, * [setNodes], * ); * * return ( * <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} /> * ); *} *``` * @remarks Various events on the <ReactFlow /> component can produce an {@link NodeChange} * that describes how to update the edges of your flow in some way. * If you don't need any custom behaviour, this util can be used to take an array * of these changes and apply them to your edges. */ function applyNodeChanges(changes, nodes) { return applyChanges(changes, nodes); } /** * Drop in function that applies edge changes to an array of edges. * @public * @param changes - Array of changes to apply. * @param edges - Array of edge to apply the changes to. * @returns Array of updated edges. * @example * ```tsx *import { useState, useCallback } from 'react'; *import { ReactFlow, applyEdgeChanges } from '@xyflow/react'; * *export default function Flow() { * const [nodes, setNodes] = useState([]); * const [edges, setEdges] = useState([]); * const onEdgesChange = useCallback( * (changes) => { * setEdges((oldEdges) => applyEdgeChanges(changes, oldEdges)); * }, * [setEdges], * ); * * return ( * <ReactFlow nodes={nodes} edges={edges} onEdgesChange={onEdgesChange} /> * ); *} *``` * @remarks Various events on the <ReactFlow /> component can produce an {@link EdgeChange} * that describes how to update the edges of your flow in some way. * If you don't need any custom behaviour, this util can be used to take an array * of these changes and apply them to your edges. */ function applyEdgeChanges(changes, edges) { return applyChanges(changes, edges); } function createSelectionChange(id, selected) { return { id, type: 'select', selected, }; } function getSelectionChanges(items, selectedIds = new Set(), mutateItem = false) { const changes = []; for (const [id, item] of items) { const willBeSelected = selectedIds.has(id); // we don't want to set all items to selected=false on the first selection if (!(item.selected === undefined && !willBeSelected) && item.selected !== willBeSelected) { if (mutateItem) { /* * this hack is needed for nodes. When the user dragged a node, it's selected. * When another node gets dragged, we need to deselect the previous one, * in order to have only one selected node at a time - the onNodesChange callback comes too late here :/ */ item.selected = willBeSelected; } changes.push(createSelectionChange(item.id, willBeSelected)); } } return changes; } function getElementsDiffChanges({ items = [], lookup, }) { const changes = []; const itemsLookup = new Map(items.map((item) => [item.id, item])); for (const [index, item] of items.entries()) { const lookupItem = lookup.get(item.id); const storeItem = lookupItem?.internals?.userNode ?? lookupItem; if (storeItem !== undefined && storeItem !== item) { changes.push({ id: item.id, item: item, type: 'replace' }); } if (storeItem === undefined) { changes.push({ item: item, type: 'add', index }); } } for (const [id] of lookup) { const nextNode = itemsLookup.get(id); if (nextNode === undefined) { changes.push({ id, type: 'remove' }); } } return changes; } function elementToRemoveChange(item) { return { id: item.id, type: 'remove', }; } /** * Test whether an object is usable as an [`Node`](/api-reference/types/node). * In TypeScript this is a type guard that will narrow the type of whatever you pass in to * [`Node`](/api-reference/types/node) if it returns `true`. * * @public * @remarks In TypeScript this is a type guard that will narrow the type of whatever you pass in to Node if it returns true * @param element - The element to test. * @returns Tests whether the provided value can be used as a `Node`. If you're using TypeScript, * this function acts as a type guard and will narrow the type of the value to `Node` if it returns * `true`. * * @example * ```js *import { isNode } from '@xyflow/react'; * *if (isNode(node)) { * // ... *} *``` */ const isNode = (element) => isNodeBase(element); /** * Test whether an object is usable as an [`Edge`](/api-reference/types/edge). * In TypeScript this is a type guard that will narrow the type of whatever you pass in to * [`Edge`](/api-reference/types/edge) if it returns `true`. * * @public * @remarks In TypeScript this is a type guard that will narrow the type of whatever you pass in to Edge if it returns true * @param element - The element to test * @returns Tests whether the provided value can be used as an `Edge`. If you're using TypeScript, * this function acts as a type guard and will narrow the type of the value to `Edge` if it returns * `true`. * * @example * ```js *import { isEdge } from '@xyflow/react'; * *if (isEdge(edge)) { * // ... *} *``` */ const isEdge = (element) => isEdgeBase(element); // eslint-disable-next-line @typescript-eslint/no-empty-object-type function fixedForwardRef(render) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return forwardRef(render); } // we need this hook to prevent a warning when using react-flow in SSR const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; /** * This hook returns a queue that can be used to batch updates. * * @param runQueue - a function that gets called when the queue is flushed * @internal * * @returns a Queue object */ function useQueue(runQueue) { /* * Because we're using a ref above, we need some way to let React know when to * actually process the queue. We increment this number any time we mutate the * queue, creating a new state to trigger the layout effect below. * Using a boolean dirty flag here instead would lead to issues related to * automatic batching. (https://github.com/xyflow/xyflow/issues/4779) */ const [serial, setSerial] = useState(BigInt(0)); /* * A reference of all the batched updates to process before the next render. We * want a reference here so multiple synchronous calls to `setNodes` etc can be * batched together. */ const [queue] = useState(() => createQueue(() => setSerial(n => n + BigInt(1)))); /* * Layout effects are guaranteed to run before the next render which means we * shouldn't run into any issues with stale state or weird issues that come from * rendering things one frame later than expected (we used to use `setTimeout`). */ useIsomorphicLayoutEffect(() => { const queueItems = queue.get(); if (queueItems.length) { runQueue(queueItems); queue.reset(); } }, [serial]); return queue; } function createQueue(cb) { let queue = []; return { get: () => queue, reset: () => { queue = []; }, push: (item) => { queue.push(item); cb(); }, }; } const BatchContext = createContext(null); /** * This is a context provider that holds and processes the node and edge update queues * that are needed to handle setNodes, addNodes, setEdges and addEdges. * * @internal */ function BatchProvider({ children, }) { const store = useStoreApi(); const nodeQueueHandler = useCallback((queueItems) => { const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup, fitViewQueued } = store.getState(); /* * This is essentially an `Array.reduce` in imperative clothing. Processing * this queue is a relatively hot path so we'd like to avoid the overhead of * array methods where we can. */ let next = nodes; for (const payload of queueItems) { next = typeof payload === 'function' ? payload(next) : payload; } const changes = getElementsDiffChanges({ items: next, lookup: nodeLookup, }); if (hasDefaultNodes) { setNodes(next); } // We only want to fire onNodesChange if there are changes to the nodes if (changes.length > 0) { onNodesChange?.(changes); } else if (fitViewQueued) { // If there are no changes to the nodes, we still need to call setNodes // to trigger a re-render and fitView. window.requestAnimationFrame(() => { const { fitViewQueued, nodes, setNodes } = store.getState(); if (fitViewQueued) { setNodes(nodes); } }); } }, []); const nodeQueue = useQueue(nodeQueueHandler); const edgeQueueHandler = useCallback((queueItems) => { const { edges = [], setEdges, hasDefaultEdges, onEdgesChange, edgeLookup } = store.getState(); let next = edges; for (const payload of queueItems) { next = typeof payload === 'function' ? payload(next) : payload; } if (hasDefaultEdges) { setEdges(next); } else if (onEdgesChange) { onEdgesChange(getElementsDiffChanges({ items: next, lookup: edgeLookup, })); } }, []); const edgeQueue = useQueue(edgeQueueHandler); const value = useMemo(() => ({ nodeQueue, edgeQueue }), []); return jsx(BatchContext.Provider, { value: value, children: children }); } function useBatchContext() { const batchContext = useContext(BatchContext); if (!batchContext) { throw new Error('useBatchContext must be used within a BatchProvider'); } return batchContext; } const selector$k = (s) => !!s.panZoom; /** * This hook returns a ReactFlowInstance that can be used to update nodes and edges, manipulate the viewport, or query the current state of the flow. * * @public * @example * ```jsx *import { useCallback, useState } from 'react'; *import { useReactFlow } from '@xyflow/react'; * *export function NodeCounter() { * const reactFlow = useReactFlow(); * const [count, setCount] = useState(0); * const countNodes = useCallback(() => { * setCount(reactFlow.getNodes().length); * // you need to pass it as a dependency if you are using it with useEffect or useCallback * // because at the first render, it's not initialized yet and some functions might not work. * }, [reactFlow]); * * return ( * <div> * <button onClick={countNodes}>Update count</button> * <p>There are {count} nodes in the flow.</p> * </div> * ); *} *``` */ function useReactFlow() { const viewportHelper = useViewportHelper(); const store = useStoreApi(); const batchContext = useBatchContext(); const viewportInitialized = useStore(selector$k); const generalHelper = useMemo(() => { const getInternalNode = (id) => store.getState().nodeLookup.get(id); const setNodes = (payload) => { batchContext.nodeQueue.push(payload); }; const setEdges = (payload) => { batchContext.edgeQueue.push(payload); }; const getNodeRect = (node) => { const { nodeLookup, nodeOrigin } = store.getState(); const nodeToUse = isNode(node) ? node : nodeLookup.get(node.id); const position = nodeToUse.parentId ? evaluateAbsolutePosition(nodeToUse.position, nodeToUse.measured, nodeToUse.parentId, nodeLookup, nodeOrigin) : nodeToUse.position; const nodeWithPosition = { ...nodeToUse, position, width: nodeToUse.measured?.width ?? nodeToUse.width, height: nodeToUse.measured?.height ?? nodeToUse.height, }; return nodeToRect(nodeWithPosition); }; const updateNode = (id, nodeUpdate, options = { replace: false }) => { setNodes((prevNodes) => prevNodes.map((node) => { if (node.id === id) { const nextNode = typeof nodeUpdate === 'function' ? nodeUpdate(node) : nodeUpdate; return options.replace && isNode(nextNode) ? nextNode : { ...node, ...nextNode }; } return node; })); }; const updateEdge = (id, edgeUpdate, options = { replace: false }) => { setEdges((prevEdges) => prevEdges.map((edge) => { if (edge.id === id) { const nextEdge = typeof edgeUpdate === 'function' ? edgeUpdate(edge) : edgeUpdate; return options.replace && isEdge(nextEdge) ? nextEdge : { ...edge, ...nextEdge }; } return edge; })); }; return { getNodes: () => store.getState().nodes.map((n) => ({ ...n })), getNode: (id) => getInternalNode(id)?.internals.userNode, getInternalNode, getEdges: () => { const { edges = [] } = store.getState(); return edges.map((e) => ({ ...e })); }, getEdge: (id) => store.getState().edgeLookup.get(id), setNodes, setEdges, addNodes: (payload) => { const newNodes = Array.isArray(payload) ? payload : [payload]; batchContext.nodeQueue.push((nodes) => [...nodes, ...newNodes]); }, addEdges: (payload) => { const newEdges = Array.isArray(payload) ? payload : [payload]; batchContext.edgeQueue.push((edges) => [...edges, ...newEdges]); }, toObject: () => { const { nodes = [], edges = [], transform } = store.getState(); const [x, y, zoom] = transform; return { nodes: nodes.map((n) => ({ ...n })), edges: edges.map((e) => ({ ...e })), viewport: { x, y, zoom, }, }; }, deleteElements: async ({ nodes: nodesToRemove = [], edges: edgesToRemove = [] }) => { const { nodes, edges, onNodesDelete, onEdgesDelete, triggerNodeChanges, triggerEdgeChanges, onDelete, onBeforeDelete, } = store.getState(); const { nodes: matchingNodes, edges: matchingEdges } = await getElementsToRemove({ nodesToRemove, edgesToRemove, nodes, edges, onBeforeDelete, }); const hasMatchingEdges = matchingEdges.length > 0; const hasMatchingNodes = matchingNodes.length > 0; if (hasMatchingEdges) { const edgeChanges = matchingEdges.map(elementToRemoveChange); onEdgesDelete?.(matchingEdges); triggerEdgeChanges(edgeChanges); } if (hasMatchingNodes) { const nodeChanges = matchingNodes.map(elementToRemoveChange); onNodesDelete?.(matchingNodes); triggerNodeChanges(nodeChanges); } if (hasMatchingNodes || hasMatchingEdges) { onDelete?.({ nodes: matchingNodes, edges: matchingEdges }); } return { deletedNodes: matchingNodes, deletedEdges: matchingEdges }; }, getIntersectingNodes: (nodeOrRect, partially = true, nodes) => { const isRect = isRectObject(nodeOrRect); const nodeRect = isRect ? nodeOrRect : getNodeRect(nodeOrRect); const hasNodesOption = nodes !== undefined; if (!nodeRect) { return []; } return (nodes || store.getState().nodes).filter((n) => { const internalNode = store.getState().nodeLookup.get(n.id); if (internalNode && !isRect && (n.id === nodeOrRect.id || !internalNode.internals.positionAbsolute)) { return false; } const currNodeRect = nodeToRect(hasNodesOption ? n : internalNode); const overlappingArea = getOverlappingArea(currNodeRect, nodeRect); const partiallyVisible = partially && overlappingArea > 0; return (partiallyVisible || overlappingArea >= currNodeRect.width * currNodeRect.height || overlappingArea >= nodeRect.width * nodeRect.height); }); }, isNodeIntersecting: (nodeOrRect, area, partially = true) => { const isRect = isRectObject(nodeOrRect); const nodeRect = isRect ? nodeOrRect : getNodeRect(nodeOrRect); if (!nodeRect) { return false; } const overlappingArea = getOverlappingArea(nodeRect, area); const partiallyVisible = partially && overlappingArea > 0; return partiallyVisible || overlappingArea >= nodeRect.width * nodeRect.height; }, updateNode, updateNodeData: (id, dataUpdate, options = { replace: false }) => { updateNode(id, (node) => { const nextData = typeof dataUpdate === 'function' ? dataUpdate(node) : dataUpdate; return options.replace ? { ...node, data: nextData } : { ...node, data: { ...node.data, ...nextData } }; }, options); }, updateEdge, updateEdgeData: (id, dataUpdate, options = { replace: false }) => { updateEdge(id, (edge) => { const nextData = typeof dataUpdate === 'function' ? dataUpdate(edge) : dataUpdate; return options.replace ? { ...edge, data: nextData } : { ...edge, data: { ...edge.data, ...nextData } }; }, options); }, getNodesBounds: (nodes) => { const { nodeLookup, nodeOrigin } = store.getState(); return getNodesBounds(nodes, { nodeLookup, nodeOrigin }); }, getHandleConnections: ({ type, id, nodeId }) => Array.from(store .getState() .connectionLookup.get(`${nodeId}-${type}${id ? `-${id}` : ''}`) ?.values() ?? []), getNodeConnections: ({ type, handleId, nodeId }) => Array.from(store .getState() .connectionLookup.get(`${nodeId}${type ? (handleId ? `-${type}-${handleId}` : `-${type}`) : ''}`) ?.values() ?? []), fitView: async (options) => { // We either create a new Promise or reuse the existing one // Even if fitView is called multiple times in a row, we only end up with a single Promise const fitViewResolver = store.getState().fitViewResolver ?? withResolvers(); // We schedule a fitView by setting fitViewQueued and triggering a setNodes store.setState({ fitViewQueued: true, fitViewOptions: options, fitViewResolver }); batchContext.nodeQueue.push((nodes) => [...nodes]); return fitViewResolver.promise; }, }; }, []); return useMemo(() => { return { ...generalHelper, ...viewportHelper, viewportInitialized, }; }, [viewportInitia