diff --git a/.changeset/selfish-nails-sing.md b/.changeset/selfish-nails-sing.md new file mode 100644 index 00000000..9cbc5b81 --- /dev/null +++ b/.changeset/selfish-nails-sing.md @@ -0,0 +1,5 @@ +--- +'@xyflow/react': minor +--- + +Add `experimental_useOnNodesChangeMiddleware` hook diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 812f2e9f..055dc9e1 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -61,6 +61,7 @@ import Redux from '../examples/Redux'; import MovingHandles from '../examples/MovingHandles'; import DetachedHandle from '../examples/DetachedHandle'; import ZIndexMode from '../examples/ZIndexMode'; +import Middlewares from '../examples/Middlewares'; export interface IRoute { name: string; @@ -239,6 +240,11 @@ const routes: IRoute[] = [ path: 'layouting', component: Layouting, }, + { + name: 'Middlewares', + path: 'middlewares', + component: Middlewares, + }, { name: 'Multi setNodes', path: 'multi-setnodes', diff --git a/examples/react/src/examples/Middlewares/RestrictExtent.tsx b/examples/react/src/examples/Middlewares/RestrictExtent.tsx new file mode 100644 index 00000000..7a447414 --- /dev/null +++ b/examples/react/src/examples/Middlewares/RestrictExtent.tsx @@ -0,0 +1,54 @@ +import { NodeChange, experimental_useOnNodesChangeMiddleware } from '@xyflow/react'; +import { useCallback, useState } from 'react'; + +export function RestrictExtent({ + label = 'Restrict Extent', + minX = -Infinity, + minY = -Infinity, + maxX = Infinity, + maxY = Infinity, +}: { + label?: string; + minX?: number; + minY?: number; + maxX?: number; + maxY?: number; +}) { + const [isEnabled, setIsEnabled] = useState(false); + experimental_useOnNodesChangeMiddleware( + useCallback( + (changes: NodeChange[]) => { + if (!isEnabled) return changes; + return changes.map((change) => { + const { type } = change; + if (type === 'position') { + const { position } = change; + if (position) { + position.x = Math.min(Math.max(position.x, minX), maxX); + position.y = Math.min(Math.max(position.y, minY), maxY); + change.position = position; + } + } else if (type === 'add' || type === 'replace') { + const { item } = change; + if (item) { + item.position.x = Math.min(Math.max(item.position.x, minX), maxX); + item.position.y = Math.min(Math.max(item.position.y, minY), maxY); + change.item = item; + } + } + return change; + }); + }, + [minX, minY, maxX, maxY, isEnabled] + ) + ); + + return ( +
+ +
+ ); +} diff --git a/examples/react/src/examples/Middlewares/index.tsx b/examples/react/src/examples/Middlewares/index.tsx new file mode 100644 index 00000000..c7a05770 --- /dev/null +++ b/examples/react/src/examples/Middlewares/index.tsx @@ -0,0 +1,85 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + MiniMap, + Background, + BackgroundVariant, + Controls, + ReactFlowProvider, + Edge, + useReactFlow, + Panel, + useNodesState, + useEdgesState, + addEdge, + Connection, +} from '@xyflow/react'; +import { initialNodes, initialEdges } from '../CancelConnection/data'; +import { RestrictExtent } from './RestrictExtent'; + +const a = { id: 'a', data: { label: 'A' }, position: { x: 250, y: 5 } }; +const b = { id: 'b', data: { label: 'B' }, position: { x: 100, y: 100 } }; +const c = { id: 'c', data: { label: 'C' }, position: { x: 400, y: 100 } }; + +const SetNotesBatchingFlow = () => { + const { setNodes, updateNode } = useReactFlow(); + + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]); + + const triggerMultipleSetNodes = useCallback(() => { + setNodes([a]); + setNodes((nodes) => [...nodes, b]); + setNodes((nodes) => [...nodes, c]); + setNodes((nodes) => + nodes.map((node) => + node.id === 'a' ? { ...node, position: { x: node.position.x + 2000, y: node.position.y + 20 } } : node + ) + ); + }, []); + + const triggerMultipleUpdateNodes = useCallback(() => { + triggerMultipleSetNodes(); + updateNode('a', (a) => ({ position: { x: a.position.x + 20, y: a.position.y + 20 } })); + updateNode('b', (b) => ({ position: { x: b.position.x + 20, y: b.position.y + 20 } })); + updateNode('c', (c) => ({ position: { x: c.position.x + 20, y: c.position.y + 20 } })); + updateNode('a', (a) => ({ data: { ...a.data, label: `A ${Date.now()}` } })); + updateNode('b', (b) => ({ data: { ...b.data, label: `B ${Date.now()}` } })); + updateNode('c', (c) => ({ data: { ...c.data, label: `C ${Date.now()}` } })); + }, []); + + return ( + + + + + + + + + + + + + + ); +}; + +export default function App() { + return ( + + + + ); +} diff --git a/packages/react/src/components/BatchProvider/index.tsx b/packages/react/src/components/BatchProvider/index.tsx index b2ed270f..f6c6436c 100644 --- a/packages/react/src/components/BatchProvider/index.tsx +++ b/packages/react/src/components/BatchProvider/index.tsx @@ -28,7 +28,15 @@ export function BatchProvider(); const nodeQueueHandler = useCallback((queueItems: QueueItem[]) => { - const { nodes = [], setNodes, hasDefaultNodes, onNodesChange, nodeLookup, fitViewQueued } = store.getState(); + const { + nodes = [], + setNodes, + hasDefaultNodes, + onNodesChange, + nodeLookup, + fitViewQueued, + onNodesChangeMiddlewareMap, + } = store.getState(); /* * This is essentially an `Array.reduce` in imperative clothing. Processing @@ -40,11 +48,15 @@ export function BatchProvider[]; + for (const middleware of onNodesChangeMiddlewareMap.values()) { + changes = middleware(changes); + } + if (hasDefaultNodes) { setNodes(next); } diff --git a/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts b/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts new file mode 100644 index 00000000..d79d6773 --- /dev/null +++ b/packages/react/src/hooks/useOnEdgesChangeMiddleware.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +import type { EdgeChange } from '@xyflow/system'; + +import { useStoreApi } from './useStore'; +import type { Edge, Node } from '../types'; + +/** + * Registers a middleware function to transform edge changes. + * + * @public + * @param fn - Middleware function. Should be memoized with useCallback to avoid re-registration. + */ +export function experimental_useOnEdgesChangeMiddleware( + fn: (changes: EdgeChange[]) => EdgeChange[] +) { + const store = useStoreApi(); + const [symbol] = useState(() => Symbol()); + + useEffect(() => { + const { onEdgesChangeMiddlewareMap } = store.getState(); + onEdgesChangeMiddlewareMap.set(symbol, fn); + }, [fn]); + + useEffect(() => { + const { onEdgesChangeMiddlewareMap } = store.getState(); + return () => { + onEdgesChangeMiddlewareMap.delete(symbol); + }; + }, []); +} diff --git a/packages/react/src/hooks/useOnNodesChangeMiddleware.ts b/packages/react/src/hooks/useOnNodesChangeMiddleware.ts new file mode 100644 index 00000000..fe7ea6f6 --- /dev/null +++ b/packages/react/src/hooks/useOnNodesChangeMiddleware.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +import type { NodeChange } from '@xyflow/system'; + +import { useStoreApi } from './useStore'; +import type { Edge, Node } from '../types'; + +/** + * Registers a middleware function to transform node changes. + * + * @public + * @param fn - Middleware function. Should be memoized with useCallback to avoid re-registration. + */ +export function experimental_useOnNodesChangeMiddleware( + fn: (changes: NodeChange[]) => NodeChange[] +) { + const store = useStoreApi(); + const [symbol] = useState(() => Symbol()); + + useEffect(() => { + const { onNodesChangeMiddlewareMap } = store.getState(); + onNodesChangeMiddlewareMap.set(symbol, fn); + }, [fn]); + + useEffect(() => { + const { onNodesChangeMiddlewareMap } = store.getState(); + return () => { + onNodesChangeMiddlewareMap.delete(symbol); + }; + }, []); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 03fe4c6c..79a50440 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,6 +30,9 @@ export { useConnection } from './hooks/useConnection'; export { useInternalNode } from './hooks/useInternalNode'; export { useNodeId } from './contexts/NodeIdContext'; +export { experimental_useOnNodesChangeMiddleware } from './hooks/useOnNodesChangeMiddleware'; +export { experimental_useOnEdgesChangeMiddleware } from './hooks/useOnEdgesChangeMiddleware'; + export { applyNodeChanges, applyEdgeChanges } from './utils/changes'; export { isNode, isEdge } from './utils/general'; diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index e8a72c60..1c5bdd3d 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -192,8 +192,8 @@ const createStore = ({ }, updateNodePositions: (nodeDragItems, dragging = false) => { const parentExpandChildren: ParentExpandChild[] = []; - const changes = []; - const { nodeLookup, triggerNodeChanges, connection, updateConnection } = get(); + let changes = []; + const { nodeLookup, triggerNodeChanges, connection, updateConnection, onNodesChangeMiddlewareMap } = get(); for (const [id, dragItem] of nodeDragItems) { // we are using the nodelookup to be sure to use the current expandParent and parentId value @@ -238,6 +238,10 @@ const createStore = ({ changes.push(...parentExpandChanges); } + for (const middleware of onNodesChangeMiddlewareMap.values()) { + changes = middleware(changes); + } + triggerNodeChanges(changes); }, triggerNodeChanges: (changes) => { diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index e516b533..d90aa8b4 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -150,6 +150,9 @@ const getInitialState = ({ debug: false, ariaLabelConfig: defaultAriaLabelConfig, zIndexMode, + + onNodesChangeMiddlewareMap: new Map(), + onEdgesChangeMiddlewareMap: new Map(), }; }; diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index 1a723405..db881fc2 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -29,8 +29,8 @@ import { type EdgeChange, type ParentLookup, type AriaLabelConfig, - SetCenter, - ZIndexMode, + type SetCenter, + type ZIndexMode, } from '@xyflow/system'; import type { @@ -155,6 +155,8 @@ export type ReactFlowStore[]) => NodeChange[]>; + onEdgesChangeMiddlewareMap: Map[]) => EdgeChange[]>; }; export type ReactFlowActions = {