refactor(packages): change package structure

This commit is contained in:
moklick
2023-06-05 15:40:18 +02:00
parent b2e296901e
commit 8354759f57
332 changed files with 980 additions and 3244 deletions

View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ['@xyflow/eslint-config'],
};

304
packages/react/CHANGELOG.md Normal file
View File

@@ -0,0 +1,304 @@
# @reactflow/core
## 11.7.2
### Patch Changes
- [#3060](https://github.com/wbkd/react-flow/pull/3060) [`70ec97f7`](https://github.com/wbkd/react-flow/commit/70ec97f7daec6d5401215cae3edac04aea88a3ba) - fix useNodes and useEdges bug with infinite re-renderings
- [#3064](https://github.com/wbkd/react-flow/pull/3064) [`d2d1aebc`](https://github.com/wbkd/react-flow/commit/d2d1aebc0f7fea4183406e7d1915b7fcd6995f48) - refactor(useUpdateNodeInternals): only call updateNodeDimensions once
- [#3059](https://github.com/wbkd/react-flow/pull/3059) [`4374459e`](https://github.com/wbkd/react-flow/commit/4374459ef9fec797bbc0407231f09a1acacd245b) - fix useUpdateNodeInternals type
## 11.7.1
### Patch Changes
- [#3043](https://github.com/wbkd/react-flow/pull/3043) [`cf7a7d3d`](https://github.com/wbkd/react-flow/commit/cf7a7d3dad1e73215a72a5dc72e21fd50208cdbb) - handles: handles on top of each other, reduce re-renderings
- [#3046](https://github.com/wbkd/react-flow/pull/3046) [`07b975bb`](https://github.com/wbkd/react-flow/commit/07b975bbee3580249e36a19582213b250f78093c) - base-edge: pass id to base edge path
- [#3007](https://github.com/wbkd/react-flow/pull/3007) [`c80d269b`](https://github.com/wbkd/react-flow/commit/c80d269b85a0054221f4639c328fc36a3befbe70) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - allow array of ids as updateNodeInternals arg
- [#3029](https://github.com/wbkd/react-flow/pull/3029) [`a3fa164c`](https://github.com/wbkd/react-flow/commit/a3fa164c34cc820c79bb031c9fd97b72a3546614) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - autopan: only update nodes when transform change happen
## 11.7.0
Most notable updates:
- Handles: `isConnectableStart` and `isConnectableEnd` props to configure if you can start or end a connection at a certain handle
- Edges: `updatable` option to enable updates for specific edges
- useNodesInitialized: options to configure if hidden nodes should be included (false by default)
### Minor Changes
- [#2960](https://github.com/wbkd/react-flow/pull/2960) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - edges: add `updatable` option
- [#2958](https://github.com/wbkd/react-flow/pull/2958) [`4d97a0ed`](https://github.com/wbkd/react-flow/commit/4d97a0ed168ce643fc0c99fa6b47cf1296d66065) - handles: add `isConnectableStart` and `isConnectableEnd` props
- [#2956](https://github.com/wbkd/react-flow/pull/2956) [`923a54c4`](https://github.com/wbkd/react-flow/commit/923a54c481b90954806202817ba844cfa7203a38) - add options for `useNodesInitialized`, ignore hidden nodes by default
### Patch Changes
- [#2926](https://github.com/wbkd/react-flow/pull/2926) Thanks [@Elringus](https://github.com/Elringus)! - fix non-passive wheel event listener violation
- [#2933](https://github.com/wbkd/react-flow/pull/2933) [`fe8cac0a`](https://github.com/wbkd/react-flow/commit/fe8cac0adb359109e0e9eafe8b9261ba354076bb) - prefix error keys with "error"
- [#2939](https://github.com/wbkd/react-flow/pull/2939) [`4a4ca171`](https://github.com/wbkd/react-flow/commit/4a4ca171955f5c8d58b23e3ad48406f1a21dc402) - add connection result to store
## 11.6.1
### Patch Changes
- Always create new edge object (fixes an issue with Redux toolkit and other immutable helper libs)
## 11.6.0
### Minor Changes
- [#2877](https://github.com/wbkd/react-flow/pull/2877) [`b8886514`](https://github.com/wbkd/react-flow/commit/b88865140c72fa7e92a883498768000cb2cc96a7) - add `isValidConnection` prop for ReactFlow component
- [#2847](https://github.com/wbkd/react-flow/pull/2847) [`16bf89f2`](https://github.com/wbkd/react-flow/commit/16bf89f2b7bbf8449c00d0e2c07c19c3ff6d2533) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - Add option to enable/disable replacing edge id when using `updateEdge`
### Patch Changes
- [#2895](https://github.com/wbkd/react-flow/pull/2895) [`3d5764ca`](https://github.com/wbkd/react-flow/commit/3d5764cac6548984a30cbf85899024e62fd69425) - add data-testid for controls, minimap and background
- [#2894](https://github.com/wbkd/react-flow/pull/2894) [`83fc4675`](https://github.com/wbkd/react-flow/commit/83fc467545527729633e817dbccfe59d0040da4b) - fix(nodes): blur when node gets unselected
- [#2892](https://github.com/wbkd/react-flow/pull/2892) [`5fabd272`](https://github.com/wbkd/react-flow/commit/5fabd2720f6367f75f79a45822d8f675a3b8e1cf) Thanks [@danielgek](https://github.com/danielgek) - track modifier keys on useKeypress
- [#2893](https://github.com/wbkd/react-flow/pull/2893) [`8f080bd5`](https://github.com/wbkd/react-flow/commit/8f080bd5e0e7e6c71f51eee9c9f2bc4b25182861) - fix: check if handle is connectable
## 11.5.5
### Patch Changes
- [#2834](https://github.com/wbkd/react-flow/pull/2834) [`23424ea6`](https://github.com/wbkd/react-flow/commit/23424ea6750f092210f83df17a00c89adb910d96) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - Add `nodes` to fit view options to allow fitting view only around specified set of nodes
- [#2836](https://github.com/wbkd/react-flow/pull/2836) [`959b1114`](https://github.com/wbkd/react-flow/commit/959b111448bba4686040473e46988be9e7befbe6) - Fix: connections for handles with bigger handles than connection radius
- [#2819](https://github.com/wbkd/react-flow/pull/2819) [`0d259b02`](https://github.com/wbkd/react-flow/commit/0d259b028558aab650546f3371a85f3bce45252f) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - Avoid triggering edge update if not using left mouse button
- [#2832](https://github.com/wbkd/react-flow/pull/2832) [`f3de9335`](https://github.com/wbkd/react-flow/commit/f3de9335af6cd96cd77dc77f24a944eef85384e5) - fitView: return type boolean
- [#2838](https://github.com/wbkd/react-flow/pull/2838) [`021f5a92`](https://github.com/wbkd/react-flow/commit/021f5a9210f47a968e50446cd2f9dae1f97880a4) - refactor: use key press handle modifier keys + input
- [#2839](https://github.com/wbkd/react-flow/pull/2839) [`72216ff6`](https://github.com/wbkd/react-flow/commit/72216ff62014acd2d73999053c72bd7aeed351f6) - fix PropsWithChildren: pass default generic for v17 types
## 11.5.4
### Patch Changes
- [`383a074a`](https://github.com/wbkd/react-flow/commit/383a074aeae6dbec8437fa08c7c8d8240838a84e) Thanks [@bcakmakoglu](https://github.com/bcakmakoglu)! - Check if prevClosestHandle exists in onPointerUp. Fixes connections getting stuck on last handle and connecting, even when out of connectionRadius
## 11.5.3
This release fixes some issues with the newly introduced connection radius feature. We are now not only checking the radius but the handle itself too (like in the old version). That means that you can connect to a handle that is bigger than the connection radius. We are also not snapping connections anymore when they are not valid and pass a status class to the connection line that says if the current connection is valid or not. More over we fixed a connection issue with iOS.
### Patch Changes
- [#2800](https://github.com/wbkd/react-flow/pull/2800) [`be8097ac`](https://github.com/wbkd/react-flow/commit/be8097acadca3054c3b236ce4296fc516010ef8c) - When node is not draggable, you can't move it with a selection either
- [#2803](https://github.com/wbkd/react-flow/pull/2803) [`1527795d`](https://github.com/wbkd/react-flow/commit/1527795d18c3af38c8ec7059436ea0fbf6c27bbd) - connection: add status class (valid or invalid) while in connection radius
- [#2801](https://github.com/wbkd/react-flow/pull/2801) [`3b6348a8`](https://github.com/wbkd/react-flow/commit/3b6348a8d1573afb39576327318bc172e33393c2) - fix(ios): connection error + dont snap invalid connection lines, check handle and connection radius
## 11.5.2
### Patch Changes
- [#2792](https://github.com/wbkd/react-flow/pull/2792) [`d8c679b4`](https://github.com/wbkd/react-flow/commit/d8c679b4c90c5b57d4b51e4aaa988243d6eaff5a) - Accept React 17 types as dev dependency
## 11.5.1
### Patch Changes
- [#2783](https://github.com/wbkd/react-flow/pull/2783) [`71153534`](https://github.com/wbkd/react-flow/commit/7115353418ebc7f7c81ab0e861200972bbf7dbd5) - connections: check handle below mouse before using connection radius
## 11.5.0
Lot's of improvements are coming with this release!
- **Connecting radius**: No need to drop a connection line on top of handle anymore. You only need to be close to the handle. That radius can be configured with the `connectionRadius` prop.
- **Auto pan**: When you drag a node, a selection or the connection line to the border of the pane, it will pan into that direction. That makes it easier to connect far away nodes for example. If you don't like it you can set `autoPnaOnNodeDrag` and `autoPanOnConnect` to false.
- **Touch devices**: It's finally possibleto connect nodes with the connection line on touch devices. In combination with the new auto pan and connection radius the overall UX is way better.
- **Errors**: We added an `onError` prop to get notified when an error like "couldn't find source handle" happens. This is useful if you want to log errors for example.
- **Node type**: We added a second param to the generic `Node` type. You can not only pass `NodeData` but also the type as a second param:
```ts
type MyCustomNode = Node<MyCustomNodeData, 'custom-node-type'>;
```
This makes it easier to work with different custom nodes and data types.
### Minor Changes
- [#2754](https://github.com/wbkd/react-flow/pull/2754) [`e96309b6`](https://github.com/wbkd/react-flow/commit/e96309b6a57b1071faeebf7b0547fef7fd418694) - Add auto pan for connecting and node dragging and `connectionRadius`
- [#2773](https://github.com/wbkd/react-flow/pull/2773) - Add `onError` prop to get notified when an error happens
### Patch Changes
- [#2763](https://github.com/wbkd/react-flow/pull/2763) [`85003b01`](https://github.com/wbkd/react-flow/commit/85003b01add71ea852bd5b0d2f1e7496050a6b52) - Connecting nodes: Enable connections on touch devices
- [#2620](https://github.com/wbkd/react-flow/pull/2620) - Thanks [RichSchulz](https://github.com/RichSchulz)! - Types: improve typing for node type
## 11.4.2
### Patch Changes
- [#2741](https://github.com/wbkd/react-flow/pull/2741) [`e2aff6c1`](https://github.com/wbkd/react-flow/commit/e2aff6c1e4ce54b57b724b2624367ee5fefd1c39) - chore(dependencies): update and cleanup
## 11.4.1
### Patch Changes
- [#2738](https://github.com/wbkd/react-flow/pull/2738) [`82988485`](https://github.com/wbkd/react-flow/commit/82988485b730a9e32acbdae1ddcc81b33ddccaba) - fix: fitView for subflows, context menu on right mouse pan
- [#2740](https://github.com/wbkd/react-flow/pull/2740) [`d91e619a`](https://github.com/wbkd/react-flow/commit/d91e619a70a95db99a621ede59bc05b5a7766086) Thanks [@michaelspiss](https://github.com/michaelspiss)! - EdgeRenderer: check all handles for connection mode loose
## 11.4.0
## New Features
New props for the ReactFlow component to customize the controls of the viewport and the selection box better:
1. `selectionOnDrag` prop: Selection box without extra button press (need to set `panOnDrag={false}` or `panOnDrag={[1, 2]}`)
2. `panOnDrag={[0, 1, 2]}` option to configure specific mouse buttons for panning
3. `panActivationKeyCode="Space"` key code for activating dragging (useful when using `selectionOnDrag`)
4. `selectionMode={SelectionMode.Full}`: you can chose if the selection box needs to contain a node fully (`SelectionMode.Full`) or partially (`SelectionMode.Partial`) to select it
5. `onSelectionStart` and `onSelectionEnd` events
6. `elevateNodesOnSelect`: Defines if z-index should be increased when node is selected
7. New store function `getNodes`. You can now do `store.getState().getNodes()` instead of `Array.from(store.getNodes().nodeInternals.values())`.
Thanks to @jackfishwick who helped a lot with the new panning and selection options.
### Minor Changes
- [#2678](https://github.com/wbkd/react-flow/pull/2678) [`baa8689e`](https://github.com/wbkd/react-flow/commit/baa8689ef629d22da4cbbef955e0c83d21df0493)
- Add new props to configure viewport controls (`selectionOnDrag`, `panActivationKeyCode`, ..)
- [#2661](https://github.com/wbkd/react-flow/pull/2661) [`7ef29108`](https://github.com/wbkd/react-flow/commit/7ef2910808aaaee029894363d52efc0c378a7654)
- panOnDrag: Use numbers for prop ([1,2] = drag via middle or right mouse button)
- selection: do not include hidden nodes
- minimap: fix onNodeClick for nodes outside the viewport
- keys: allow multi select when input is focused
### Patch Changes
- [#2695](https://github.com/wbkd/react-flow/pull/2695) [`ab2ff374`](https://github.com/wbkd/react-flow/commit/ab2ff3740618da48bd4350597e816c397f3d78ff) - Add elevateNodesOnSelect prop
- [#2660](https://github.com/wbkd/react-flow/pull/2660) [`50032c3d`](https://github.com/wbkd/react-flow/commit/50032c3d953bd819d0afe48e4b61f77f987cc8d0) - Add `getNodes` function to the store so that you don't need to do `Array.from(store.getState().nodeInternals.values())` anymore.
- [#2659](https://github.com/wbkd/react-flow/pull/2659) [`4244bae2`](https://github.com/wbkd/react-flow/commit/4244bae25a36cb4904dc1fbba26e1c4d5d463cb9) - Use translateExtent correctly
- [#2657](https://github.com/wbkd/react-flow/pull/2657) [`23afb3ab`](https://github.com/wbkd/react-flow/commit/23afb3abebdb42fad284f68bec164afac609563c) - Only trigger drag event when change happened
## 11.4.0-next.1
### Minor Changes
- panOnDrag: Use numbers for prop ([1,2] = drag via middle or right mouse button)
- selection: do not include hidden nodes
- minimap: fix onNodeClick for nodes outside the viewport
- keys: allow multi select when input is focused
## 11.4.0-next.0
### Minor Changes
- [#2678](https://github.com/wbkd/react-flow/pull/2678) [`baa8689e`](https://github.com/wbkd/react-flow/commit/baa8689ef629d22da4cbbef955e0c83d21df0493) Thanks [@moklick](https://github.com/moklick)! - ## New Features
New props for the ReactFlow component to customize the controls of the viewport and the selection box better:
1. `selectionOnDrag` prop: Selection box without extra button press (need to set `panOnDrag={false} or `panOnDrag="RightClick"`)
2. `panOnDrag="RightClick"` option
3. `panActivationKeyCode="Space"` key code for activating dragging (useful when using `selectionOnDrag`)
4. `selectionMode={SelectionMode.Full}`: you can chose if the selection box needs to contain a node fully (`SelectionMode.Full`) or partially (`SelectionMode.Partial`) to select it
5. `onSelectionStart` and `onSelectionEnd` events
### Patch Changes
- [#2660](https://github.com/wbkd/react-flow/pull/2660) [`50032c3d`](https://github.com/wbkd/react-flow/commit/50032c3d953bd819d0afe48e4b61f77f987cc8d0) Thanks [@moklick](https://github.com/moklick)! - Add `getNodes` function to the store so that you don't need to do `Array.from(store.getState().nodeInternals.values())` anymore.
- [#2659](https://github.com/wbkd/react-flow/pull/2659) [`4244bae2`](https://github.com/wbkd/react-flow/commit/4244bae25a36cb4904dc1fbba26e1c4d5d463cb9) Thanks [@moklick](https://github.com/moklick)! - Use translateExtent correctly
- [#2657](https://github.com/wbkd/react-flow/pull/2657) [`23afb3ab`](https://github.com/wbkd/react-flow/commit/23afb3abebdb42fad284f68bec164afac609563c) Thanks [@moklick](https://github.com/moklick)! - Only trigger drag event when change happened
## 11.3.2
In this update we did some changes so that we could implement the new [`<NodeResizer />`](https://reactflow.dev/docs/api/nodes/node-resizer/) component more smoothly.
### Patch Changes
- [#2646](https://github.com/wbkd/react-flow/pull/2646) [`e6b5d90f`](https://github.com/wbkd/react-flow/commit/e6b5d90f61c8ee60e817bba232a162cae2ab3e2a) - Fix getRectOfNodes
- [#2648](https://github.com/wbkd/react-flow/pull/2648) [`6ee44e07`](https://github.com/wbkd/react-flow/commit/6ee44e076eaa6908d07578a757a5187642b732ae) - Allow middle mouse pan over edges
- [#2647](https://github.com/wbkd/react-flow/pull/2647) [`aa69c207`](https://github.com/wbkd/react-flow/commit/aa69c20765e6978f4f9c8cc63ed7110dbf6d9d9d) Thanks [@neo](https://github.com/neo)! - Invalidate node trying to connect itself with the same handle
- [#2626](https://github.com/wbkd/react-flow/pull/2626) [`d29c401d`](https://github.com/wbkd/react-flow/commit/d29c401d598dbf2dcd5609b7adb8d029906a6f18) - Export the useNodeId hook, refactor how changes are applied and create a helper function
- [#2642](https://github.com/wbkd/react-flow/pull/2642) [`0df02f35`](https://github.com/wbkd/react-flow/commit/0df02f35f8d6c54dae36af18278feadc77acb2d6) - Ignore key events for nodes when input is focused
## 11.3.1
### Patch Changes
- [#2595](https://github.com/wbkd/react-flow/pull/2595) [`c828bfda`](https://github.com/wbkd/react-flow/commit/c828bfda0a8c4774bc43588640c7cca0cfdcb3f4) Thanks [@chrtze](https://github.com/chrtze)! - Fix and improve the behaviour when using nodeOrigin in combination with subflows
- [#2602](https://github.com/wbkd/react-flow/pull/2602) [`b0302ce4`](https://github.com/wbkd/react-flow/commit/b0302ce4261a992bee841bae84af347d03be690f) Thanks [@sdegueldre](https://github.com/sdegueldre)! - Don't use try catch in wrapper for checking if provider is available
- [#2601](https://github.com/wbkd/react-flow/pull/2601) [`b2c72813`](https://github.com/wbkd/react-flow/commit/b2c728137d1b53e38883f044fa447585c377a6af) Thanks [@hoondeveloper](https://github.com/hoondeveloper)! - fix isRectObject function
## 11.3.0
### Minor Changes
- [#2563](https://github.com/wbkd/react-flow/pull/2563) [`98116d43`](https://github.com/wbkd/react-flow/commit/98116d431f9fcdcc9b23a5b606a94ec0740b64cd) Thanks [@chrtze](https://github.com/chrtze)! - Export a new component "NodeToolbar" that renders a fixed element attached to a node
### Patch Changes
- [#2561](https://github.com/wbkd/react-flow/pull/2561) [`92cf497e`](https://github.com/wbkd/react-flow/commit/92cf497eb72f21af592a53f5af9770c9f1e6d940) Thanks [@moklick](https://github.com/moklick)! - Fix multi selection and fitView when nodeOrigin is used
- [#2560](https://github.com/wbkd/react-flow/pull/2560) [`a39224b3`](https://github.com/wbkd/react-flow/commit/a39224b3a80afbdb83fc4490dd5f4f2be23cd4dd) Thanks [@neo](https://github.com/neo)! - Always elevate zIndex when node is selected
- [#2573](https://github.com/wbkd/react-flow/pull/2573) [`5e8b67dd`](https://github.com/wbkd/react-flow/commit/5e8b67dd41f9bb60dcd7f5d14cc34b42c970e967) Thanks [@moklick](https://github.com/moklick)! - Fix disappearing connection line for loose flows
- [#2558](https://github.com/wbkd/react-flow/pull/2558) [`2a1c7db6`](https://github.com/wbkd/react-flow/commit/2a1c7db6b27ac0f4f81dcef2d593f4753c4321c7) Thanks [@moklick](https://github.com/moklick)! - EdgeLabelRenderer: handle multiple instances on a page
## 11.2.0
### Minor Changes
- [#2535](https://github.com/wbkd/react-flow/pull/2535) [`7902a3ce`](https://github.com/wbkd/react-flow/commit/7902a3ce3188426d5cd07cf0943a68f679e67948) Thanks [@moklick](https://github.com/moklick)! - Feat: Add edge label renderer
- [#2536](https://github.com/wbkd/react-flow/pull/2536) [`b25d499e`](https://github.com/wbkd/react-flow/commit/b25d499ec05b5c6f21ac552d03650eb37433552e) Thanks [@pengfu](https://github.com/pengfu)! - Feat: add deleteElements helper function
- [#2539](https://github.com/wbkd/react-flow/pull/2539) [`4fc1253e`](https://github.com/wbkd/react-flow/commit/4fc1253eadf9b7dd392d8dc2348f44fa8d08f931) Thanks [@moklick](https://github.com/moklick)! - Feat: add intersection helpers
- [#2530](https://github.com/wbkd/react-flow/pull/2530) [`8ba4dd5d`](https://github.com/wbkd/react-flow/commit/8ba4dd5d1d4b2e6f107c148de62aec0b688d8b21) Thanks [@moklick](https://github.com/moklick)! - Feat: Add pan and zoom to mini map
### Patch Changes
- [#2538](https://github.com/wbkd/react-flow/pull/2538) [`740659c0`](https://github.com/wbkd/react-flow/commit/740659c0e788c7572d4a1e64e1d33d60712233fc) Thanks [@neo](https://github.com/neo)! - Refactor: put React Flow in isolated stacking context
## 11.1.2
### Patch Changes
- make pro options acc type optional
- cleanup types
- fix rf id handling
- always render nodes when dragging=true
- don't apply animations to helper edge
## 11.1.1
### Patch Changes
- [`c44413d`](https://github.com/wbkd/react-flow/commit/c44413d816604ae2d6ad81ed227c3dfde1a7bd8a) Thanks [@moklick](https://github.com/moklick)! - chore(panel): dont break user selection above panel
- [`48c402c`](https://github.com/wbkd/react-flow/commit/48c402c4d3bd9e16dc91cd4c549324e57b6d5c57) Thanks [@moklick](https://github.com/moklick)! - refactor(aria-descriptions): render when disableKeyboardA11y is true
- [`3a1a365`](https://github.com/wbkd/react-flow/commit/3a1a365a63fc4564d9a8d96309908986fcc86f95) Thanks [@moklick](https://github.com/moklick)! - fix(useOnSelectionChange): repair hook closes #2484
- [`5d35094`](https://github.com/wbkd/react-flow/commit/5d350942d33ded626b3387206f0b0dee368efdfb) Thanks [@neo](https://github.com/neo)! - Add css files as sideEffects
## 11.1.0
### Minor Changes
- [`def11008`](https://github.com/wbkd/react-flow/commit/def11008d88749fec40e6fcba8bc41eea2511bab) Thanks [@moklick](https://github.com/moklick)! - New props: nodesFocusable and edgesFocusable
### Patch Changes
- [`d00faa6b`](https://github.com/wbkd/react-flow/commit/d00faa6b3e77388bfd655d4c02e9a5375bc515e4) Thanks [@moklick](https://github.com/moklick)! - Make nopan class name overwritable with class name option
## 11.0.0
### Major Changes
- **Better [Accessibility](/docs/guides/accessibility)**
- Nodes and edges are focusable, selectable, moveable and deleteable with the keyboard.
- `aria-` default attributes for all elements and controllable via `ariaLabel` options
- Keyboard controls can be disabled with the new `disableKeyboardA11y` prop
- **Better selectable edges** via new edge option: `interactionWidth` - renders invisible edge that makes it easier to interact
- **Better routing for smoothstep and step edges**: https://twitter.com/reactflowdev/status/1567535405284614145
- **Nicer edge updating behaviour**: https://twitter.com/reactflowdev/status/1564966917517021184
- **Node origin**: The new `nodeOrigin` prop lets you control the origin of a node. Useful for layouting.
- **New background pattern**: `BackgroundVariant.Cross` variant
- **[`useOnViewportChange`](/docs/api/hooks/use-on-viewport-change) hook** - handle viewport changes within a component
- **[`useOnSelectionChange`](/docs/api/hooks/use-on-selection-change) hook** - handle selection changes within a component
- **[`useNodesInitialized`](/docs/api/hooks/use-nodes-initialized) hook** - returns true if all nodes are initialized and if there is more than one node
- **Deletable option** for Nodes and edges
- **New Event handlers**: `onPaneMouseEnter`, `onPaneMouseMove` and `onPaneMouseLeave`
- **Edge `pathOptions`** for `smoothstep` and `default` edges
- **Nicer cursor defaults**: Cursor is grabbing, while dragging a node or panning
- **Pane moveable** with middle mouse button
- **Pan over nodes** when they are not draggable (`draggable=false` or `nodesDraggable` false)
- **[`<BaseEdge />`](/docs/api/edges/base-edge) component** that makes it easier to build custom edges
- **[Separately installable packages](/docs/overview/packages/)**
- @reactflow/core
- @reactflow/background
- @reactflow/controls
- @reactflow/minimap

10
packages/react/README.md Normal file
View File

@@ -0,0 +1,10 @@
# @reactflow/core
Core components and util functions of React Flow.
## Installation
```sh
npm install @reactflow/core
```

View File

@@ -0,0 +1,78 @@
{
"name": "@xyflow/react",
"version": "11.7.2",
"description": "Core components and util functions of React Flow.",
"keywords": [
"react",
"node-based UI",
"graph",
"diagram",
"workflow",
"react-flow",
"xyflow"
],
"files": [
"dist"
],
"source": "src/index.ts",
"main": "dist/umd/index.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"sideEffects": [
"*.css"
],
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/wbkd/react-flow.git",
"directory": "packages/core"
},
"scripts": {
"dev": "concurrently \"rollup --config node:@xyflow/rollup-config --watch\" pnpm:css-watch",
"build": "rollup --config node:@xyflow/rollup-config --environment NODE_ENV:production && npm run css",
"css": "postcss src/styles/{base,style}.css --config ./../../tooling/postcss-config/postcss.config.js --dir dist",
"css-watch": "pnpm css --watch",
"lint": "eslint --ext .js,.jsx,.ts,.tsx src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@types/d3": "^7.4.0",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"@xyflow/system": "workspace:*",
"classcat": "^5.0.3",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.3.3"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
},
"devDependencies": {
"@xyflow/eslint-config": "workspace:*",
"@xyflow/rollup-config": "workspace:*",
"@xyflow/tsconfig": "workspace:*",
"@types/node": "^18.7.16",
"@types/react": ">=17",
"@types/react-dom": ">=17",
"react": "^18.2.0",
"typescript": "^4.9.4"
},
"rollup": {
"globals": {
"classcat": "cc",
"d3-selection": "d3Selection",
"d3-zoom": "d3Zoom",
"d3-drag": "d3Drag",
"zustand": "zustand",
"zustand/shallow": "zustandShallow"
},
"name": "ReactFlowCore"
}
}

View File

@@ -0,0 +1,89 @@
import { memo, useRef } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { useStore } from '../../hooks/useStore';
import { type ReactFlowState } from '../../types';
import { BackgroundProps, BackgroundVariant } from './types';
import { DotPattern, LinePattern } from './Patterns';
const defaultColor = {
[BackgroundVariant.Dots]: '#91919a',
[BackgroundVariant.Lines]: '#eee',
[BackgroundVariant.Cross]: '#e2e2e2',
};
const defaultSize = {
[BackgroundVariant.Dots]: 1,
[BackgroundVariant.Lines]: 1,
[BackgroundVariant.Cross]: 6,
};
const selector = (s: ReactFlowState) => ({ transform: s.transform, patternId: `pattern-${s.rfId}` });
function Background({
id,
variant = BackgroundVariant.Dots,
// only used for dots and cross
gap = 20,
// only used for lines and cross
size,
lineWidth = 1,
offset = 2,
color,
style,
className,
}: BackgroundProps) {
const ref = useRef<SVGSVGElement>(null);
const { transform, patternId } = useStore(selector, shallow);
const patternColor = color || defaultColor[variant];
const patternSize = size || defaultSize[variant];
const isDots = variant === BackgroundVariant.Dots;
const isCross = variant === BackgroundVariant.Cross;
const gapXY: [number, number] = Array.isArray(gap) ? gap : [gap, gap];
const scaledGap: [number, number] = [gapXY[0] * transform[2] || 1, gapXY[1] * transform[2] || 1];
const scaledSize = patternSize * transform[2];
const patternDimensions: [number, number] = isCross ? [scaledSize, scaledSize] : scaledGap;
const patternOffset = isDots
? [scaledSize / offset, scaledSize / offset]
: [patternDimensions[0] / offset, patternDimensions[1] / offset];
return (
<svg
className={cc(['react-flow__background', className])}
style={{
...style,
position: 'absolute',
width: '100%',
height: '100%',
top: 0,
left: 0,
}}
ref={ref}
data-testid="rf__background"
>
<pattern
id={patternId + id}
x={transform[0] % scaledGap[0]}
y={transform[1] % scaledGap[1]}
width={scaledGap[0]}
height={scaledGap[1]}
patternUnits="userSpaceOnUse"
patternTransform={`translate(-${patternOffset[0]},-${patternOffset[1]})`}
>
{isDots ? (
<DotPattern color={patternColor} radius={scaledSize / offset} />
) : (
<LinePattern dimensions={patternDimensions} color={patternColor} lineWidth={lineWidth} />
)}
</pattern>
<rect x="0" y="0" width="100%" height="100%" fill={`url(#${patternId + id})`} />
</svg>
);
}
Background.displayName = 'Background';
export default memo(Background);

View File

@@ -0,0 +1,30 @@
type LinePatternProps = {
dimensions: [number, number];
lineWidth?: number;
color: string;
};
export function LinePattern({
color,
dimensions,
lineWidth,
}: LinePatternProps) {
return (
<path
stroke={color}
strokeWidth={lineWidth}
d={`M${dimensions[0] / 2} 0 V${dimensions[1]} M0 ${dimensions[1] / 2} H${
dimensions[0]
}`}
/>
);
}
type DotPatternProps = {
radius: number;
color: string;
};
export function DotPattern({ color, radius }: DotPatternProps) {
return <circle cx={radius} cy={radius} r={radius} fill={color} />;
}

View File

@@ -0,0 +1,2 @@
export { default as Background } from './Background';
export * from './types';

View File

@@ -0,0 +1,19 @@
import { CSSProperties } from 'react';
export enum BackgroundVariant {
Lines = 'lines',
Dots = 'dots',
Cross = 'cross',
}
export type BackgroundProps = {
id?: string
color?: string;
className?: string;
gap?: number | [number, number];
size?: number;
offset?: number;
lineWidth?: number;
variant?: BackgroundVariant;
style?: CSSProperties;
};

View File

@@ -0,0 +1,14 @@
import type { FC, PropsWithChildren } from 'react';
import cc from 'classcat';
import type { ControlButtonProps } from './types';
const ControlButton: FC<PropsWithChildren<ControlButtonProps>> = ({ children, className, ...rest }) => (
<button type="button" className={cc(['react-flow__controls-button', className])} {...rest}>
{children}
</button>
);
ControlButton.displayName = 'ControlButton';
export default ControlButton;

View File

@@ -0,0 +1,133 @@
import { memo, useEffect, useState, type FC, type PropsWithChildren } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { useStore, useStoreApi } from '../../hooks/useStore';
import useReactFlow from '../../hooks/useReactFlow';
import Panel from '../../components/Panel';
import { type ReactFlowState } from '../../types';
import PlusIcon from './Icons/Plus';
import MinusIcon from './Icons/Minus';
import FitviewIcon from './Icons/FitView';
import LockIcon from './Icons/Lock';
import UnlockIcon from './Icons/Unlock';
import ControlButton from './ControlButton';
import type { ControlProps } from './types';
const selector = (s: ReactFlowState) => ({
isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable,
minZoomReached: s.transform[2] <= s.minZoom,
maxZoomReached: s.transform[2] >= s.maxZoom,
});
const Controls: FC<PropsWithChildren<ControlProps>> = ({
style,
showZoom = true,
showFitView = true,
showInteractive = true,
fitViewOptions,
onZoomIn,
onZoomOut,
onFitView,
onInteractiveChange,
className,
children,
position = 'bottom-left',
}) => {
const store = useStoreApi();
const [isVisible, setIsVisible] = useState<boolean>(false);
const { isInteractive, minZoomReached, maxZoomReached } = useStore(selector, shallow);
const { zoomIn, zoomOut, fitView } = useReactFlow();
useEffect(() => {
setIsVisible(true);
}, []);
if (!isVisible) {
return null;
}
const onZoomInHandler = () => {
zoomIn();
onZoomIn?.();
};
const onZoomOutHandler = () => {
zoomOut();
onZoomOut?.();
};
const onFitViewHandler = () => {
fitView(fitViewOptions);
onFitView?.();
};
const onToggleInteractivity = () => {
store.setState({
nodesDraggable: !isInteractive,
nodesConnectable: !isInteractive,
elementsSelectable: !isInteractive,
});
onInteractiveChange?.(!isInteractive);
};
return (
<Panel
className={cc(['react-flow__controls', className])}
position={position}
style={style}
data-testid="rf__controls"
>
{showZoom && (
<>
<ControlButton
onClick={onZoomInHandler}
className="react-flow__controls-zoomin"
title="zoom in"
aria-label="zoom in"
disabled={maxZoomReached}
>
<PlusIcon />
</ControlButton>
<ControlButton
onClick={onZoomOutHandler}
className="react-flow__controls-zoomout"
title="zoom out"
aria-label="zoom out"
disabled={minZoomReached}
>
<MinusIcon />
</ControlButton>
</>
)}
{showFitView && (
<ControlButton
className="react-flow__controls-fitview"
onClick={onFitViewHandler}
title="fit view"
aria-label="fit view"
>
<FitviewIcon />
</ControlButton>
)}
{showInteractive && (
<ControlButton
className="react-flow__controls-interactive"
onClick={onToggleInteractivity}
title="toggle interactivity"
aria-label="toggle interactivity"
>
{isInteractive ? <UnlockIcon /> : <LockIcon />}
</ControlButton>
)}
{children}
</Panel>
);
};
Controls.displayName = 'Controls';
export default memo(Controls);

View File

@@ -0,0 +1,9 @@
function FitViewIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 30">
<path d="M3.692 4.63c0-.53.4-.938.939-.938h5.215V0H4.708C2.13 0 0 2.054 0 4.63v5.216h3.692V4.631zM27.354 0h-5.2v3.692h5.17c.53 0 .984.4.984.939v5.215H32V4.631A4.624 4.624 0 0027.354 0zm.954 24.83c0 .532-.4.94-.939.94h-5.215v3.768h5.215c2.577 0 4.631-2.13 4.631-4.707v-5.139h-3.692v5.139zm-23.677.94c-.531 0-.939-.4-.939-.94v-5.138H0v5.139c0 2.577 2.13 4.707 4.708 4.707h5.138V25.77H4.631z" />
</svg>
);
}
export default FitViewIcon;

View File

@@ -0,0 +1,9 @@
function LockIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 32">
<path d="M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0 8 0 4.571 3.429 4.571 7.619v3.048H3.048A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047zm4.724-13.866H7.467V7.619c0-2.59 2.133-4.724 4.723-4.724 2.591 0 4.724 2.133 4.724 4.724v3.048z" />
</svg>
);
}
export default LockIcon;

View File

@@ -0,0 +1,9 @@
function MinusIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 5">
<path d="M0 0h32v4.2H0z" />
</svg>
);
}
export default MinusIcon;

View File

@@ -0,0 +1,9 @@
function PlusIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M32 18.133H18.133V32h-4.266V18.133H0v-4.266h13.867V0h4.266v13.867H32z" />
</svg>
);
}
export default PlusIcon;

View File

@@ -0,0 +1,9 @@
function UnlockIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 32">
<path d="M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0c-4.114 1.828-1.37 2.133.305 2.438 1.676.305 4.42 2.59 4.42 5.181v3.048H3.047A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047z" />
</svg>
);
}
export default UnlockIcon;

View File

@@ -0,0 +1,3 @@
export { default as Controls } from './Controls';
export { default as ControlButton } from './ControlButton';
export * from './types';

View File

@@ -0,0 +1,18 @@
import type { ButtonHTMLAttributes, HTMLAttributes } from 'react';
import type { PanelPosition } from '@xyflow/system';
import type { FitViewOptions } from '../../types';
export type ControlProps = HTMLAttributes<HTMLDivElement> & {
showZoom?: boolean;
showFitView?: boolean;
showInteractive?: boolean;
fitViewOptions?: FitViewOptions;
onZoomIn?: () => void;
onZoomOut?: () => void;
onFitView?: () => void;
onInteractiveChange?: (interactiveStatus: boolean) => void;
position?: PanelPosition;
};
export type ControlButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;

View File

@@ -0,0 +1,229 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { memo, useEffect, useRef, type MouseEvent } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { type D3ZoomEvent, zoom } from 'd3-zoom';
import { select, pointer } from 'd3-selection';
import {
getRectOfNodes,
getBoundsOfRects,
getNodePositionWithOrigin,
CoordinateExtent,
type Rect,
} from '@xyflow/system';
import { useStore, useStoreApi } from '../../hooks/useStore';
import Panel from '../../components/Panel';
import type { ReactFlowState } from '../../types';
import MiniMapNode from './MiniMapNode';
import type { MiniMapProps, GetMiniMapNodeAttribute } from './types';
declare const window: any;
const defaultWidth = 200;
const defaultHeight = 150;
const selector = (s: ReactFlowState) => {
const nodes = s.getNodes();
const viewBB: Rect = {
x: -s.transform[0] / s.transform[2],
y: -s.transform[1] / s.transform[2],
width: s.width / s.transform[2],
height: s.height / s.transform[2],
};
return {
nodes: nodes.filter((node) => !node.hidden && node.width && node.height),
viewBB,
boundingRect: nodes.length > 0 ? getBoundsOfRects(getRectOfNodes(nodes, s.nodeOrigin), viewBB) : viewBB,
rfId: s.rfId,
nodeOrigin: s.nodeOrigin,
};
};
const getAttrFunction = (func: any): GetMiniMapNodeAttribute => (func instanceof Function ? func : () => func);
const ARIA_LABEL_KEY = 'react-flow__minimap-desc';
function MiniMap({
style,
className,
nodeStrokeColor = 'transparent',
nodeColor = '#e2e2e2',
nodeClassName = '',
nodeBorderRadius = 5,
nodeStrokeWidth = 2,
// We need to rename the prop to be `CapitalCase` so that JSX will render it as
// a component properly.
nodeComponent: NodeComponent = MiniMapNode,
maskColor = 'rgb(240, 240, 240, 0.6)',
maskStrokeColor = 'none',
maskStrokeWidth = 1,
position = 'bottom-right',
onClick,
onNodeClick,
pannable = false,
zoomable = false,
ariaLabel = 'React Flow mini map',
inversePan = false,
zoomStep = 10,
}: MiniMapProps) {
const store = useStoreApi();
const svg = useRef<SVGSVGElement>(null);
const { boundingRect, viewBB, nodes, rfId, nodeOrigin } = useStore(selector, shallow);
const elementWidth = (style?.width as number) ?? defaultWidth;
const elementHeight = (style?.height as number) ?? defaultHeight;
const nodeColorFunc = getAttrFunction(nodeColor);
const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor);
const nodeClassNameFunc = getAttrFunction(nodeClassName);
const scaledWidth = boundingRect.width / elementWidth;
const scaledHeight = boundingRect.height / elementHeight;
const viewScale = Math.max(scaledWidth, scaledHeight);
const viewWidth = viewScale * elementWidth;
const viewHeight = viewScale * elementHeight;
const offset = 5 * viewScale;
const x = boundingRect.x - (viewWidth - boundingRect.width) / 2 - offset;
const y = boundingRect.y - (viewHeight - boundingRect.height) / 2 - offset;
const width = viewWidth + offset * 2;
const height = viewHeight + offset * 2;
const shapeRendering = typeof window === 'undefined' || !!window.chrome ? 'crispEdges' : 'geometricPrecision';
const labelledBy = `${ARIA_LABEL_KEY}-${rfId}`;
const viewScaleRef = useRef(0);
viewScaleRef.current = viewScale;
useEffect(() => {
if (svg.current) {
const selection = select(svg.current as Element);
const zoomHandler = (event: D3ZoomEvent<SVGSVGElement, any>) => {
const { transform, panZoom } = store.getState();
if (event.sourceEvent.type !== 'wheel' || !panZoom) {
return;
}
const pinchDelta =
-event.sourceEvent.deltaY *
(event.sourceEvent.deltaMode === 1 ? 0.05 : event.sourceEvent.deltaMode ? 1 : 0.002) *
zoomStep;
const nextZoom = transform[2] * Math.pow(2, pinchDelta);
panZoom.scaleTo(nextZoom);
};
const panHandler = (event: D3ZoomEvent<HTMLDivElement, any>) => {
const { transform, panZoom, translateExtent, width, height } = store.getState();
if (event.sourceEvent.type !== 'mousemove' || !panZoom) {
return;
}
// @TODO: how to calculate the correct next position? Math.max(1, transform[2]) is a workaround.
const moveScale = viewScaleRef.current * Math.max(1, transform[2]) * (inversePan ? -1 : 1);
const position = {
x: transform[0] - event.sourceEvent.movementX * moveScale,
y: transform[1] - event.sourceEvent.movementY * moveScale,
};
const extent: CoordinateExtent = [
[0, 0],
[width, height],
];
panZoom.setViewportConstrained(
{
x: position.x,
y: position.y,
zoom: transform[2],
},
extent,
translateExtent
);
};
const zoomAndPanHandler = zoom()
// @ts-ignore
.on('zoom', pannable ? panHandler : null)
// @ts-ignore
.on('zoom.wheel', zoomable ? zoomHandler : null);
selection.call(zoomAndPanHandler, {});
return () => {
selection.on('zoom', null);
};
}
}, [pannable, zoomable, inversePan, zoomStep]);
const onSvgClick = onClick
? (event: MouseEvent) => {
const rfCoord = pointer(event);
onClick(event, { x: rfCoord[0], y: rfCoord[1] });
}
: undefined;
const onSvgNodeClick = onNodeClick
? (event: MouseEvent, nodeId: string) => {
const node = store.getState().nodeInternals.get(nodeId)!;
onNodeClick(event, node);
}
: undefined;
return (
<Panel
position={position}
style={style}
className={cc(['react-flow__minimap', className])}
data-testid="rf__minimap"
>
<svg
width={elementWidth}
height={elementHeight}
viewBox={`${x} ${y} ${width} ${height}`}
role="img"
aria-labelledby={labelledBy}
ref={svg}
onClick={onSvgClick}
>
{ariaLabel && <title id={labelledBy}>{ariaLabel}</title>}
{nodes.map((node) => {
const { x, y } = getNodePositionWithOrigin(node, node.origin || nodeOrigin).positionAbsolute;
return (
<NodeComponent
key={node.id}
x={x}
y={y}
width={node.width!}
height={node.height!}
style={node.style}
className={nodeClassNameFunc(node)}
color={nodeColorFunc(node)}
borderRadius={nodeBorderRadius}
strokeColor={nodeStrokeColorFunc(node)}
strokeWidth={nodeStrokeWidth}
shapeRendering={shapeRendering}
onClick={onSvgNodeClick}
id={node.id}
/>
);
})}
<path
className="react-flow__minimap-mask"
d={`M${x - offset},${y - offset}h${width + offset * 2}v${height + offset * 2}h${-width - offset * 2}z
M${viewBB.x},${viewBB.y}h${viewBB.width}v${viewBB.height}h${-viewBB.width}z`}
fill={maskColor}
fillRule="evenodd"
stroke={maskStrokeColor}
strokeWidth={maskStrokeWidth}
pointerEvents="none"
/>
</svg>
</Panel>
);
}
MiniMap.displayName = 'MiniMap';
export default memo(MiniMap);

View File

@@ -0,0 +1,43 @@
import { memo } from 'react';
import type { MiniMapNodeProps } from './types';
import cc from 'classcat';
const MiniMapNode = ({
id,
x,
y,
width,
height,
style,
color,
strokeColor,
strokeWidth,
className,
borderRadius,
shapeRendering,
onClick,
}: MiniMapNodeProps) => {
const { background, backgroundColor } = style || {};
const fill = (color || background || backgroundColor) as string;
return (
<rect
className={cc(['react-flow__minimap-node', className])}
x={x}
y={y}
rx={borderRadius}
ry={borderRadius}
width={width}
height={height}
fill={fill}
stroke={strokeColor}
strokeWidth={strokeWidth}
shapeRendering={shapeRendering}
onClick={onClick ? (event) => onClick(event, id) : undefined}
/>
);
};
MiniMapNode.displayName = 'MiniMapNode';
export default memo(MiniMapNode);

View File

@@ -0,0 +1,2 @@
export { default as MiniMap } from './MiniMap';
export * from './types';

View File

@@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ComponentType, CSSProperties, HTMLAttributes, MouseEvent } from 'react';
import type { PanelPosition, XYPosition } from '@xyflow/system';
import type { Node } from '../../types';
export type GetMiniMapNodeAttribute<NodeData = any> = (node: Node<NodeData>) => string;
export type MiniMapProps<NodeData = any> = Omit<HTMLAttributes<SVGSVGElement>, 'onClick'> & {
nodeColor?: string | GetMiniMapNodeAttribute<NodeData>;
nodeStrokeColor?: string | GetMiniMapNodeAttribute<NodeData>;
nodeClassName?: string | GetMiniMapNodeAttribute<NodeData>;
nodeBorderRadius?: number;
nodeStrokeWidth?: number;
nodeComponent?: ComponentType<MiniMapNodeProps>;
maskColor?: string;
maskStrokeColor?: string;
maskStrokeWidth?: number;
position?: PanelPosition;
onClick?: (event: MouseEvent, position: XYPosition) => void;
onNodeClick?: (event: MouseEvent, node: Node<NodeData>) => void;
pannable?: boolean;
zoomable?: boolean;
ariaLabel?: string | null;
inversePan?: boolean;
zoomStep?: number;
};
export interface MiniMapNodeProps {
id: string;
x: number;
y: number;
width: number;
height: number;
borderRadius: number;
className: string;
color: string;
shapeRendering: string;
strokeColor: string;
strokeWidth: number;
style?: CSSProperties;
onClick?: (event: MouseEvent, id: string) => void;
}

View File

@@ -0,0 +1,72 @@
import ResizeControl from './ResizeControl';
import { ControlPosition, NodeResizerProps, ResizeControlVariant, ControlLinePosition } from './types';
const handleControls: ControlPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
const lineControls: ControlLinePosition[] = ['top', 'right', 'bottom', 'left'];
export default function NodeResizer({
nodeId,
isVisible = true,
handleClassName,
handleStyle,
lineClassName,
lineStyle,
color,
minWidth = 10,
minHeight = 10,
maxWidth = Number.MAX_VALUE,
maxHeight = Number.MAX_VALUE,
keepAspectRatio = false,
shouldResize,
onResizeStart,
onResize,
onResizeEnd,
}: NodeResizerProps) {
if (!isVisible) {
return null;
}
return (
<>
{lineControls.map((c) => (
<ResizeControl
key={c}
className={lineClassName}
style={lineStyle}
nodeId={nodeId}
position={c}
variant={ResizeControlVariant.Line}
color={color}
minWidth={minWidth}
minHeight={minHeight}
maxWidth={maxWidth}
maxHeight={maxHeight}
onResizeStart={onResizeStart}
keepAspectRatio={keepAspectRatio}
shouldResize={shouldResize}
onResize={onResize}
onResizeEnd={onResizeEnd}
/>
))}
{handleControls.map((c) => (
<ResizeControl
key={c}
className={handleClassName}
style={handleStyle}
nodeId={nodeId}
position={c}
color={color}
minWidth={minWidth}
minHeight={minHeight}
maxWidth={maxWidth}
maxHeight={maxHeight}
onResizeStart={onResizeStart}
keepAspectRatio={keepAspectRatio}
shouldResize={shouldResize}
onResize={onResize}
onResizeEnd={onResizeEnd}
/>
))}
</>
);
}

View File

@@ -0,0 +1,245 @@
import { useRef, useEffect, memo } from 'react';
import cc from 'classcat';
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import { getPointerPosition, clamp } from '@xyflow/system';
import { useStoreApi } from '../../hooks/useStore';
import { useNodeId } from '../../contexts/NodeIdContext';
import type { NodeChange, NodeDimensionChange, NodePositionChange } from '../../types';
import {
type ResizeDragEvent,
type ResizeControlProps,
type ResizeControlLineProps,
ResizeControlVariant,
} from './types';
import { getDirection } from './utils';
const initPrevValues = { width: 0, height: 0, x: 0, y: 0 };
const initStartValues = {
...initPrevValues,
pointerX: 0,
pointerY: 0,
aspectRatio: 1,
};
function ResizeControl({
nodeId,
position,
variant = ResizeControlVariant.Handle,
className,
style = {},
children,
color,
minWidth = 10,
minHeight = 10,
maxWidth = Number.MAX_VALUE,
maxHeight = Number.MAX_VALUE,
keepAspectRatio = false,
shouldResize,
onResizeStart,
onResize,
onResizeEnd,
}: ResizeControlProps) {
const contextNodeId = useNodeId();
const id = typeof nodeId === 'string' ? nodeId : contextNodeId;
const store = useStoreApi();
const resizeControlRef = useRef<HTMLDivElement>(null);
const startValues = useRef<typeof initStartValues>(initStartValues);
const prevValues = useRef<typeof initPrevValues>(initPrevValues);
const defaultPosition = variant === ResizeControlVariant.Line ? 'right' : 'bottom-right';
const controlPosition = position ?? defaultPosition;
useEffect(() => {
if (!resizeControlRef.current || !id) {
return;
}
const selection = select(resizeControlRef.current);
const enableX = controlPosition.includes('right') || controlPosition.includes('left');
const enableY = controlPosition.includes('bottom') || controlPosition.includes('top');
const invertX = controlPosition.includes('left');
const invertY = controlPosition.includes('top');
const dragHandler = drag<HTMLDivElement, unknown>()
.on('start', (event: ResizeDragEvent) => {
const { nodeInternals, transform, snapGrid, snapToGrid } = store.getState();
const node = nodeInternals.get(id);
const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
prevValues.current = {
width: node?.width ?? 0,
height: node?.height ?? 0,
x: node?.position.x ?? 0,
y: node?.position.y ?? 0,
};
startValues.current = {
...prevValues.current,
pointerX: xSnapped,
pointerY: ySnapped,
aspectRatio: prevValues.current.width / prevValues.current.height,
};
onResizeStart?.(event, { ...prevValues.current });
})
.on('drag', (event: ResizeDragEvent) => {
const { nodeInternals, transform, snapGrid, snapToGrid, triggerNodeChanges } = store.getState();
const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
const node = nodeInternals.get(id);
if (node) {
const changes: NodeChange[] = [];
const {
pointerX: startX,
pointerY: startY,
width: startWidth,
height: startHeight,
x: startNodeX,
y: startNodeY,
aspectRatio,
} = startValues.current;
const { x: prevX, y: prevY, width: prevWidth, height: prevHeight } = prevValues.current;
const distX = Math.floor(enableX ? xSnapped - startX : 0);
const distY = Math.floor(enableY ? ySnapped - startY : 0);
let width = clamp(startWidth + (invertX ? -distX : distX), minWidth, maxWidth);
let height = clamp(startHeight + (invertY ? -distY : distY), minHeight, maxHeight);
if (keepAspectRatio) {
const nextAspectRatio = width / height;
const isDiagonal = enableX && enableY;
const isHorizontal = enableX && !enableY;
const isVertical = enableY && !enableX;
width = (nextAspectRatio <= aspectRatio && isDiagonal) || isVertical ? height * aspectRatio : width;
height = (nextAspectRatio > aspectRatio && isDiagonal) || isHorizontal ? width / aspectRatio : height;
if (width >= maxWidth) {
width = maxWidth;
height = maxWidth / aspectRatio;
} else if (width <= minWidth) {
width = minWidth;
height = minWidth / aspectRatio;
}
if (height >= maxHeight) {
height = maxHeight;
width = maxHeight * aspectRatio;
} else if (height <= minHeight) {
height = minHeight;
width = minHeight * aspectRatio;
}
}
const isWidthChange = width !== prevWidth;
const isHeightChange = height !== prevHeight;
if (invertX || invertY) {
const x = invertX ? startNodeX - (width - startWidth) : startNodeX;
const y = invertY ? startNodeY - (height - startHeight) : startNodeY;
// only transform the node if the width or height changes
const isXPosChange = x !== prevX && isWidthChange;
const isYPosChange = y !== prevY && isHeightChange;
if (isXPosChange || isYPosChange) {
const positionChange: NodePositionChange = {
id: node.id,
type: 'position',
position: {
x: isXPosChange ? x : prevX,
y: isYPosChange ? y : prevY,
},
};
changes.push(positionChange);
prevValues.current.x = positionChange.position!.x;
prevValues.current.y = positionChange.position!.y;
}
}
if (isWidthChange || isHeightChange) {
const dimensionChange: NodeDimensionChange = {
id: id,
type: 'dimensions',
updateStyle: true,
resizing: true,
dimensions: {
width: width,
height: height,
},
};
changes.push(dimensionChange);
prevValues.current.width = width;
prevValues.current.height = height;
}
if (changes.length === 0) {
return;
}
const direction = getDirection({
width: prevValues.current.width,
prevWidth,
height: prevValues.current.height,
prevHeight,
invertX,
invertY,
});
const nextValues = { ...prevValues.current, direction };
const callResize = shouldResize?.(event, nextValues);
if (callResize === false) {
return;
}
onResize?.(event, nextValues);
triggerNodeChanges(changes);
}
})
.on('end', (event: ResizeDragEvent) => {
const dimensionChange: NodeDimensionChange = {
id: id,
type: 'dimensions',
resizing: false,
};
onResizeEnd?.(event, { ...prevValues.current });
store.getState().triggerNodeChanges([dimensionChange]);
});
selection.call(dragHandler);
return () => {
selection.on('.drag', null);
};
}, [id, controlPosition, minWidth, minHeight, maxWidth, maxHeight, keepAspectRatio]);
const positionClassNames = controlPosition.split('-');
const colorStyleProp = variant === ResizeControlVariant.Line ? 'borderColor' : 'backgroundColor';
const controlStyle = color ? { ...style, [colorStyleProp]: color } : style;
return (
<div
className={cc(['react-flow__resize-control', 'nodrag', ...positionClassNames, variant, className])}
ref={resizeControlRef}
style={controlStyle}
>
{children}
</div>
);
}
export function ResizeControlLine(props: ResizeControlLineProps) {
return <ResizeControl {...props} variant={ResizeControlVariant.Line} />;
}
export default memo(ResizeControl);

View File

@@ -0,0 +1,4 @@
export { default as NodeResizer } from './NodeResizer';
export { default as NodeResizeControl } from './ResizeControl';
export * from './types';

View File

@@ -0,0 +1,75 @@
import type { CSSProperties, ReactNode } from 'react';
import type { D3DragEvent, SubjectPosition } from 'd3-drag';
export type ResizeParams = {
x: number;
y: number;
width: number;
height: number;
};
export type ResizeParamsWithDirection = ResizeParams & {
direction: number[];
};
type OnResizeHandler<Params = ResizeParams, Result = void> = (event: ResizeDragEvent, params: Params) => Result;
export type ShouldResize = OnResizeHandler<ResizeParamsWithDirection, boolean>;
export type OnResizeStart = OnResizeHandler;
export type OnResize = OnResizeHandler<ResizeParamsWithDirection>;
export type OnResizeEnd = OnResizeHandler;
export type NodeResizerProps = {
nodeId?: string;
color?: string;
handleClassName?: string;
handleStyle?: CSSProperties;
lineClassName?: string;
lineStyle?: CSSProperties;
isVisible?: boolean;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
keepAspectRatio?: boolean;
shouldResize?: ShouldResize;
onResizeStart?: OnResizeStart;
onResize?: OnResize;
onResizeEnd?: OnResizeEnd;
};
export type ControlLinePosition = 'top' | 'bottom' | 'left' | 'right';
export type ControlPosition = ControlLinePosition | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export enum ResizeControlVariant {
Line = 'line',
Handle = 'handle',
}
export type ResizeControlProps = Pick<
NodeResizerProps,
| 'nodeId'
| 'color'
| 'minWidth'
| 'minHeight'
| 'maxWidth'
| 'maxHeight'
| 'keepAspectRatio'
| 'shouldResize'
| 'onResizeStart'
| 'onResize'
| 'onResizeEnd'
> & {
position?: ControlPosition;
variant?: ResizeControlVariant;
className?: string;
style?: CSSProperties;
children?: ReactNode;
};
export type ResizeControlLineProps = ResizeControlProps & {
position?: ControlLinePosition;
};
export type ResizeDragEvent = D3DragEvent<HTMLDivElement, null, SubjectPosition>;

View File

@@ -0,0 +1,26 @@
type GetDirectionParams = {
width: number;
prevWidth: number;
height: number;
prevHeight: number;
invertX: boolean;
invertY: boolean;
};
// returns an array of two numbers (0, 1 or -1) representing the direction of the resize
// 0 = no change, 1 = increase, -1 = decrease
export function getDirection({ width, prevWidth, height, prevHeight, invertX, invertY }: GetDirectionParams) {
const deltaWidth = width - prevWidth;
const deltaHeight = height - prevHeight;
const direction = [deltaWidth > 0 ? 1 : deltaWidth < 0 ? -1 : 0, deltaHeight > 0 ? 1 : deltaHeight < 0 ? -1 : 0];
if (deltaWidth && invertX) {
direction[0] = direction[0] * -1;
}
if (deltaHeight && invertY) {
direction[1] = direction[1] * -1;
}
return direction;
}

View File

@@ -0,0 +1,127 @@
import { useCallback, CSSProperties } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { getRectOfNodes, Transform, Rect, Position, internalsSymbol } from '@xyflow/system';
import { Node, ReactFlowState } from '../../types';
import { useStore } from '../../hooks/useStore';
import { useNodeId } from '../../contexts/NodeIdContext';
import NodeToolbarPortal from './NodeToolbarPortal';
import { Align, NodeToolbarProps } from './types';
const nodeEqualityFn = (a: Node | undefined, b: Node | undefined) =>
a?.positionAbsolute?.x === b?.positionAbsolute?.x &&
a?.positionAbsolute?.y === b?.positionAbsolute?.y &&
a?.width === b?.width &&
a?.height === b?.height &&
a?.selected === b?.selected &&
a?.[internalsSymbol]?.z === b?.[internalsSymbol]?.z;
const nodesEqualityFn = (a: Node[], b: Node[]) => {
return a.length === b.length && a.every((node, i) => nodeEqualityFn(node, b[i]));
};
const storeSelector = (state: ReactFlowState) => ({
transform: state.transform,
nodeOrigin: state.nodeOrigin,
selectedNodesCount: state.getNodes().filter((node) => node.selected).length,
});
function getTransform(nodeRect: Rect, transform: Transform, position: Position, offset: number, align: Align): string {
let alignmentOffset = 0.5;
if (align === 'start') {
alignmentOffset = 0;
} else if (align === 'end') {
alignmentOffset = 1;
}
// position === Position.Top
// we set the x any y position of the toolbar based on the nodes position
let pos = [
(nodeRect.x + nodeRect.width * alignmentOffset) * transform[2] + transform[0],
nodeRect.y * transform[2] + transform[1] - offset,
];
// and than shift it based on the alignment. The shift values are in %.
let shift = [-100 * alignmentOffset, -100];
switch (position) {
case Position.Right:
pos = [
(nodeRect.x + nodeRect.width) * transform[2] + transform[0] + offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * transform[2] + transform[1],
];
shift = [0, -100 * alignmentOffset];
break;
case Position.Bottom:
pos[1] = (nodeRect.y + nodeRect.height) * transform[2] + transform[1] + offset;
shift[1] = 0;
break;
case Position.Left:
pos = [
nodeRect.x * transform[2] + transform[0] - offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * transform[2] + transform[1],
];
shift = [-100, -100 * alignmentOffset];
break;
}
return `translate(${pos[0]}px, ${pos[1]}px) translate(${shift[0]}%, ${shift[1]}%)`;
}
function NodeToolbar({
nodeId,
children,
className,
style,
isVisible,
position = Position.Top,
offset = 10,
align = 'center',
...rest
}: NodeToolbarProps) {
const contextNodeId = useNodeId();
const nodesSelector = useCallback(
(state: ReactFlowState): Node[] => {
const nodeIds = Array.isArray(nodeId) ? nodeId : [nodeId || contextNodeId || ''];
return nodeIds.reduce<Node[]>((acc, id) => {
const node = state.nodeInternals.get(id);
if (node) {
acc.push(node);
}
return acc;
}, [] as Node[]);
},
[nodeId, contextNodeId]
);
const nodes = useStore(nodesSelector, nodesEqualityFn);
const { transform, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow);
const isActive =
typeof isVisible === 'boolean' ? isVisible : nodes.length === 1 && nodes[0].selected && selectedNodesCount === 1;
if (!isActive || !nodes.length) {
return null;
}
const nodeRect: Rect = getRectOfNodes(nodes, nodeOrigin);
const zIndex: number = Math.max(...nodes.map((node) => (node[internalsSymbol]?.z || 1) + 1));
const wrapperStyle: CSSProperties = {
position: 'absolute',
transform: getTransform(nodeRect, transform, position, offset, align),
zIndex,
...style,
};
return (
<NodeToolbarPortal>
<div style={wrapperStyle} className={cc(['react-flow__node-toolbar', className])} {...rest}>
{children}
</div>
</NodeToolbarPortal>
);
}
export default NodeToolbar;

View File

@@ -0,0 +1,19 @@
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import type { ReactFlowState } from '../../types';
import { useStore } from '../../hooks/useStore';
const selector = (state: ReactFlowState) => state.domNode?.querySelector('.react-flow__renderer');
function NodeToolbarPortal({ children }: { children: ReactNode }) {
const wrapperRef = useStore(selector);
if (!wrapperRef) {
return null;
}
return createPortal(children, wrapperRef);
}
export default NodeToolbarPortal;

View File

@@ -0,0 +1,2 @@
export { default as NodeToolbar } from './NodeToolbar';
export * from './types';

View File

@@ -0,0 +1,12 @@
import type { HTMLAttributes } from 'react';
import type { Position } from '@xyflow/system';
export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
nodeId?: string | string[];
isVisible?: boolean;
position?: Position;
offset?: number;
align?: Align;
};
export type Align = 'center' | 'start' | 'end';

View File

@@ -0,0 +1,5 @@
export * from './Background';
export * from './Controls';
export * from './MiniMap';
export * from './NodeResizer';
export * from './NodeToolbar';

View File

@@ -0,0 +1,51 @@
import { CSSProperties } from 'react';
import { useStore } from '../../hooks/useStore';
import type { ReactFlowState } from '../../types';
const style: CSSProperties = { display: 'none' };
const ariaLiveStyle: CSSProperties = {
position: 'absolute',
width: 1,
height: 1,
margin: -1,
border: 0,
padding: 0,
overflow: 'hidden',
clip: 'rect(0px, 0px, 0px, 0px)',
clipPath: 'inset(100%)',
};
export const ARIA_NODE_DESC_KEY = 'react-flow__node-desc';
export const ARIA_EDGE_DESC_KEY = 'react-flow__edge-desc';
export const ARIA_LIVE_MESSAGE = 'react-flow__aria-live';
const selector = (s: ReactFlowState) => s.ariaLiveMessage;
function AriaLiveMessage({ rfId }: { rfId: string }) {
const ariaLiveMessage = useStore(selector);
return (
<div id={`${ARIA_LIVE_MESSAGE}-${rfId}`} aria-live="assertive" aria-atomic="true" style={ariaLiveStyle}>
{ariaLiveMessage}
</div>
);
}
function A11yDescriptions({ rfId, disableKeyboardA11y }: { rfId: string; disableKeyboardA11y: boolean }) {
return (
<>
<div id={`${ARIA_NODE_DESC_KEY}-${rfId}`} style={style}>
Press enter or space to select a node.
{!disableKeyboardA11y && 'You can then use the arrow keys to move the node around.'} Press delete to remove it
and escape to cancel.{' '}
</div>
<div id={`${ARIA_EDGE_DESC_KEY}-${rfId}`} style={style}>
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.
</div>
{!disableKeyboardA11y && <AriaLiveMessage rfId={rfId} />}
</>
);
}
export default A11yDescriptions;

View File

@@ -0,0 +1,28 @@
import type { PanelPosition, ProOptions } from '@xyflow/system';
import Panel from '../Panel';
type AttributionProps = {
proOptions?: ProOptions;
position?: PanelPosition;
};
function Attribution({ proOptions, position = 'bottom-right' }: AttributionProps) {
if (proOptions?.hideAttribution) {
return null;
}
return (
<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"
>
<a href="https://reactflow.dev" target="_blank" rel="noopener noreferrer" aria-label="React Flow attribution">
React Flow
</a>
</Panel>
);
}
export default Attribution;

View File

@@ -0,0 +1,174 @@
import { CSSProperties, useCallback } from 'react';
import { shallow } from 'zustand/shallow';
import cc from 'classcat';
import {
internalsSymbol,
Position,
ConnectionLineType,
ConnectionMode,
getBezierPath,
getSmoothStepPath,
type ConnectionStatus,
type HandleType,
} from '@xyflow/system';
import { useStore } from '../../hooks/useStore';
import { getSimpleBezierPath } from '../Edges/SimpleBezierEdge';
import type { ConnectionLineComponent, ReactFlowState, ReactFlowStore } from '../../types';
type ConnectionLineProps = {
nodeId: string;
handleType: HandleType;
type: ConnectionLineType;
style?: CSSProperties;
CustomComponent?: ConnectionLineComponent;
connectionStatus: ConnectionStatus | null;
};
const oppositePosition = {
[Position.Left]: Position.Right,
[Position.Right]: Position.Left,
[Position.Top]: Position.Bottom,
[Position.Bottom]: Position.Top,
};
const ConnectionLine = ({
nodeId,
handleType,
style,
type = ConnectionLineType.Bezier,
CustomComponent,
connectionStatus,
}: ConnectionLineProps) => {
const { fromNode, handleId, toX, toY, connectionMode } = useStore(
useCallback(
(s: ReactFlowStore) => ({
fromNode: s.nodeInternals.get(nodeId),
handleId: s.connectionStartHandle?.handleId,
toX: (s.connectionPosition.x - s.transform[0]) / s.transform[2],
toY: (s.connectionPosition.y - s.transform[1]) / s.transform[2],
connectionMode: s.connectionMode,
}),
[nodeId]
),
shallow
);
const fromHandleBounds = fromNode?.[internalsSymbol]?.handleBounds;
let handleBounds = fromHandleBounds?.[handleType];
if (connectionMode === ConnectionMode.Loose) {
handleBounds = handleBounds ? handleBounds : fromHandleBounds?.[handleType === 'source' ? 'target' : 'source'];
}
if (!fromNode || !handleBounds) {
return null;
}
const fromHandle = handleId ? handleBounds.find((d) => d.id === handleId) : handleBounds[0];
const fromHandleX = fromHandle ? fromHandle.x + fromHandle.width / 2 : (fromNode.width ?? 0) / 2;
const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode.height ?? 0;
const fromX = (fromNode.positionAbsolute?.x ?? 0) + fromHandleX;
const fromY = (fromNode.positionAbsolute?.y ?? 0) + fromHandleY;
const fromPosition = fromHandle?.position;
const toPosition = fromPosition ? oppositePosition[fromPosition] : null;
if (!fromPosition || !toPosition) {
return null;
}
if (CustomComponent) {
return (
<CustomComponent
connectionLineType={type}
connectionLineStyle={style}
fromNode={fromNode}
fromHandle={fromHandle}
fromX={fromX}
fromY={fromY}
toX={toX}
toY={toY}
fromPosition={fromPosition}
toPosition={toPosition}
connectionStatus={connectionStatus}
/>
);
}
let dAttr = '';
const pathParams = {
sourceX: fromX,
sourceY: fromY,
sourcePosition: fromPosition,
targetX: toX,
targetY: toY,
targetPosition: toPosition,
};
if (type === ConnectionLineType.Bezier) {
// we assume the destination position is opposite to the source position
[dAttr] = getBezierPath(pathParams);
} else if (type === ConnectionLineType.Step) {
[dAttr] = getSmoothStepPath({
...pathParams,
borderRadius: 0,
});
} else if (type === ConnectionLineType.SmoothStep) {
[dAttr] = getSmoothStepPath(pathParams);
} else if (type === ConnectionLineType.SimpleBezier) {
[dAttr] = getSimpleBezierPath(pathParams);
} else {
dAttr = `M${fromX},${fromY} ${toX},${toY}`;
}
return <path d={dAttr} fill="none" className="react-flow__connection-path" style={style} />;
};
ConnectionLine.displayName = 'ConnectionLine';
type ConnectionLineWrapperProps = {
type: ConnectionLineType;
component?: ConnectionLineComponent;
containerStyle?: CSSProperties;
style?: CSSProperties;
};
const selector = (s: ReactFlowState) => ({
nodeId: s.connectionStartHandle?.nodeId,
handleType: s.connectionStartHandle?.type,
nodesConnectable: s.nodesConnectable,
connectionStatus: s.connectionStatus,
width: s.width,
height: s.height,
});
function ConnectionLineWrapper({ containerStyle, style, type, component }: ConnectionLineWrapperProps) {
const { nodeId, handleType, nodesConnectable, width, height, connectionStatus } = useStore(selector, shallow);
const isValid = !!(nodeId && handleType && width && nodesConnectable);
if (!isValid) {
return null;
}
return (
<svg
style={containerStyle}
width={width}
height={height}
className="react-flow__edges react-flow__connectionline react-flow__container"
>
<g className={cc(['react-flow__connection', connectionStatus])}>
<ConnectionLine
nodeId={nodeId}
handleType={handleType}
style={style}
type={type}
CustomComponent={component}
connectionStatus={connectionStatus}
/>
</g>
</svg>
);
}
export default ConnectionLineWrapper;

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useStore } from '../../hooks/useStore';
import type { ReactFlowState } from '../../types';
const selector = (s: ReactFlowState) => s.domNode?.querySelector('.react-flow__edgelabel-renderer');
function EdgeLabelRenderer({ children }: { children: ReactNode }) {
const edgeLabelRenderer = useStore(selector);
if (!edgeLabelRenderer) {
return null;
}
return createPortal(children, edgeLabelRenderer);
}
export default EdgeLabelRenderer;

View File

@@ -0,0 +1,61 @@
import { isNumeric } from '@xyflow/system';
import type { BaseEdgeProps } from '../../types';
import EdgeText from './EdgeText';
const BaseEdge = ({
id,
path,
labelX,
labelY,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
interactionWidth = 20,
}: BaseEdgeProps) => {
return (
<>
<path
id={id}
style={style}
d={path}
fill="none"
className="react-flow__edge-path"
markerEnd={markerEnd}
markerStart={markerStart}
/>
{interactionWidth && (
<path
d={path}
fill="none"
strokeOpacity={0}
strokeWidth={interactionWidth}
className="react-flow__edge-interaction"
/>
)}
{label && isNumeric(labelX) && isNumeric(labelY) ? (
<EdgeText
x={labelX}
y={labelY}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
/>
) : null}
</>
);
};
BaseEdge.displayName = 'BaseEdge';
export default BaseEdge;

View File

@@ -0,0 +1,59 @@
import { memo } from 'react';
import { Position, getBezierPath } from '@xyflow/system';
import BaseEdge from './BaseEdge';
import type { BezierEdgeProps } from '../../types';
const BezierEdge = memo(
({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
pathOptions,
interactionWidth,
}: BezierEdgeProps) => {
const [path, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: pathOptions?.curvature,
});
return (
<BaseEdge
path={path}
labelX={labelX}
labelY={labelY}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
style={style}
markerEnd={markerEnd}
markerStart={markerStart}
interactionWidth={interactionWidth}
/>
);
}
);
BezierEdge.displayName = 'BezierEdge';
export default BezierEdge;

View File

@@ -0,0 +1,51 @@
import type { FC, MouseEvent as ReactMouseEvent, SVGAttributes } from 'react';
import cc from 'classcat';
import { Position } from '@xyflow/system';
const shiftX = (x: number, shift: number, position: Position): number => {
if (position === Position.Left) return x - shift;
if (position === Position.Right) return x + shift;
return x;
};
const shiftY = (y: number, shift: number, position: Position): number => {
if (position === Position.Top) return y - shift;
if (position === Position.Bottom) return y + shift;
return y;
};
export interface EdgeAnchorProps extends SVGAttributes<SVGGElement> {
position: Position;
centerX: number;
centerY: number;
radius?: number;
onMouseDown: (event: ReactMouseEvent<SVGGElement, MouseEvent>) => void;
onMouseEnter: (event: ReactMouseEvent<SVGGElement, MouseEvent>) => void;
onMouseOut: (event: ReactMouseEvent<SVGGElement, MouseEvent>) => void;
type: string;
}
const EdgeUpdaterClassName = 'react-flow__edgeupdater';
export const EdgeAnchor: FC<EdgeAnchorProps> = ({
position,
centerX,
centerY,
radius = 10,
onMouseDown,
onMouseEnter,
onMouseOut,
type,
}: EdgeAnchorProps) => (
<circle
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
onMouseOut={onMouseOut}
className={cc([EdgeUpdaterClassName, `${EdgeUpdaterClassName}-${type}`])}
cx={shiftX(centerX, radius, position)}
cy={shiftY(centerY, radius, position)}
r={radius}
stroke="transparent"
fill="transparent"
/>
);

View File

@@ -0,0 +1,67 @@
import { memo, useRef, useState, useEffect, type FC, type PropsWithChildren } from 'react';
import cc from 'classcat';
import type { Rect } from '@xyflow/system';
import type { EdgeTextProps } from '../../types';
const EdgeText: FC<PropsWithChildren<EdgeTextProps>> = ({
x,
y,
label,
labelStyle = {},
labelShowBg = true,
labelBgStyle = {},
labelBgPadding = [2, 4],
labelBgBorderRadius = 2,
children,
className,
...rest
}) => {
const edgeRef = useRef<SVGTextElement>(null);
const [edgeTextBbox, setEdgeTextBbox] = useState<Rect>({ x: 0, y: 0, width: 0, height: 0 });
const edgeTextClasses = cc(['react-flow__edge-textwrapper', className]);
useEffect(() => {
if (edgeRef.current) {
const textBbox = edgeRef.current.getBBox();
setEdgeTextBbox({
x: textBbox.x,
y: textBbox.y,
width: textBbox.width,
height: textBbox.height,
});
}
}, [label]);
if (typeof label === 'undefined' || !label) {
return null;
}
return (
<g
transform={`translate(${x - edgeTextBbox.width / 2} ${y - edgeTextBbox.height / 2})`}
className={edgeTextClasses}
visibility={edgeTextBbox.width ? 'visible' : 'hidden'}
{...rest}
>
{labelShowBg && (
<rect
width={edgeTextBbox.width + 2 * labelBgPadding[0]}
x={-labelBgPadding[0]}
y={-labelBgPadding[1]}
height={edgeTextBbox.height + 2 * labelBgPadding[1]}
className="react-flow__edge-textbg"
style={labelBgStyle}
rx={labelBgBorderRadius}
ry={labelBgBorderRadius}
/>
)}
<text className="react-flow__edge-text" y={edgeTextBbox.height / 2} dy="0.3em" ref={edgeRef} style={labelStyle}>
{label}
</text>
{children}
</g>
);
};
export default memo(EdgeText);

View File

@@ -0,0 +1,124 @@
import { memo } from 'react';
import { Position, getBezierEdgeCenter } from '@xyflow/system';
import BaseEdge from './BaseEdge';
import type { EdgeProps } from '../../types';
export interface GetSimpleBezierPathParams {
sourceX: number;
sourceY: number;
sourcePosition?: Position;
targetX: number;
targetY: number;
targetPosition?: Position;
}
interface GetControlParams {
pos: Position;
x1: number;
y1: number;
x2: number;
y2: number;
}
function getControl({ pos, x1, y1, x2, y2 }: GetControlParams): [number, number] {
if (pos === Position.Left || pos === Position.Right) {
return [0.5 * (x1 + x2), y1];
}
return [x1, 0.5 * (y1 + y2)];
}
export function getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
}: GetSimpleBezierPathParams): [path: string, labelX: number, labelY: number, offsetX: number, offsetY: number] {
const [sourceControlX, sourceControlY] = getControl({
pos: sourcePosition,
x1: sourceX,
y1: sourceY,
x2: targetX,
y2: targetY,
});
const [targetControlX, targetControlY] = getControl({
pos: targetPosition,
x1: targetX,
y1: targetY,
x2: sourceX,
y2: sourceY,
});
const [labelX, labelY, offsetX, offsetY] = getBezierEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
sourceControlX,
sourceControlY,
targetControlX,
targetControlY,
});
return [
`M${sourceX},${sourceY} C${sourceControlX},${sourceControlY} ${targetControlX},${targetControlY} ${targetX},${targetY}`,
labelX,
labelY,
offsetX,
offsetY,
];
}
const SimpleBezierEdge = memo(
({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
interactionWidth,
}: EdgeProps) => {
const [path, labelX, labelY] = getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
return (
<BaseEdge
path={path}
labelX={labelX}
labelY={labelY}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
style={style}
markerEnd={markerEnd}
markerStart={markerStart}
interactionWidth={interactionWidth}
/>
);
}
);
SimpleBezierEdge.displayName = 'SimpleBezierEdge';
export default SimpleBezierEdge;

View File

@@ -0,0 +1,60 @@
import { memo } from 'react';
import { Position, getSmoothStepPath } from '@xyflow/system';
import BaseEdge from './BaseEdge';
import type { SmoothStepEdgeProps } from '../../types';
const SmoothStepEdge = memo(
({
sourceX,
sourceY,
targetX,
targetY,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
markerEnd,
markerStart,
pathOptions,
interactionWidth,
}: SmoothStepEdgeProps) => {
const [path, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: pathOptions?.borderRadius,
offset: pathOptions?.offset,
});
return (
<BaseEdge
path={path}
labelX={labelX}
labelY={labelY}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
style={style}
markerEnd={markerEnd}
markerStart={markerStart}
interactionWidth={interactionWidth}
/>
);
}
);
SmoothStepEdge.displayName = 'SmoothStepEdge';
export default SmoothStepEdge;

View File

@@ -0,0 +1,15 @@
import { memo, useMemo } from 'react';
import SmoothStepEdge from './SmoothStepEdge';
import type { SmoothStepEdgeProps } from '../../types';
const StepEdge = memo((props: SmoothStepEdgeProps) => (
<SmoothStepEdge
{...props}
pathOptions={useMemo(() => ({ borderRadius: 0, offset: props.pathOptions?.offset }), [props.pathOptions?.offset])}
/>
));
StepEdge.displayName = 'StepEdge';
export default StepEdge;

View File

@@ -0,0 +1,48 @@
import { memo } from 'react';
import { getStraightPath } from '@xyflow/system';
import BaseEdge from './BaseEdge';
import type { EdgeProps } from '../../types';
const StraightEdge = memo(
({
sourceX,
sourceY,
targetX,
targetY,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
markerEnd,
markerStart,
interactionWidth,
}: EdgeProps) => {
const [path, labelX, labelY] = getStraightPath({ sourceX, sourceY, targetX, targetY });
return (
<BaseEdge
path={path}
labelX={labelX}
labelY={labelY}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
style={style}
markerEnd={markerEnd}
markerStart={markerStart}
interactionWidth={interactionWidth}
/>
);
}
);
StraightEdge.displayName = 'StraightEdge';
export default StraightEdge;

View File

@@ -0,0 +1,5 @@
export { default as SimpleBezierEdge } from './SimpleBezierEdge';
export { default as SmoothStepEdge } from './SmoothStepEdge';
export { default as StepEdge } from './StepEdge';
export { default as StraightEdge } from './StraightEdge';
export { default as BezierEdge } from './BezierEdge';

View File

@@ -0,0 +1,20 @@
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<ReactFlowState>['getState'],
handler?: (event: ReactMouseEvent<SVGGElement, MouseEvent>, edge: Edge) => void
) {
return handler === undefined
? handler
: (event: ReactMouseEvent<SVGGElement, MouseEvent>) => {
const edge = getState().edges.find((e) => e.id === id);
if (edge) {
handler(event, { ...edge });
}
};
}

View File

@@ -0,0 +1,265 @@
import { memo, useState, useMemo, useRef, type ComponentType, type KeyboardEvent } from 'react';
import cc from 'classcat';
import { getMarkerId, elementSelectionKeys, XYHandle, type Connection } from '@xyflow/system';
import { useStoreApi } from '../../hooks/useStore';
import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions';
import { EdgeAnchor } from './EdgeAnchor';
import { getMouseHandler } from './utils';
import type { EdgeProps, WrapEdgeProps } from '../../types';
const alwaysValidConnection = () => true;
export default (EdgeComponent: ComponentType<EdgeProps>) => {
const EdgeWrapper = ({
id,
className,
type,
data,
onClick,
onEdgeDoubleClick,
selected,
animated,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
style,
source,
target,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
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<SVGGElement>(null);
const [updateHover, setUpdateHover] = useState<boolean>(false);
const [updating, setUpdating] = useState<boolean>(false);
const store = useStoreApi();
const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]);
const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]);
if (hidden) {
return null;
}
const onEdgeClick = (event: React.MouseEvent<SVGGElement, MouseEvent>): void => {
const { edges, addSelectedEdges } = store.getState();
if (isSelectable) {
store.setState({ nodesSelectionActive: false });
addSelectedEdges([id]);
}
if (onClick) {
const edge = edges.find((e) => e.id === id)!;
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<SVGGElement, MouseEvent>, isSourceHandle: boolean) => {
// avoid triggering edge updater if mouse btn is not left
if (event.button !== 0) {
return;
}
const {
autoPanOnConnect,
domNode,
edges,
isValidConnection: isValidConnectionStore,
connectionMode,
connectionRadius,
transform,
lib,
onConnectStart,
onConnectEnd,
cancelConnection,
getNodes,
panBy,
updateConnection,
} = store.getState();
const nodeId = isSourceHandle ? target : source;
const handleId = (isSourceHandle ? targetHandleId : sourceHandleId) || null;
const handleType = isSourceHandle ? 'target' : 'source';
const isValidConnection = isValidConnectionStore || alwaysValidConnection;
const isTarget = isSourceHandle;
const edge = edges.find((e) => e.id === id)!;
const nodes = getNodes();
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,
transform,
lib,
cancelConnection,
panBy,
isValidConnection,
onConnect: onConnectEdge,
onConnectStart,
onConnectEnd,
onEdgeUpdateEnd: _onEdgeUpdateEnd,
updateConnection,
});
};
const onEdgeUpdaterSourceMouseDown = (event: React.MouseEvent<SVGGElement, MouseEvent>): void =>
handleEdgeUpdater(event, true);
const onEdgeUpdaterTargetMouseDown = (event: React.MouseEvent<SVGGElement, 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 (
<g
className={cc([
'react-flow__edge',
`react-flow__edge-${type}`,
className,
{ selected, animated, inactive, updating: updateHover },
])}
onClick={onEdgeClick}
onDoubleClick={onEdgeDoubleClickHandler}
onContextMenu={onEdgeContextMenu}
onMouseEnter={onEdgeMouseEnter}
onMouseMove={onEdgeMouseMove}
onMouseLeave={onEdgeMouseLeave}
onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined}
role={isFocusable ? 'button' : undefined}
data-testid={`rf__edge-${id}`}
aria-label={ariaLabel === null ? undefined : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`}
aria-describedby={isFocusable ? `${ARIA_EDGE_DESC_KEY}-${rfId}` : undefined}
ref={edgeRef}
>
{!updating && (
<EdgeComponent
id={id}
source={source}
target={target}
selected={selected}
animated={animated}
label={label}
labelStyle={labelStyle}
labelShowBg={labelShowBg}
labelBgStyle={labelBgStyle}
labelBgPadding={labelBgPadding}
labelBgBorderRadius={labelBgBorderRadius}
data={data}
style={style}
sourceX={sourceX}
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
sourcePosition={sourcePosition}
targetPosition={targetPosition}
sourceHandleId={sourceHandleId}
targetHandleId={targetHandleId}
markerStart={markerStartUrl}
markerEnd={markerEndUrl}
pathOptions={pathOptions}
interactionWidth={interactionWidth}
/>
)}
{isUpdatable && (
<>
{(isUpdatable === 'source' || isUpdatable === true) && (
<EdgeAnchor
position={sourcePosition}
centerX={sourceX}
centerY={sourceY}
radius={edgeUpdaterRadius}
onMouseDown={onEdgeUpdaterSourceMouseDown}
onMouseEnter={onEdgeUpdaterMouseEnter}
onMouseOut={onEdgeUpdaterMouseOut}
type="source"
/>
)}
{(isUpdatable === 'target' || isUpdatable === true) && (
<EdgeAnchor
position={targetPosition}
centerX={targetX}
centerY={targetY}
radius={edgeUpdaterRadius}
onMouseDown={onEdgeUpdaterTargetMouseDown}
onMouseEnter={onEdgeUpdaterMouseEnter}
onMouseOut={onEdgeUpdaterMouseOut}
type="target"
/>
)}
</>
)}
</g>
);
};
EdgeWrapper.displayName = 'EdgeWrapper';
return memo(EdgeWrapper);
};

View File

@@ -0,0 +1,216 @@
import { memo, HTMLAttributes, forwardRef, MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import {
errorMessages,
Position,
XYHandle,
getHostForElement,
isMouseEvent,
type HandleProps,
type Connection,
type HandleType,
} from '@xyflow/system';
import { useStore, useStoreApi } from '../../hooks/useStore';
import { useNodeId } from '../../contexts/NodeIdContext';
import { addEdge } from '../../utils/';
import { type ReactFlowState } from '../../types';
export type HandleComponentProps = HandleProps & Omit<HTMLAttributes<HTMLDivElement>, 'id'>;
const selector = (s: ReactFlowState) => ({
connectionStartHandle: s.connectionStartHandle,
connectOnClick: s.connectOnClick,
noPanClassName: s.noPanClassName,
});
const connectingSelector =
(nodeId: string | null, handleId: string | null, type: HandleType) => (state: ReactFlowState) => {
const {
connectionStartHandle: startHandle,
connectionEndHandle: endHandle,
connectionClickStartHandle: clickHandle,
} = state;
return {
connecting:
(startHandle?.nodeId === nodeId && startHandle?.handleId === handleId && startHandle?.type === type) ||
(endHandle?.nodeId === nodeId && endHandle?.handleId === handleId && endHandle?.type === type),
clickConnecting:
clickHandle?.nodeId === nodeId && clickHandle?.handleId === handleId && clickHandle?.type === type,
};
};
const Handle = forwardRef<HTMLDivElement, HandleComponentProps>(
(
{
type = 'source',
position = Position.Top,
isValidConnection,
isConnectable = true,
isConnectableStart = true,
isConnectableEnd = true,
id,
onConnect,
children,
className,
onMouseDown,
onTouchStart,
...rest
},
ref
) => {
const handleId = id || null;
const isTarget = type === 'target';
const store = useStoreApi();
const nodeId = useNodeId();
const { connectOnClick, noPanClassName } = useStore(selector, shallow);
const { connecting, clickConnecting } = useStore(connectingSelector(nodeId, handleId, type), shallow);
if (!nodeId) {
store.getState().onError?.('010', errorMessages['error010']());
}
const onConnectExtended = (params: Connection) => {
const { defaultEdgeOptions, onConnect: onConnectAction, hasDefaultEdges } = store.getState();
const edgeParams = {
...defaultEdgeOptions,
...params,
};
if (hasDefaultEdges) {
const { edges, setEdges } = store.getState();
setEdges(addEdge(edgeParams, edges));
}
onConnectAction?.(edgeParams);
onConnect?.(edgeParams);
};
const onPointerDown = (event: ReactMouseEvent<HTMLDivElement> | ReactTouchEvent<HTMLDivElement>) => {
if (!nodeId) {
return;
}
const isMouseTriggered = isMouseEvent(event.nativeEvent);
if (
isConnectableStart &&
((isMouseTriggered && (event as ReactMouseEvent<HTMLDivElement>).button === 0) || !isMouseTriggered)
) {
const currentStore = store.getState();
XYHandle.onPointerDown(event.nativeEvent, {
autoPanOnConnect: currentStore.autoPanOnConnect,
connectionMode: currentStore.connectionMode,
connectionRadius: currentStore.connectionRadius,
domNode: currentStore.domNode,
nodes: currentStore.getNodes(),
lib: currentStore.lib,
isTarget,
handleId,
nodeId,
panBy: currentStore.panBy,
cancelConnection: currentStore.cancelConnection,
onConnectStart: currentStore.onConnectStart,
onConnectEnd: currentStore.onConnectEnd,
updateConnection: currentStore.updateConnection,
onConnect: onConnectExtended,
isValidConnection: isValidConnection || currentStore.isValidConnection,
getTransform: () => store.getState().transform,
});
}
if (isMouseTriggered) {
onMouseDown?.(event as ReactMouseEvent<HTMLDivElement>);
} else {
onTouchStart?.(event as ReactTouchEvent<HTMLDivElement>);
}
};
const onClick = (event: ReactMouseEvent) => {
const {
onClickConnectStart,
onClickConnectEnd,
connectionClickStartHandle,
connectionMode,
isValidConnection: isValidConnectionStore,
lib,
} = store.getState();
if (!nodeId || (!connectionClickStartHandle && !isConnectableStart)) {
return;
}
if (!connectionClickStartHandle) {
onClickConnectStart?.(event.nativeEvent, { nodeId, handleId, handleType: type });
store.setState({ connectionClickStartHandle: { nodeId, type, handleId } });
return;
}
const doc = getHostForElement(event.target as HTMLElement);
const isValidConnectionHandler = isValidConnection || isValidConnectionStore;
const { connection, isValid } = XYHandle.isValid(event.nativeEvent, {
handle: {
nodeId,
id: handleId,
type,
},
connectionMode,
fromNodeId: connectionClickStartHandle.nodeId,
fromHandleId: connectionClickStartHandle.handleId || null,
fromType: connectionClickStartHandle.type,
isValidConnection: isValidConnectionHandler,
doc,
lib,
});
if (isValid) {
onConnectExtended(connection);
}
onClickConnectEnd?.(event as unknown as MouseEvent);
store.setState({ connectionClickStartHandle: null });
};
return (
<div
data-handleid={handleId}
data-nodeid={nodeId}
data-handlepos={position}
data-id={`${nodeId}-${handleId}-${type}`}
className={cc([
'react-flow__handle',
`react-flow__handle-${position}`,
'nodrag',
noPanClassName,
className,
{
source: !isTarget,
target: isTarget,
connectable: isConnectable,
connectablestart: isConnectableStart,
connectableend: isConnectableEnd,
connecting: clickConnecting,
// this class is used to style the handle when the user is connecting
connectionindicator:
isConnectable && ((isConnectableStart && !connecting) || (isConnectableEnd && connecting)),
},
])}
onMouseDown={onPointerDown}
onTouchStart={onPointerDown}
onClick={connectOnClick ? onClick : undefined}
ref={ref}
{...rest}
>
{children}
</div>
);
}
);
Handle.displayName = 'Handle';
export default memo(Handle);

View File

@@ -0,0 +1,23 @@
import { memo } from 'react';
import { Position, type NodeProps } from '@xyflow/system';
import Handle from '../../components/Handle';
const DefaultNode = ({
data,
isConnectable,
targetPosition = Position.Top,
sourcePosition = Position.Bottom,
}: NodeProps) => {
return (
<>
<Handle type="target" position={targetPosition} isConnectable={isConnectable} />
{data?.label}
<Handle type="source" position={sourcePosition} isConnectable={isConnectable} />
</>
);
};
DefaultNode.displayName = 'DefaultNode';
export default memo(DefaultNode);

View File

@@ -0,0 +1,5 @@
const GroupNode = () => null;
GroupNode.displayName = 'GroupNode';
export default GroupNode;

View File

@@ -0,0 +1,15 @@
import { memo } from 'react';
import { Position, type NodeProps } from '@xyflow/system';
import Handle from '../../components/Handle';
const InputNode = ({ data, isConnectable, sourcePosition = Position.Bottom }: NodeProps) => (
<>
{data?.label}
<Handle type="source" position={sourcePosition} isConnectable={isConnectable} />
</>
);
InputNode.displayName = 'InputNode';
export default memo(InputNode);

View File

@@ -0,0 +1,15 @@
import { memo } from 'react';
import { Position, type NodeProps } from '@xyflow/system';
import Handle from '../../components/Handle';
const OutputNode = ({ data, isConnectable, targetPosition = Position.Top }: NodeProps) => (
<>
<Handle type="target" position={targetPosition} isConnectable={isConnectable} />
{data?.label}
</>
);
OutputNode.displayName = 'OutputNode';
export default memo(OutputNode);

View File

@@ -0,0 +1,49 @@
import type { MouseEvent, RefObject } from 'react';
import type { StoreApi } from 'zustand';
import type { Node, ReactFlowState } from '../../types';
export function getMouseHandler(
id: string,
getState: StoreApi<ReactFlowState>['getState'],
handler?: (event: MouseEvent, node: Node) => void
) {
return handler === undefined
? handler
: (event: MouseEvent) => {
const node = getState().nodeInternals.get(id)!;
handler(event, { ...node });
};
}
// this handler is called by
// 1. the click handler when node is not draggable or selectNodesOnDrag = false
// or
// 2. the on drag start handler when node is draggable and selectNodesOnDrag = true
export function handleNodeClick({
id,
store,
unselect = false,
nodeRef,
}: {
id: string;
store: {
getState: StoreApi<ReactFlowState>['getState'];
setState: StoreApi<ReactFlowState>['setState'];
};
unselect?: boolean;
nodeRef?: RefObject<HTMLDivElement>;
}) {
const { addSelectedNodes, unselectNodesAndEdges, multiSelectionActive, nodeInternals } = store.getState();
const node = nodeInternals.get(id)!;
store.setState({ nodesSelectionActive: false });
if (!node.selected) {
addSelectedNodes([id]);
} else if (unselect || (node.selected && multiSelectionActive)) {
unselectNodesAndEdges({ nodes: [node] });
requestAnimationFrame(() => nodeRef?.current?.blur());
}
}

View File

@@ -0,0 +1,224 @@
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<string, XYPosition> = {
ArrowUp: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 1 },
ArrowLeft: { x: -1, y: 0 },
ArrowRight: { x: 1, y: 0 },
};
export default (NodeComponent: ComponentType<NodeProps>) => {
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,
}: WrapNodeProps) => {
const store = useStoreApi();
const nodeRef = useRef<HTMLDivElement>(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 } = store.getState();
if (isSelectable && (!selectNodesOnDrag || !isDraggable)) {
// this handler gets called within the drag start event when selectNodesOnDrag=true
handleNodeClick({
id,
store,
nodeRef,
});
}
if (onClick) {
const node = store.getState().nodeInternals.get(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([{ 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 (
<div
className={cc([
'react-flow__node',
`react-flow__node-${type}`,
{
// this is overwritable by passing `nopan` as a class name
[noPanClassName]: isDraggable,
},
className,
{
selected,
selectable: isSelectable,
parent: isParent,
dragging,
},
])}
ref={nodeRef}
style={{
zIndex,
transform: `translate(${xPosOrigin}px,${yPosOrigin}px)`,
pointerEvents: hasPointerEvents ? 'all' : 'none',
visibility: initialized ? 'visible' : 'hidden',
...style,
}}
data-id={id}
data-testid={`rf__node-${id}`}
onMouseEnter={onMouseEnterHandler}
onMouseMove={onMouseMoveHandler}
onMouseLeave={onMouseLeaveHandler}
onContextMenu={onContextMenuHandler}
onClick={onSelectNodeHandler}
onDoubleClick={onDoubleClickHandler}
onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined}
role={isFocusable ? 'button' : undefined}
aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${rfId}`}
aria-label={ariaLabel}
>
<Provider value={id}>
<NodeComponent
id={id}
data={data}
type={type}
xPos={xPos}
yPos={yPos}
selected={selected}
isConnectable={isConnectable}
sourcePosition={sourcePosition}
targetPosition={targetPosition}
dragging={dragging}
dragHandle={dragHandle}
zIndex={zIndex}
/>
</Provider>
</div>
);
};
NodeWrapper.displayName = 'NodeWrapper';
return memo(NodeWrapper);
};

View File

@@ -0,0 +1,99 @@
/**
* The nodes selection rectangle gets displayed when a user
* made a selection with on or several nodes
*/
import { memo, useRef, useEffect, type MouseEvent, type KeyboardEvent } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { getRectOfNodes } 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';
export interface NodesSelectionProps {
onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void;
noPanClassName?: string;
disableKeyboardA11y: boolean;
}
const selector = (s: ReactFlowState) => {
const selectedNodes = s.getNodes().filter((n) => n.selected);
return {
...getRectOfNodes(selectedNodes, s.nodeOrigin),
transformString: `translate(${s.transform[0]}px,${s.transform[1]}px) scale(${s.transform[2]})`,
userSelectionActive: s.userSelectionActive,
};
};
function NodesSelection({ onSelectionContextMenu, noPanClassName, disableKeyboardA11y }: NodesSelectionProps) {
const store = useStoreApi();
const { width, height, x: left, y: top, transformString, userSelectionActive } = useStore(selector, shallow);
const updatePositions = useUpdateNodePositions();
const nodeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!disableKeyboardA11y) {
nodeRef.current?.focus({
preventScroll: true,
});
}
}, [disableKeyboardA11y]);
useDrag({
nodeRef,
});
if (userSelectionActive || !width || !height) {
return null;
}
const onContextMenu = onSelectionContextMenu
? (event: MouseEvent) => {
const selectedNodes = store
.getState()
.getNodes()
.filter((n) => n.selected);
onSelectionContextMenu(event, selectedNodes);
}
: undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key)) {
updatePositions({
x: arrowKeyDiffs[event.key].x,
y: arrowKeyDiffs[event.key].y,
isShiftPressed: event.shiftKey,
});
}
};
return (
<div
className={cc(['react-flow__nodesselection', 'react-flow__container', noPanClassName])}
style={{
transform: transformString,
}}
>
<div
ref={nodeRef}
className="react-flow__nodesselection-rect"
onContextMenu={onContextMenu}
tabIndex={disableKeyboardA11y ? undefined : -1}
onKeyDown={disableKeyboardA11y ? undefined : onKeyDown}
style={{
width,
height,
top,
left,
}}
/>
</div>
);
}
export default memo(NodesSelection);

View File

@@ -0,0 +1,30 @@
import type { HTMLAttributes, ReactNode } from 'react';
import cc from 'classcat';
import type { PanelPosition } from '@xyflow/system';
import { useStore } from '../../hooks/useStore';
import type { ReactFlowState } from '../../types';
export type PanelProps = HTMLAttributes<HTMLDivElement> & {
position: PanelPosition;
children: ReactNode;
};
const selector = (s: ReactFlowState) => (s.userSelectionActive ? 'none' : 'all');
function Panel({ position, children, className, style, ...rest }: PanelProps) {
const pointerEvents = useStore(selector);
const positionClasses = `${position}`.split('-');
return (
<div
className={cc(['react-flow__panel', className, ...positionClasses])}
style={{ ...style, pointerEvents }}
{...rest}
>
{children}
</div>
);
}
export default Panel;

View File

@@ -0,0 +1,20 @@
import { useRef, type FC, type PropsWithChildren } from 'react';
import { type StoreApi } from 'zustand';
import { Provider } from '../../contexts/RFStoreContext';
import { createRFStore } from '../../store';
import type { ReactFlowState } from '../../types';
const ReactFlowProvider: FC<PropsWithChildren<unknown>> = ({ children }) => {
const storeRef = useRef<StoreApi<ReactFlowState> | null>(null);
if (!storeRef.current) {
storeRef.current = createRFStore();
}
return <Provider value={storeRef.current}>{children}</Provider>;
};
ReactFlowProvider.displayName = 'ReactFlowProvider';
export default ReactFlowProvider;

View File

@@ -0,0 +1,56 @@
import { memo, useEffect } from 'react';
import { shallow } from 'zustand/shallow';
import { useStore, useStoreApi } from '../../hooks/useStore';
import type { ReactFlowState, OnSelectionChangeFunc, Node, Edge } from '../../types';
type SelectionListenerProps = {
onSelectionChange?: OnSelectionChangeFunc;
};
const selector = (s: ReactFlowState) => ({
selectedNodes: s.getNodes().filter((n) => n.selected),
selectedEdges: s.edges.filter((e) => e.selected),
});
type SelectorSlice = ReturnType<typeof selector>;
const selectId = (obj: Node | Edge) => obj.id;
function areEqual(a: SelectorSlice, b: SelectorSlice) {
return (
shallow(a.selectedNodes.map(selectId), b.selectedNodes.map(selectId)) &&
shallow(a.selectedEdges.map(selectId), b.selectedEdges.map(selectId))
);
}
// This is just a helper component for calling the onSelectionChange listener.
// @TODO: Now that we have the onNodesChange and on EdgesChange listeners, do we still need this component?
const SelectionListener = memo(({ onSelectionChange }: SelectionListenerProps) => {
const store = useStoreApi();
const { selectedNodes, selectedEdges } = useStore(selector, areEqual);
useEffect(() => {
const params = { nodes: selectedNodes, edges: selectedEdges };
onSelectionChange?.(params);
store.getState().onSelectionChange?.(params);
}, [selectedNodes, selectedEdges, onSelectionChange]);
return null;
});
SelectionListener.displayName = 'SelectionListener';
const changeSelector = (s: ReactFlowState) => !!s.onSelectionChange;
function Wrapper({ onSelectionChange }: SelectionListenerProps) {
const storeHasSelectionChange = useStore(changeSelector);
if (onSelectionChange || storeHasSelectionChange) {
return <SelectionListener onSelectionChange={onSelectionChange} />;
}
return null;
}
export default Wrapper;

View File

@@ -0,0 +1,217 @@
import { useEffect } from 'react';
import { StoreApi } from 'zustand';
import { shallow } from 'zustand/shallow';
import type { CoordinateExtent } from '@xyflow/system';
import { useStore, useStoreApi } from '../../hooks/useStore';
import type { Node, Edge, ReactFlowState, ReactFlowProps, ReactFlowStore } from '../../types';
type StoreUpdaterProps = Pick<
ReactFlowProps,
| 'nodes'
| 'edges'
| 'defaultNodes'
| 'defaultEdges'
| 'onConnect'
| 'onConnectStart'
| 'onConnectEnd'
| 'onClickConnectStart'
| 'onClickConnectEnd'
| 'nodesDraggable'
| 'nodesConnectable'
| 'nodesFocusable'
| 'edgesFocusable'
| 'edgesUpdatable'
| 'minZoom'
| 'maxZoom'
| 'nodeExtent'
| 'onNodesChange'
| 'onEdgesChange'
| 'elementsSelectable'
| 'connectionMode'
| 'snapToGrid'
| 'snapGrid'
| 'translateExtent'
| 'connectOnClick'
| 'defaultEdgeOptions'
| 'fitView'
| 'fitViewOptions'
| 'onNodesDelete'
| 'onEdgesDelete'
| 'onNodeDragStart'
| 'onNodeDrag'
| 'onNodeDragStop'
| 'onSelectionDragStart'
| 'onSelectionDrag'
| 'onSelectionDragStop'
| 'onMove'
| 'onMoveStart'
| 'onMoveEnd'
| 'noPanClassName'
| 'nodeOrigin'
| 'elevateNodesOnSelect'
| 'autoPanOnConnect'
| 'autoPanOnNodeDrag'
| 'onError'
| 'connectionRadius'
| 'isValidConnection'
| 'selectNodesOnDrag'
> & { rfId: string };
const selector = (s: ReactFlowState) => ({
setNodes: s.setNodes,
setEdges: s.setEdges,
setDefaultNodesAndEdges: s.setDefaultNodesAndEdges,
setMinZoom: s.setMinZoom,
setMaxZoom: s.setMaxZoom,
setTranslateExtent: s.setTranslateExtent,
setNodeExtent: s.setNodeExtent,
reset: s.reset,
});
function useStoreUpdater<T>(value: T | undefined, setStoreState: (param: T) => void) {
useEffect(() => {
if (typeof value !== 'undefined') {
setStoreState(value);
}
}, [value]);
}
// updates with values in store that don't have a dedicated setter function
function useDirectStoreUpdater(
key: keyof ReactFlowStore,
value: unknown,
setState: StoreApi<ReactFlowState>['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,
onNodeDrag,
onNodeDragStart,
onNodeDragStop,
onSelectionDrag,
onSelectionDragStart,
onSelectionDragStop,
onMoveStart,
onMove,
onMoveEnd,
noPanClassName,
nodeOrigin,
rfId,
autoPanOnConnect,
autoPanOnNodeDrag,
onError,
connectionRadius,
isValidConnection,
selectNodesOnDrag,
}: StoreUpdaterProps) => {
const {
setNodes,
setEdges,
setDefaultNodesAndEdges,
setMinZoom,
setMaxZoom,
setTranslateExtent,
setNodeExtent,
reset,
} = useStore(selector, shallow);
const store = useStoreApi();
useEffect(() => {
const edgesWithDefaults = defaultEdges?.map((e) => ({ ...e, ...defaultEdgeOptions }));
setDefaultNodesAndEdges(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('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, store.setState);
useDirectStoreUpdater('connectionRadius', connectionRadius, store.setState);
useDirectStoreUpdater('isValidConnection', isValidConnection, store.setState);
useDirectStoreUpdater('selectNodesOnDrag', selectNodesOnDrag, store.setState);
useStoreUpdater<Node[]>(nodes, setNodes);
useStoreUpdater<Edge[]>(edges, setEdges);
useStoreUpdater<number>(minZoom, setMinZoom);
useStoreUpdater<number>(maxZoom, setMaxZoom);
useStoreUpdater<CoordinateExtent>(translateExtent, setTranslateExtent);
useStoreUpdater<CoordinateExtent>(nodeExtent, setNodeExtent);
return null;
};
export default StoreUpdater;

View File

@@ -0,0 +1,31 @@
import { shallow } from 'zustand/shallow';
import { useStore } from '../../hooks/useStore';
import type { ReactFlowState } from '../../types';
const selector = (s: ReactFlowState) => ({
userSelectionActive: s.userSelectionActive,
userSelectionRect: s.userSelectionRect,
});
function UserSelection() {
const { userSelectionActive, userSelectionRect } = useStore(selector, shallow);
const isActive = userSelectionActive && userSelectionRect;
if (!isActive) {
return null;
}
return (
<div
className="react-flow__selection react-flow__container"
style={{
width: userSelectionRect.width,
height: userSelectionRect.height,
transform: `translate(${userSelectionRect.x}px, ${userSelectionRect.y}px)`,
}}
/>
);
}
export default UserSelection;

View File

@@ -0,0 +1,85 @@
import { memo, useCallback } from 'react';
import { type MarkerProps, createMarkerIds } from '@xyflow/system';
import { useStore } from '../../hooks/useStore';
import { useMarkerSymbol } from './MarkerSymbols';
import type { ReactFlowState } from '../../types';
type MarkerDefinitionsProps = {
defaultColor: string;
rfId?: string;
};
const Marker = ({
id,
type,
color,
width = 12.5,
height = 12.5,
markerUnits = 'strokeWidth',
strokeWidth,
orient = 'auto-start-reverse',
}: MarkerProps) => {
const Symbol = useMarkerSymbol(type);
if (!Symbol) {
return null;
}
return (
<marker
className="react-flow__arrowhead"
id={id}
markerWidth={`${width}`}
markerHeight={`${height}`}
viewBox="-10 -10 20 20"
markerUnits={markerUnits}
orient={orient}
refX="0"
refY="0"
>
<Symbol color={color} strokeWidth={strokeWidth} />
</marker>
);
};
const markerSelector =
({ defaultColor, rfId }: { defaultColor: string; rfId?: string }) =>
(s: ReactFlowState) => {
const markers = createMarkerIds(s.edges, { id: rfId, defaultColor });
return markers;
};
// 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))
);
return (
<defs>
{markers.map((marker: MarkerProps) => (
<Marker
id={marker.id}
key={marker.id}
type={marker.type}
color={marker.color}
width={marker.width}
height={marker.height}
markerUnits={marker.markerUnits}
strokeWidth={marker.strokeWidth}
orient={marker.orient}
/>
))}
</defs>
);
};
MarkerDefinitions.displayName = 'MarkerDefinitions';
export default memo(MarkerDefinitions);

View File

@@ -0,0 +1,57 @@
import { useMemo } from 'react';
import { errorMessages, MarkerType, type EdgeMarker } from '@xyflow/system';
import { useStoreApi } from '../../hooks/useStore';
type SymbolProps = Omit<EdgeMarker, 'type'>;
const ArrowSymbol = ({ color = 'none', strokeWidth = 1 }: SymbolProps) => {
return (
<polyline
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={strokeWidth}
fill="none"
points="-5,-4 0,0 -5,4"
/>
);
};
const ArrowClosedSymbol = ({ color = 'none', strokeWidth = 1 }: SymbolProps) => {
return (
<polyline
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={strokeWidth}
fill={color}
points="-5,-4 0,0 -5,4 -5,-4"
/>
);
};
export const MarkerSymbols = {
[MarkerType.Arrow]: ArrowSymbol,
[MarkerType.ArrowClosed]: ArrowClosedSymbol,
};
export function useMarkerSymbol(type: MarkerType) {
const store = useStoreApi();
const symbol = useMemo(() => {
const symbolExists = Object.prototype.hasOwnProperty.call(MarkerSymbols, type);
if (!symbolExists) {
store.getState().onError?.('009', errorMessages['error009'](type));
return null;
}
return MarkerSymbols[type];
}, [type]);
return symbol;
}
export default MarkerSymbols;

View File

@@ -0,0 +1,196 @@
import { memo, ReactNode } from 'react';
import { shallow } from 'zustand/shallow';
import cc from 'classcat';
import { errorMessages, ConnectionMode, Position, getHandle } from '@xyflow/system';
import { useStore } from '../../hooks/useStore';
import useVisibleEdges from '../../hooks/useVisibleEdges';
import MarkerDefinitions from './MarkerDefinitions';
import { getEdgePositions, getNodeData } from './utils';
import { GraphViewProps } from '../GraphView';
import type { Edge, ReactFlowState } from '../../types';
type EdgeRendererProps = Pick<
GraphViewProps,
| 'edgeTypes'
| 'onEdgeClick'
| 'onEdgeDoubleClick'
| 'defaultMarkerColor'
| 'onlyRenderVisibleElements'
| 'onEdgeUpdate'
| 'onEdgeContextMenu'
| 'onEdgeMouseEnter'
| 'onEdgeMouseMove'
| 'onEdgeMouseLeave'
| 'onEdgeUpdateStart'
| 'onEdgeUpdateEnd'
| 'edgeUpdaterRadius'
| 'noPanClassName'
| 'elevateEdgesOnSelect'
| 'rfId'
| 'disableKeyboardA11y'
> & {
elevateEdgesOnSelect: boolean;
children: ReactNode;
};
const selector = (s: ReactFlowState) => ({
nodesConnectable: s.nodesConnectable,
edgesFocusable: s.edgesFocusable,
edgesUpdatable: s.edgesUpdatable,
elementsSelectable: s.elementsSelectable,
width: s.width,
height: s.height,
connectionMode: s.connectionMode,
nodeInternals: s.nodeInternals,
onError: s.onError,
});
const EdgeRenderer = ({
defaultMarkerColor,
onlyRenderVisibleElements,
elevateEdgesOnSelect,
rfId,
edgeTypes,
noPanClassName,
onEdgeUpdate,
onEdgeContextMenu,
onEdgeMouseEnter,
onEdgeMouseMove,
onEdgeMouseLeave,
onEdgeClick,
edgeUpdaterRadius,
onEdgeDoubleClick,
onEdgeUpdateStart,
onEdgeUpdateEnd,
children,
}: EdgeRendererProps) => {
const { edgesFocusable, edgesUpdatable, elementsSelectable, width, height, connectionMode, nodeInternals, onError } =
useStore(selector, shallow);
const edgeTree = useVisibleEdges(onlyRenderVisibleElements, nodeInternals, elevateEdgesOnSelect);
if (!width) {
return null;
}
return (
<>
{edgeTree.map(({ level, edges, isMaxLevel }) => (
<svg
key={level}
style={{ zIndex: level }}
width={width}
height={height}
className="react-flow__edges react-flow__container"
>
{isMaxLevel && <MarkerDefinitions defaultColor={defaultMarkerColor} rfId={rfId} />}
<g>
{edges.map((edge: Edge) => {
const [sourceNodeRect, sourceHandleBounds, sourceIsValid] = getNodeData(nodeInternals.get(edge.source));
const [targetNodeRect, targetHandleBounds, targetIsValid] = getNodeData(nodeInternals.get(edge.target));
if (!sourceIsValid || !targetIsValid) {
return null;
}
let edgeType = edge.type || 'default';
if (!edgeTypes[edgeType]) {
onError?.('011', errorMessages['error011'](edgeType));
edgeType = 'default';
}
const EdgeComponent = edgeTypes[edgeType] || edgeTypes.default;
// when connection type is loose we can define all handles as sources and connect source -> source
const targetNodeHandles =
connectionMode === ConnectionMode.Strict
? targetHandleBounds!.target
: (targetHandleBounds!.target ?? []).concat(targetHandleBounds!.source ?? []);
const sourceHandle = getHandle(sourceHandleBounds!.source!, edge.sourceHandle);
const targetHandle = getHandle(targetNodeHandles!, edge.targetHandle);
const sourcePosition = sourceHandle?.position || Position.Bottom;
const targetPosition = targetHandle?.position || Position.Top;
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')
);
if (!sourceHandle || !targetHandle) {
onError?.('008', errorMessages['error008'](sourceHandle, edge));
return null;
}
const { sourceX, sourceY, targetX, targetY } = getEdgePositions(
sourceNodeRect,
sourceHandle,
sourcePosition,
targetNodeRect,
targetHandle,
targetPosition
);
return (
<EdgeComponent
key={edge.id}
id={edge.id}
className={cc([edge.className, noPanClassName])}
type={edgeType}
data={edge.data}
selected={!!edge.selected}
animated={!!edge.animated}
hidden={!!edge.hidden}
label={edge.label}
labelStyle={edge.labelStyle}
labelShowBg={edge.labelShowBg}
labelBgStyle={edge.labelBgStyle}
labelBgPadding={edge.labelBgPadding}
labelBgBorderRadius={edge.labelBgBorderRadius}
style={edge.style}
source={edge.source}
target={edge.target}
sourceHandleId={edge.sourceHandle}
targetHandleId={edge.targetHandle}
markerEnd={edge.markerEnd}
markerStart={edge.markerStart}
sourceX={sourceX}
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
sourcePosition={sourcePosition}
targetPosition={targetPosition}
isSelectable={isSelectable}
onEdgeUpdate={onEdgeUpdate}
onContextMenu={onEdgeContextMenu}
onMouseEnter={onEdgeMouseEnter}
onMouseMove={onEdgeMouseMove}
onMouseLeave={onEdgeMouseLeave}
onClick={onEdgeClick}
edgeUpdaterRadius={edgeUpdaterRadius}
onEdgeDoubleClick={onEdgeDoubleClick}
onEdgeUpdateStart={onEdgeUpdateStart}
onEdgeUpdateEnd={onEdgeUpdateEnd}
rfId={rfId}
ariaLabel={edge.ariaLabel}
isFocusable={isFocusable}
isUpdatable={isUpdatable}
pathOptions={'pathOptions' in edge ? edge.pathOptions : undefined}
interactionWidth={edge.interactionWidth}
/>
);
})}
</g>
</svg>
))}
{children}
</>
);
};
EdgeRenderer.displayName = 'EdgeRenderer';
export default memo(EdgeRenderer);

View File

@@ -0,0 +1,142 @@
import type { ComponentType } from 'react';
import {
internalsSymbol,
Position,
rectToBox,
getHandlePosition,
type HandleElement,
type NodeHandleBounds,
type Rect,
type Transform,
type XYPosition,
} from '@xyflow/system';
import { BezierEdge, SmoothStepEdge, StepEdge, StraightEdge, SimpleBezierEdge } from '../../components/Edges';
import wrapEdge from '../../components/Edges/wrapEdge';
import type { EdgeProps, EdgeTypes, EdgeTypesWrapped, Node } from '../../types';
export type CreateEdgeTypes = (edgeTypes: EdgeTypes) => EdgeTypesWrapped;
export function createEdgeTypes(edgeTypes: EdgeTypes): EdgeTypesWrapped {
const standardTypes: EdgeTypesWrapped = {
default: wrapEdge((edgeTypes.default || BezierEdge) as ComponentType<EdgeProps>),
straight: wrapEdge((edgeTypes.bezier || StraightEdge) as ComponentType<EdgeProps>),
step: wrapEdge((edgeTypes.step || StepEdge) as ComponentType<EdgeProps>),
smoothstep: wrapEdge((edgeTypes.step || SmoothStepEdge) as ComponentType<EdgeProps>),
simplebezier: wrapEdge((edgeTypes.simplebezier || SimpleBezierEdge) as ComponentType<EdgeProps>),
};
const wrappedTypes = {} as EdgeTypesWrapped;
const specialTypes: EdgeTypesWrapped = Object.keys(edgeTypes)
.filter((k) => !['default', 'bezier'].includes(k))
.reduce((res, key) => {
res[key] = wrapEdge((edgeTypes[key] || BezierEdge) as ComponentType<EdgeProps>);
return res;
}, wrappedTypes);
return {
...standardTypes,
...specialTypes,
};
}
interface EdgePositions {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
}
export const getEdgePositions = (
sourceNodeRect: Rect,
sourceHandle: HandleElement,
sourcePosition: Position,
targetNodeRect: Rect,
targetHandle: HandleElement,
targetPosition: Position
): EdgePositions => {
const sourceHandlePos = getHandlePosition(sourcePosition, sourceNodeRect, sourceHandle);
const targetHandlePos = getHandlePosition(targetPosition, targetNodeRect, targetHandle);
return {
sourceX: sourceHandlePos.x,
sourceY: sourceHandlePos.y,
targetX: targetHandlePos.x,
targetY: targetHandlePos.y,
};
};
interface IsEdgeVisibleParams {
sourcePos: XYPosition;
targetPos: XYPosition;
sourceWidth: number;
sourceHeight: number;
targetWidth: number;
targetHeight: number;
width: number;
height: number;
transform: Transform;
}
export function isEdgeVisible({
sourcePos,
targetPos,
sourceWidth,
sourceHeight,
targetWidth,
targetHeight,
width,
height,
transform,
}: IsEdgeVisibleParams): boolean {
const edgeBox = {
x: Math.min(sourcePos.x, targetPos.x),
y: Math.min(sourcePos.y, targetPos.y),
x2: Math.max(sourcePos.x + sourceWidth, targetPos.x + targetWidth),
y2: Math.max(sourcePos.y + sourceHeight, targetPos.y + targetHeight),
};
if (edgeBox.x === edgeBox.x2) {
edgeBox.x2 += 1;
}
if (edgeBox.y === edgeBox.y2) {
edgeBox.y2 += 1;
}
const viewBox = rectToBox({
x: (0 - transform[0]) / transform[2],
y: (0 - transform[1]) / transform[2],
width: width / transform[2],
height: height / transform[2],
});
const xOverlap = Math.max(0, Math.min(viewBox.x2, edgeBox.x2) - Math.max(viewBox.x, edgeBox.x));
const yOverlap = Math.max(0, Math.min(viewBox.y2, edgeBox.y2) - Math.max(viewBox.y, edgeBox.y));
const overlappingArea = Math.ceil(xOverlap * yOverlap);
return overlappingArea > 0;
}
export function getNodeData(node?: Node): [Rect, NodeHandleBounds | null, boolean] {
const handleBounds = node?.[internalsSymbol]?.handleBounds || null;
const isValid =
handleBounds &&
node?.width &&
node?.height &&
typeof node?.positionAbsolute?.x !== 'undefined' &&
typeof node?.positionAbsolute?.y !== 'undefined';
return [
{
x: node?.positionAbsolute?.x || 0,
y: node?.positionAbsolute?.y || 0,
width: node?.width || 0,
height: node?.height || 0,
},
handleBounds,
!!isValid,
];
}

View File

@@ -0,0 +1,124 @@
import { memo, type ReactNode } from 'react';
import { useStore } from '../../hooks/useStore';
import useGlobalKeyHandler from '../../hooks/useGlobalKeyHandler';
import useKeyPress from '../../hooks/useKeyPress';
import { GraphViewProps } from '../GraphView';
import ZoomPane from '../ZoomPane';
import Pane from '../Pane';
import NodesSelection from '../../components/NodesSelection';
import type { ReactFlowState } from '../../types';
export type FlowRendererProps = Omit<
GraphViewProps,
| 'snapToGrid'
| 'nodeTypes'
| 'edgeTypes'
| 'snapGrid'
| 'connectionLineType'
| 'connectionLineContainerStyle'
| 'arrowHeadColor'
| 'onlyRenderVisibleElements'
| 'selectNodesOnDrag'
| 'defaultMarkerColor'
| 'rfId'
| 'nodeOrigin'
> & {
children: ReactNode;
};
const selector = (s: ReactFlowState) => s.nodesSelectionActive;
const FlowRenderer = ({
children,
onPaneClick,
onPaneMouseEnter,
onPaneMouseMove,
onPaneMouseLeave,
onPaneContextMenu,
onPaneScroll,
deleteKeyCode,
selectionKeyCode,
selectionOnDrag,
selectionMode,
onSelectionStart,
onSelectionEnd,
multiSelectionKeyCode,
panActivationKeyCode,
zoomActivationKeyCode,
elementsSelectable,
zoomOnScroll,
zoomOnPinch,
panOnScroll,
panOnScrollSpeed,
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag: _panOnDrag,
defaultViewport,
translateExtent,
minZoom,
maxZoom,
preventScrolling,
onSelectionContextMenu,
noWheelClassName,
noPanClassName,
disableKeyboardA11y,
}: FlowRendererProps) => {
const nodesSelectionActive = useStore(selector);
const selectionKeyPressed = useKeyPress(selectionKeyCode);
const panActivationKeyPressed = useKeyPress(panActivationKeyCode);
const panOnDrag = panActivationKeyPressed || _panOnDrag;
const isSelecting = selectionKeyPressed || (selectionOnDrag && panOnDrag !== true);
useGlobalKeyHandler({ deleteKeyCode, multiSelectionKeyCode });
return (
<ZoomPane
onPaneContextMenu={onPaneContextMenu}
elementsSelectable={elementsSelectable}
zoomOnScroll={zoomOnScroll}
zoomOnPinch={zoomOnPinch}
panOnScroll={panOnScroll}
panOnScrollSpeed={panOnScrollSpeed}
panOnScrollMode={panOnScrollMode}
zoomOnDoubleClick={zoomOnDoubleClick}
panOnDrag={!selectionKeyPressed && panOnDrag}
defaultViewport={defaultViewport}
translateExtent={translateExtent}
minZoom={minZoom}
maxZoom={maxZoom}
zoomActivationKeyCode={zoomActivationKeyCode}
preventScrolling={preventScrolling}
noWheelClassName={noWheelClassName}
noPanClassName={noPanClassName}
>
<Pane
onSelectionStart={onSelectionStart}
onSelectionEnd={onSelectionEnd}
onPaneClick={onPaneClick}
onPaneMouseEnter={onPaneMouseEnter}
onPaneMouseMove={onPaneMouseMove}
onPaneMouseLeave={onPaneMouseLeave}
onPaneContextMenu={onPaneContextMenu}
onPaneScroll={onPaneScroll}
panOnDrag={panOnDrag}
isSelecting={!!isSelecting}
selectionMode={selectionMode}
>
{children}
{nodesSelectionActive && (
<NodesSelection
onSelectionContextMenu={onSelectionContextMenu}
noPanClassName={noPanClassName}
disableKeyboardA11y={disableKeyboardA11y}
/>
)}
</Pane>
</ZoomPane>
);
};
FlowRenderer.displayName = 'FlowRenderer';
export default memo(FlowRenderer);

View File

@@ -0,0 +1,195 @@
import { memo } from 'react';
import FlowRenderer from '../FlowRenderer';
import NodeRenderer from '../NodeRenderer';
import EdgeRenderer from '../EdgeRenderer';
import ViewportWrapper from '../Viewport';
import useOnInitHandler from '../../hooks/useOnInitHandler';
import ConnectionLine from '../../components/ConnectionLine';
import type { EdgeTypesWrapped, NodeTypesWrapped, ReactFlowProps } from '../../types';
export type GraphViewProps = Omit<
ReactFlowProps,
'onSelectionChange' | 'nodes' | 'edges' | 'nodeTypes' | 'edgeTypes' | 'onMove' | 'onMoveStart' | 'onMoveEnd'
> &
Required<
Pick<
ReactFlowProps,
| 'selectionKeyCode'
| 'deleteKeyCode'
| 'multiSelectionKeyCode'
| 'connectionLineType'
| 'onlyRenderVisibleElements'
| 'translateExtent'
| 'minZoom'
| 'maxZoom'
| 'defaultMarkerColor'
| 'noDragClassName'
| 'noDragClassName'
| 'noWheelClassName'
| 'noPanClassName'
| 'defaultViewport'
| 'disableKeyboardA11y'
| 'nodeOrigin'
>
> & {
nodeTypes: NodeTypesWrapped;
edgeTypes: EdgeTypesWrapped;
rfId: string;
};
const GraphView = ({
nodeTypes,
edgeTypes,
onInit,
onNodeClick,
onEdgeClick,
onNodeDoubleClick,
onEdgeDoubleClick,
onNodeMouseEnter,
onNodeMouseMove,
onNodeMouseLeave,
onNodeContextMenu,
onSelectionContextMenu,
onSelectionStart,
onSelectionEnd,
connectionLineType,
connectionLineStyle,
connectionLineComponent,
connectionLineContainerStyle,
selectionKeyCode,
selectionOnDrag,
selectionMode,
multiSelectionKeyCode,
panActivationKeyCode,
zoomActivationKeyCode,
deleteKeyCode,
onlyRenderVisibleElements,
elementsSelectable,
defaultViewport,
translateExtent,
minZoom,
maxZoom,
preventScrolling,
defaultMarkerColor,
zoomOnScroll,
zoomOnPinch,
panOnScroll,
panOnScrollSpeed,
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag,
onPaneClick,
onPaneMouseEnter,
onPaneMouseMove,
onPaneMouseLeave,
onPaneScroll,
onPaneContextMenu,
onEdgeUpdate,
onEdgeContextMenu,
onEdgeMouseEnter,
onEdgeMouseMove,
onEdgeMouseLeave,
edgeUpdaterRadius,
onEdgeUpdateStart,
onEdgeUpdateEnd,
noDragClassName,
noWheelClassName,
noPanClassName,
elevateEdgesOnSelect,
disableKeyboardA11y,
nodeOrigin,
nodeExtent,
rfId,
}: GraphViewProps) => {
useOnInitHandler(onInit);
return (
<FlowRenderer
onPaneClick={onPaneClick}
onPaneMouseEnter={onPaneMouseEnter}
onPaneMouseMove={onPaneMouseMove}
onPaneMouseLeave={onPaneMouseLeave}
onPaneContextMenu={onPaneContextMenu}
onPaneScroll={onPaneScroll}
deleteKeyCode={deleteKeyCode}
selectionKeyCode={selectionKeyCode}
selectionOnDrag={selectionOnDrag}
selectionMode={selectionMode}
onSelectionStart={onSelectionStart}
onSelectionEnd={onSelectionEnd}
multiSelectionKeyCode={multiSelectionKeyCode}
panActivationKeyCode={panActivationKeyCode}
zoomActivationKeyCode={zoomActivationKeyCode}
elementsSelectable={elementsSelectable}
zoomOnScroll={zoomOnScroll}
zoomOnPinch={zoomOnPinch}
zoomOnDoubleClick={zoomOnDoubleClick}
panOnScroll={panOnScroll}
panOnScrollSpeed={panOnScrollSpeed}
panOnScrollMode={panOnScrollMode}
panOnDrag={panOnDrag}
defaultViewport={defaultViewport}
translateExtent={translateExtent}
minZoom={minZoom}
maxZoom={maxZoom}
onSelectionContextMenu={onSelectionContextMenu}
preventScrolling={preventScrolling}
noDragClassName={noDragClassName}
noWheelClassName={noWheelClassName}
noPanClassName={noPanClassName}
disableKeyboardA11y={disableKeyboardA11y}
>
<ViewportWrapper>
<EdgeRenderer
edgeTypes={edgeTypes}
onEdgeClick={onEdgeClick}
onEdgeDoubleClick={onEdgeDoubleClick}
onEdgeUpdate={onEdgeUpdate}
onlyRenderVisibleElements={onlyRenderVisibleElements}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseMove={onEdgeMouseMove}
onEdgeMouseLeave={onEdgeMouseLeave}
onEdgeUpdateStart={onEdgeUpdateStart}
onEdgeUpdateEnd={onEdgeUpdateEnd}
edgeUpdaterRadius={edgeUpdaterRadius}
defaultMarkerColor={defaultMarkerColor}
noPanClassName={noPanClassName}
elevateEdgesOnSelect={!!elevateEdgesOnSelect}
disableKeyboardA11y={disableKeyboardA11y}
rfId={rfId}
>
<ConnectionLine
style={connectionLineStyle}
type={connectionLineType}
component={connectionLineComponent}
containerStyle={connectionLineContainerStyle}
/>
</EdgeRenderer>
<div className="react-flow__edgelabel-renderer" />
<NodeRenderer
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseMove={onNodeMouseMove}
onNodeMouseLeave={onNodeMouseLeave}
onNodeContextMenu={onNodeContextMenu}
onlyRenderVisibleElements={onlyRenderVisibleElements}
noPanClassName={noPanClassName}
noDragClassName={noDragClassName}
disableKeyboardA11y={disableKeyboardA11y}
nodeOrigin={nodeOrigin}
nodeExtent={nodeExtent}
rfId={rfId}
/>
</ViewportWrapper>
</FlowRenderer>
);
};
GraphView.displayName = 'GraphView';
export default memo(GraphView);

View File

@@ -0,0 +1,146 @@
import { memo, useMemo, useEffect, useRef, type ComponentType } from 'react';
import { shallow } from 'zustand/shallow';
import { internalsSymbol, errorMessages, Position, clampPosition, getPositionWithOrigin } from '@xyflow/system';
import useVisibleNodes from '../../hooks/useVisibleNodes';
import { useStore } from '../../hooks/useStore';
import { containerStyle } from '../../styles';
import { GraphViewProps } from '../GraphView';
import type { ReactFlowState, WrapNodeProps } from '../../types';
type NodeRendererProps = Pick<
GraphViewProps,
| 'nodeTypes'
| 'onNodeClick'
| 'onNodeDoubleClick'
| 'onNodeMouseEnter'
| 'onNodeMouseMove'
| 'onNodeMouseLeave'
| 'onNodeContextMenu'
| 'onlyRenderVisibleElements'
| 'noPanClassName'
| 'noDragClassName'
| 'rfId'
| 'disableKeyboardA11y'
| 'nodeOrigin'
| 'nodeExtent'
>;
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<ResizeObserver>();
const resizeObserver = useMemo(() => {
if (typeof ResizeObserver === 'undefined') {
return null;
}
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const updates = entries.map((entry: ResizeObserverEntry) => ({
id: entry.target.getAttribute('data-id') as string,
nodeElement: entry.target as HTMLDivElement,
forceUpdate: true,
}));
updateNodeDimensions(updates);
});
resizeObserverRef.current = observer;
return observer;
}, []);
useEffect(() => {
return () => {
resizeObserverRef?.current?.disconnect();
};
}, []);
return (
<div className="react-flow__nodes" style={containerStyle}>
{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<WrapNodeProps>;
const isDraggable = !!(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined'));
const isSelectable = !!(node.selectable || (elementsSelectable && typeof node.selectable === 'undefined'));
const isConnectable = !!(node.connectable || (nodesConnectable && typeof node.connectable === 'undefined'));
const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined'));
const clampedPosition = props.nodeExtent
? clampPosition(node.positionAbsolute, props.nodeExtent)
: node.positionAbsolute;
const posX = clampedPosition?.x ?? 0;
const posY = clampedPosition?.y ?? 0;
const posOrigin = getPositionWithOrigin({
x: posX,
y: posY,
width: node.width ?? 0,
height: node.height ?? 0,
origin: node.origin || props.nodeOrigin,
});
return (
<NodeComponent
key={node.id}
id={node.id}
className={node.className}
style={node.style}
type={nodeType}
data={node.data}
sourcePosition={node.sourcePosition || Position.Bottom}
targetPosition={node.targetPosition || Position.Top}
hidden={node.hidden}
xPos={posX}
yPos={posY}
xPosOrigin={posOrigin.x}
yPosOrigin={posOrigin.y}
onClick={props.onNodeClick}
onMouseEnter={props.onNodeMouseEnter}
onMouseMove={props.onNodeMouseMove}
onMouseLeave={props.onNodeMouseLeave}
onContextMenu={props.onNodeContextMenu}
onDoubleClick={props.onNodeDoubleClick}
selected={!!node.selected}
isDraggable={isDraggable}
isSelectable={isSelectable}
isConnectable={isConnectable}
isFocusable={isFocusable}
resizeObserver={resizeObserver}
dragHandle={node.dragHandle}
zIndex={node[internalsSymbol]?.z ?? 0}
isParent={!!node[internalsSymbol]?.isParent}
noDragClassName={props.noDragClassName}
noPanClassName={props.noPanClassName}
initialized={!!node.width && !!node.height}
rfId={props.rfId}
disableKeyboardA11y={props.disableKeyboardA11y}
ariaLabel={node.ariaLabel}
/>
);
})}
</div>
);
};
NodeRenderer.displayName = 'NodeRenderer';
export default memo(NodeRenderer);

View File

@@ -0,0 +1,34 @@
import type { ComponentType } from 'react';
import type { NodeProps } from '@xyflow/system';
import DefaultNode from '../../components/Nodes/DefaultNode';
import InputNode from '../../components/Nodes/InputNode';
import OutputNode from '../../components/Nodes/OutputNode';
import GroupNode from '../../components/Nodes/GroupNode';
import wrapNode from '../../components/Nodes/wrapNode';
import type { NodeTypes, NodeTypesWrapped } from '../../types';
export type CreateNodeTypes = (nodeTypes: NodeTypes) => NodeTypesWrapped;
export function createNodeTypes(nodeTypes: NodeTypes): NodeTypesWrapped {
const standardTypes: NodeTypesWrapped = {
input: wrapNode((nodeTypes.input || InputNode) as ComponentType<NodeProps>),
default: wrapNode((nodeTypes.default || DefaultNode) as ComponentType<NodeProps>),
output: wrapNode((nodeTypes.output || OutputNode) as ComponentType<NodeProps>),
group: wrapNode((nodeTypes.group || GroupNode) as ComponentType<NodeProps>),
};
const wrappedTypes = {} as NodeTypesWrapped;
const specialTypes: NodeTypesWrapped = Object.keys(nodeTypes)
.filter((k) => !['input', 'default', 'output', 'group'].includes(k))
.reduce((res, key) => {
res[key] = wrapNode((nodeTypes[key] || DefaultNode) as ComponentType<NodeProps>);
return res;
}, wrappedTypes);
return {
...standardTypes,
...specialTypes,
};
}

View File

@@ -0,0 +1,236 @@
/**
* The user selection rectangle gets displayed when a user drags the mouse while pressing shift
*/
import { memo, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { shallow } from 'zustand/shallow';
import cc from 'classcat';
import { getNodesInside, getEventPosition, SelectionMode } from '@xyflow/system';
import UserSelection from '../../components/UserSelection';
import { containerStyle } from '../../styles';
import { useStore, useStoreApi } from '../../hooks/useStore';
import { getSelectionChanges, getConnectedEdges } from '../../utils';
import type { ReactFlowProps, ReactFlowState, NodeChange, EdgeChange, Node } from '../../types';
type PaneProps = {
isSelecting: boolean;
children: ReactNode;
} & Partial<
Pick<
ReactFlowProps,
| 'selectionMode'
| 'panOnDrag'
| 'onSelectionStart'
| 'onSelectionEnd'
| 'onPaneClick'
| 'onPaneContextMenu'
| 'onPaneScroll'
| 'onPaneMouseEnter'
| 'onPaneMouseMove'
| 'onPaneMouseLeave'
>
>;
const wrapHandler = (
handler: React.MouseEventHandler | undefined,
containerRef: React.MutableRefObject<HTMLDivElement | null>
): React.MouseEventHandler => {
return (event: ReactMouseEvent) => {
if (event.target !== containerRef.current) {
return;
}
handler?.(event);
};
};
const selector = (s: ReactFlowState) => ({
userSelectionActive: s.userSelectionActive,
elementsSelectable: s.elementsSelectable,
dragging: s.paneDragging,
});
const Pane = memo(
({
isSelecting,
selectionMode = SelectionMode.Full,
panOnDrag,
onSelectionStart,
onSelectionEnd,
onPaneClick,
onPaneContextMenu,
onPaneScroll,
onPaneMouseEnter,
onPaneMouseMove,
onPaneMouseLeave,
children,
}: PaneProps) => {
const container = useRef<HTMLDivElement | null>(null);
const store = useStoreApi();
const prevSelectedNodesCount = useRef<number>(0);
const prevSelectedEdgesCount = useRef<number>(0);
const containerBounds = useRef<DOMRect>();
const { userSelectionActive, elementsSelectable, dragging } = useStore(selector, shallow);
const resetUserSelection = () => {
store.setState({ userSelectionActive: false, userSelectionRect: null });
prevSelectedNodesCount.current = 0;
prevSelectedEdgesCount.current = 0;
};
const onClick = (event: ReactMouseEvent) => {
onPaneClick?.(event);
store.getState().resetSelectedElements();
store.setState({ nodesSelectionActive: false });
};
const onContextMenu = (event: ReactMouseEvent) => {
if (Array.isArray(panOnDrag) && panOnDrag?.includes(2)) {
event.preventDefault();
return;
}
onPaneContextMenu?.(event);
};
const onWheel = onPaneScroll ? (event: React.WheelEvent) => onPaneScroll(event) : undefined;
const onMouseDown = (event: ReactMouseEvent): void => {
const { resetSelectedElements, domNode } = store.getState();
containerBounds.current = domNode?.getBoundingClientRect();
if (
!elementsSelectable ||
!isSelecting ||
event.button !== 0 ||
event.target !== container.current ||
!containerBounds.current
) {
return;
}
const { x, y } = getEventPosition(event.nativeEvent, containerBounds.current);
resetSelectedElements();
store.setState({
userSelectionRect: {
width: 0,
height: 0,
startX: x,
startY: y,
x,
y,
},
});
onSelectionStart?.(event);
};
const onMouseMove = (event: ReactMouseEvent): void => {
const { userSelectionRect, edges, transform, onNodesChange, onEdgesChange, nodeOrigin, getNodes } =
store.getState();
if (!isSelecting || !containerBounds.current || !userSelectionRect) {
return;
}
store.setState({ userSelectionActive: true, nodesSelectionActive: false });
const mousePos = getEventPosition(event.nativeEvent, containerBounds.current);
const startX = userSelectionRect.startX ?? 0;
const startY = userSelectionRect.startY ?? 0;
const nextUserSelectRect = {
...userSelectionRect,
x: mousePos.x < startX ? mousePos.x : startX,
y: mousePos.y < startY ? mousePos.y : startY,
width: Math.abs(mousePos.x - startX),
height: Math.abs(mousePos.y - startY),
};
const nodes = getNodes();
const selectedNodes = getNodesInside<Node>(
nodes,
nextUserSelectRect,
transform,
selectionMode === SelectionMode.Partial,
true,
nodeOrigin
);
const selectedEdgeIds = getConnectedEdges(selectedNodes, edges).map((e) => e.id);
const selectedNodeIds = selectedNodes.map((n) => n.id);
if (prevSelectedNodesCount.current !== selectedNodeIds.length) {
prevSelectedNodesCount.current = selectedNodeIds.length;
const changes = getSelectionChanges(nodes, selectedNodeIds) as NodeChange[];
if (changes.length) {
onNodesChange?.(changes);
}
}
if (prevSelectedEdgesCount.current !== selectedEdgeIds.length) {
prevSelectedEdgesCount.current = selectedEdgeIds.length;
const changes = getSelectionChanges(edges, selectedEdgeIds) as EdgeChange[];
if (changes.length) {
onEdgesChange?.(changes);
}
}
store.setState({
userSelectionRect: nextUserSelectRect,
});
};
const onMouseUp = (event: ReactMouseEvent) => {
if (event.button !== 0) {
return;
}
const { userSelectionRect } = store.getState();
// We only want to trigger click functions when in selection mode if
// the user did not move the mouse.
if (!userSelectionActive && userSelectionRect && event.target === container.current) {
onClick?.(event);
}
store.setState({ nodesSelectionActive: prevSelectedNodesCount.current > 0 });
resetUserSelection();
onSelectionEnd?.(event);
};
const onMouseLeave = (event: ReactMouseEvent) => {
if (userSelectionActive) {
store.setState({ nodesSelectionActive: prevSelectedNodesCount.current > 0 });
onSelectionEnd?.(event);
}
resetUserSelection();
};
const hasActiveSelection = elementsSelectable && (isSelecting || userSelectionActive);
return (
<div
className={cc(['react-flow__pane', { dragging, selection: isSelecting }])}
onClick={hasActiveSelection ? undefined : wrapHandler(onClick, container)}
onContextMenu={wrapHandler(onContextMenu, container)}
onWheel={wrapHandler(onWheel, container)}
onMouseEnter={hasActiveSelection ? undefined : onPaneMouseEnter}
onMouseDown={hasActiveSelection ? onMouseDown : undefined}
onMouseMove={hasActiveSelection ? onMouseMove : onPaneMouseMove}
onMouseUp={hasActiveSelection ? onMouseUp : undefined}
onMouseLeave={hasActiveSelection ? onMouseLeave : onPaneMouseLeave}
ref={container}
style={containerStyle}
>
{children}
<UserSelection />
</div>
);
}
);
Pane.displayName = 'Pane';
export default Pane;

View File

@@ -0,0 +1,20 @@
import { useContext, type FC, type PropsWithChildren } from 'react';
import StoreContext from '../../contexts/RFStoreContext';
import ReactFlowProvider from '../../components/ReactFlowProvider';
const Wrapper: FC<PropsWithChildren<unknown>> = ({ children }) => {
const isWrapped = useContext(StoreContext);
if (isWrapped) {
// we need to wrap it with a fragment because it's not allowed for children to be a ReactNode
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18051
return <>{children}</>;
}
return <ReactFlowProvider>{children}</ReactFlowProvider>;
};
Wrapper.displayName = 'ReactFlowWrapper';
export default Wrapper;

View File

@@ -0,0 +1,319 @@
import { forwardRef, type CSSProperties } from 'react';
import cc from 'classcat';
import {
ConnectionLineType,
ConnectionMode,
PanOnScrollMode,
SelectionMode,
infiniteExtent,
type NodeOrigin,
type Viewport,
} from '@xyflow/system';
import Attribution from '../../components/Attribution';
import { BezierEdge, SmoothStepEdge, StepEdge, StraightEdge, SimpleBezierEdge } from '../../components/Edges';
import DefaultNode from '../../components/Nodes/DefaultNode';
import InputNode from '../../components/Nodes/InputNode';
import OutputNode from '../../components/Nodes/OutputNode';
import GroupNode from '../../components/Nodes/GroupNode';
import SelectionListener from '../../components/SelectionListener';
import StoreUpdater from '../../components/StoreUpdater';
import A11yDescriptions from '../../components/A11yDescriptions';
import { createEdgeTypes } from '../EdgeRenderer/utils';
import { createNodeTypes } from '../NodeRenderer/utils';
import GraphView from '../GraphView';
import Wrapper from './Wrapper';
import { useNodeOrEdgeTypes } from './utils';
import type {
EdgeTypes,
EdgeTypesWrapped,
NodeTypes,
NodeTypesWrapped,
ReactFlowProps,
ReactFlowRefType,
} from '../../types';
const defaultNodeTypes: NodeTypes = {
input: InputNode,
default: DefaultNode,
output: OutputNode,
group: GroupNode,
};
const defaultEdgeTypes: EdgeTypes = {
default: BezierEdge,
straight: StraightEdge,
step: StepEdge,
smoothstep: SmoothStepEdge,
simplebezier: SimpleBezierEdge,
};
const initNodeOrigin: NodeOrigin = [0, 0];
const initSnapGrid: [number, number] = [15, 15];
const initDefaultViewport: Viewport = { x: 0, y: 0, zoom: 1 };
const wrapperStyle: CSSProperties = {
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative',
zIndex: 0,
};
const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
(
{
nodes,
edges,
defaultNodes,
defaultEdges,
className,
nodeTypes = defaultNodeTypes,
edgeTypes = defaultEdgeTypes,
onNodeClick,
onEdgeClick,
onInit,
onMove,
onMoveStart,
onMoveEnd,
onConnect,
onConnectStart,
onConnectEnd,
onClickConnectStart,
onClickConnectEnd,
onNodeMouseEnter,
onNodeMouseMove,
onNodeMouseLeave,
onNodeContextMenu,
onNodeDoubleClick,
onNodeDragStart,
onNodeDrag,
onNodeDragStop,
onNodesDelete,
onEdgesDelete,
onSelectionChange,
onSelectionDragStart,
onSelectionDrag,
onSelectionDragStop,
onSelectionContextMenu,
onSelectionStart,
onSelectionEnd,
connectionMode = ConnectionMode.Strict,
connectionLineType = ConnectionLineType.Bezier,
connectionLineStyle,
connectionLineComponent,
connectionLineContainerStyle,
deleteKeyCode = 'Backspace',
selectionKeyCode = 'Shift',
selectionOnDrag = false,
selectionMode = SelectionMode.Full,
panActivationKeyCode = 'Space',
multiSelectionKeyCode = 'Meta',
zoomActivationKeyCode = 'Meta',
snapToGrid = false,
snapGrid = initSnapGrid,
onlyRenderVisibleElements = false,
selectNodesOnDrag = true,
nodesDraggable,
nodesConnectable,
nodesFocusable,
nodeOrigin = initNodeOrigin,
edgesFocusable,
edgesUpdatable,
elementsSelectable = true,
defaultViewport = initDefaultViewport,
minZoom = 0.5,
maxZoom = 2,
translateExtent = infiniteExtent,
preventScrolling = true,
nodeExtent,
defaultMarkerColor = '#b1b1b7',
zoomOnScroll = true,
zoomOnPinch = true,
panOnScroll = false,
panOnScrollSpeed = 0.5,
panOnScrollMode = PanOnScrollMode.Free,
zoomOnDoubleClick = true,
panOnDrag = true,
onPaneClick,
onPaneMouseEnter,
onPaneMouseMove,
onPaneMouseLeave,
onPaneScroll,
onPaneContextMenu,
children,
onEdgeUpdate,
onEdgeContextMenu,
onEdgeDoubleClick,
onEdgeMouseEnter,
onEdgeMouseMove,
onEdgeMouseLeave,
onEdgeUpdateStart,
onEdgeUpdateEnd,
edgeUpdaterRadius = 10,
onNodesChange,
onEdgesChange,
noDragClassName = 'nodrag',
noWheelClassName = 'nowheel',
noPanClassName = 'nopan',
fitView = false,
fitViewOptions,
connectOnClick = true,
attributionPosition,
proOptions,
defaultEdgeOptions,
elevateNodesOnSelect = true,
elevateEdgesOnSelect = false,
disableKeyboardA11y = false,
autoPanOnConnect = true,
autoPanOnNodeDrag = true,
connectionRadius = 20,
isValidConnection,
onError,
style,
id,
...rest
},
ref
) => {
const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes) as NodeTypesWrapped;
const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes) as EdgeTypesWrapped;
const rfId = id || '1';
return (
<div
{...rest}
style={{ ...style, ...wrapperStyle }}
ref={ref}
className={cc(['react-flow', className])}
data-testid="rf__wrapper"
id={id}
>
<Wrapper>
<GraphView
onInit={onInit}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseMove={onNodeMouseMove}
onNodeMouseLeave={onNodeMouseLeave}
onNodeContextMenu={onNodeContextMenu}
onNodeDoubleClick={onNodeDoubleClick}
nodeTypes={nodeTypesWrapped}
edgeTypes={edgeTypesWrapped}
connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle}
connectionLineComponent={connectionLineComponent}
connectionLineContainerStyle={connectionLineContainerStyle}
selectionKeyCode={selectionKeyCode}
selectionOnDrag={selectionOnDrag}
selectionMode={selectionMode}
deleteKeyCode={deleteKeyCode}
multiSelectionKeyCode={multiSelectionKeyCode}
panActivationKeyCode={panActivationKeyCode}
zoomActivationKeyCode={zoomActivationKeyCode}
onlyRenderVisibleElements={onlyRenderVisibleElements}
defaultViewport={defaultViewport}
translateExtent={translateExtent}
minZoom={minZoom}
maxZoom={maxZoom}
preventScrolling={preventScrolling}
zoomOnScroll={zoomOnScroll}
zoomOnPinch={zoomOnPinch}
zoomOnDoubleClick={zoomOnDoubleClick}
panOnScroll={panOnScroll}
panOnScrollSpeed={panOnScrollSpeed}
panOnScrollMode={panOnScrollMode}
panOnDrag={panOnDrag}
onPaneClick={onPaneClick}
onPaneMouseEnter={onPaneMouseEnter}
onPaneMouseMove={onPaneMouseMove}
onPaneMouseLeave={onPaneMouseLeave}
onPaneScroll={onPaneScroll}
onPaneContextMenu={onPaneContextMenu}
onSelectionContextMenu={onSelectionContextMenu}
onSelectionStart={onSelectionStart}
onSelectionEnd={onSelectionEnd}
onEdgeUpdate={onEdgeUpdate}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseMove={onEdgeMouseMove}
onEdgeMouseLeave={onEdgeMouseLeave}
onEdgeUpdateStart={onEdgeUpdateStart}
onEdgeUpdateEnd={onEdgeUpdateEnd}
edgeUpdaterRadius={edgeUpdaterRadius}
defaultMarkerColor={defaultMarkerColor}
noDragClassName={noDragClassName}
noWheelClassName={noWheelClassName}
noPanClassName={noPanClassName}
elevateEdgesOnSelect={elevateEdgesOnSelect}
rfId={rfId}
disableKeyboardA11y={disableKeyboardA11y}
nodeOrigin={nodeOrigin}
nodeExtent={nodeExtent}
/>
<StoreUpdater
nodes={nodes}
edges={edges}
defaultNodes={defaultNodes}
defaultEdges={defaultEdges}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onClickConnectStart={onClickConnectStart}
onClickConnectEnd={onClickConnectEnd}
nodesDraggable={nodesDraggable}
nodesConnectable={nodesConnectable}
nodesFocusable={nodesFocusable}
edgesFocusable={edgesFocusable}
edgesUpdatable={edgesUpdatable}
elementsSelectable={elementsSelectable}
elevateNodesOnSelect={elevateNodesOnSelect}
minZoom={minZoom}
maxZoom={maxZoom}
nodeExtent={nodeExtent}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
snapToGrid={snapToGrid}
snapGrid={snapGrid}
connectionMode={connectionMode}
translateExtent={translateExtent}
connectOnClick={connectOnClick}
defaultEdgeOptions={defaultEdgeOptions}
fitView={fitView}
fitViewOptions={fitViewOptions}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onSelectionDrag={onSelectionDrag}
onSelectionDragStart={onSelectionDragStart}
onSelectionDragStop={onSelectionDragStop}
onMove={onMove}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
noPanClassName={noPanClassName}
nodeOrigin={nodeOrigin}
rfId={rfId}
autoPanOnConnect={autoPanOnConnect}
autoPanOnNodeDrag={autoPanOnNodeDrag}
onError={onError}
connectionRadius={connectionRadius}
isValidConnection={isValidConnection}
selectNodesOnDrag={selectNodesOnDrag}
/>
<SelectionListener onSelectionChange={onSelectionChange} />
{children}
<Attribution proOptions={proOptions} position={attributionPosition} />
<A11yDescriptions rfId={rfId} disableKeyboardA11y={disableKeyboardA11y} />
</Wrapper>
</div>
);
}
);
ReactFlow.displayName = 'ReactFlow';
export default ReactFlow;

View File

@@ -0,0 +1,28 @@
import { useMemo, useRef } from 'react';
import { shallow } from 'zustand/shallow';
import { errorMessages, devWarn } from '@xyflow/system';
import { CreateEdgeTypes } from '../EdgeRenderer/utils';
import { CreateNodeTypes } from '../NodeRenderer/utils';
import type { EdgeTypes, EdgeTypesWrapped, NodeTypes, NodeTypesWrapped } from '../../types';
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<string[] | null>(null);
const typesParsed = useMemo(() => {
if (process.env.NODE_ENV === 'development') {
const typeKeys = Object.keys(nodeOrEdgeTypes);
if (shallow(typesKeysRef.current, typeKeys)) {
devWarn('002', errorMessages['error002']());
}
typesKeysRef.current = typeKeys;
}
return createTypes(nodeOrEdgeTypes);
}, [nodeOrEdgeTypes]);
return typesParsed;
}

View File

@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
import { useStore } from '../../hooks/useStore';
import type { ReactFlowState } from '../../types';
const selector = (s: ReactFlowState) => `translate(${s.transform[0]}px,${s.transform[1]}px) scale(${s.transform[2]})`;
type ViewportProps = {
children: ReactNode;
};
function Viewport({ children }: ViewportProps) {
const transform = useStore(selector);
return (
<div className="react-flow__viewport react-flow__container" style={{ transform }}>
{children}
</div>
);
}
export default Viewport;

View File

@@ -0,0 +1,137 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef } from 'react';
import { shallow } from 'zustand/shallow';
import { XYPanZoom, PanOnScrollMode, type Transform, type PanZoomInstance } from '@xyflow/system';
import useKeyPress from '../../hooks/useKeyPress';
import useResizeHandler from '../../hooks/useResizeHandler';
import { useStore, useStoreApi } from '../../hooks/useStore';
import { containerStyle } from '../../styles';
import type { FlowRendererProps } from '../FlowRenderer';
import type { ReactFlowState } from '../../types';
type ZoomPaneProps = Omit<
FlowRendererProps,
| 'deleteKeyCode'
| 'selectionKeyCode'
| 'multiSelectionKeyCode'
| 'noDragClassName'
| 'disableKeyboardA11y'
| 'selectionOnDrag'
>;
const selector = (s: ReactFlowState) => ({
userSelectionActive: s.userSelectionActive,
lib: s.lib,
});
const ZoomPane = ({
onPaneContextMenu,
zoomOnScroll = true,
zoomOnPinch = true,
panOnScroll = false,
panOnScrollSpeed = 0.5,
panOnScrollMode = PanOnScrollMode.Free,
zoomOnDoubleClick = true,
panOnDrag = true,
defaultViewport,
translateExtent,
minZoom,
maxZoom,
zoomActivationKeyCode,
preventScrolling = true,
children,
noWheelClassName,
noPanClassName,
}: ZoomPaneProps) => {
const store = useStoreApi();
const zoomPane = useRef<HTMLDivElement>(null);
const { userSelectionActive, lib } = useStore(selector, shallow);
const zoomActivationKeyPressed = useKeyPress(zoomActivationKeyCode);
const panZoom = useRef<PanZoomInstance>();
useResizeHandler(zoomPane);
useEffect(() => {
if (zoomPane.current) {
panZoom.current = XYPanZoom({
domNode: zoomPane.current,
minZoom,
maxZoom,
translateExtent,
viewport: defaultViewport,
onTransformChange: (transform: Transform) => store.setState({ transform }),
onDraggingChange: (paneDragging: boolean) => store.setState({ paneDragging }),
onPanZoomStart: (event, vp) => {
const { onViewportChangeStart, onMoveStart } = store.getState();
onMoveStart?.(event, vp);
onViewportChangeStart?.(vp);
},
onPanZoom: (event, vp) => {
const { onViewportChange, onMove } = store.getState();
onMove?.(event, vp);
onViewportChange?.(vp);
},
onPanZoomEnd: (event, vp) => {
const { onViewportChangeEnd, onMoveEnd } = store.getState();
onMoveEnd?.(event, vp);
onViewportChangeEnd?.(vp);
},
});
const { x, y, zoom } = panZoom.current!.getViewport();
store.setState({
panZoom: panZoom.current,
transform: [x, y, zoom],
domNode: zoomPane.current.closest('.react-flow') as HTMLDivElement,
});
return () => {
panZoom.current?.destroy();
};
}
}, []);
useEffect(() => {
panZoom.current?.update({
onPaneContextMenu,
zoomOnScroll,
zoomOnPinch,
panOnScroll,
panOnScrollSpeed,
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag,
zoomActivationKeyPressed,
preventScrolling,
noPanClassName,
userSelectionActive,
noWheelClassName,
lib,
});
}, [
onPaneContextMenu,
zoomOnScroll,
zoomOnPinch,
panOnScroll,
panOnScrollSpeed,
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag,
zoomActivationKeyPressed,
preventScrolling,
noPanClassName,
userSelectionActive,
noWheelClassName,
lib,
]);
return (
<div className="react-flow__renderer" ref={zoomPane} style={containerStyle}>
{children}
</div>
);
};
export default ZoomPane;

View File

@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
export const NodeIdContext = createContext<string | null>(null);
export const Provider = NodeIdContext.Provider;
export const Consumer = NodeIdContext.Consumer;
export const useNodeId = (): string | null => {
const nodeId = useContext(NodeIdContext);
return nodeId;
};
export default NodeIdContext;

View File

@@ -0,0 +1,8 @@
import { createContext } from 'react';
import { createRFStore } from '../store';
const StoreContext = createContext<ReturnType<typeof createRFStore> | null>(null);
export const Provider = StoreContext.Provider;
export default StoreContext;

13
packages/react/src/custom.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
declare module '*.css' {
const content: { [className: string]: string };
export default content;
}
type SvgrComponent = React.FunctionComponent<React.SVGAttributes<SVGElement>>;
declare module '*.svg' {
const svgUrl: string;
const svgComponent: SvgrComponent;
export default svgUrl;
export { svgComponent as ReactComponent };
}

View File

@@ -0,0 +1,72 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { XYDrag, type XYDragInstance } from '@xyflow/system';
import { handleNodeClick } from '../components/Nodes/utils';
import { useStoreApi } from './useStore';
type UseDragParams = {
nodeRef: RefObject<Element>;
disabled?: boolean;
noDragClassName?: string;
handleSelector?: string;
nodeId?: string;
isSelectable?: boolean;
};
function useDrag({ nodeRef, disabled = false, noDragClassName, handleSelector, nodeId, isSelectable }: UseDragParams) {
const store = useStoreApi();
const [dragging, setDragging] = useState<boolean>(false);
const xyDrag = useRef<XYDragInstance>();
useEffect(() => {
if (nodeRef?.current) {
xyDrag.current = XYDrag({
domNode: nodeRef.current,
getStoreItems: () => {
const currentStore = store.getState();
return {
nodes: currentStore.getNodes(),
...store.getState(),
};
},
onNodeClick: () => {
if (nodeId) {
handleNodeClick({
id: nodeId,
store,
nodeRef: nodeRef as RefObject<HTMLDivElement>,
});
}
},
onDragStart: () => {
setDragging(true);
},
onDragStop: () => {
setDragging(false);
},
});
}
}, []);
useEffect(() => {
if (disabled) {
xyDrag.current?.destroy();
} else {
xyDrag.current?.update({
noDragClassName,
handleSelector,
domNode: nodeRef.current as Element,
isSelectable,
nodeId,
});
return () => {
xyDrag.current?.destroy();
};
}
}, [noDragClassName, handleSelector, disabled, isSelectable, nodeRef, nodeId]);
return dragging;
}
export default useDrag;

View File

@@ -0,0 +1,14 @@
import { shallow } from 'zustand/shallow';
import { useStore } from '../hooks/useStore';
import type { Edge, ReactFlowState } from '../types';
const edgesSelector = (state: ReactFlowState) => state.edges;
function useEdges<EdgeData>(): Edge<EdgeData>[] {
const edges = useStore(edgesSelector, shallow);
return edges;
}
export default useEdges;

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import type { KeyCode } from '@xyflow/system';
import { useStoreApi } from '../hooks/useStore';
import useKeyPress from './useKeyPress';
import useReactFlow from './useReactFlow';
export default ({
deleteKeyCode,
multiSelectionKeyCode,
}: {
deleteKeyCode: KeyCode | null;
multiSelectionKeyCode: KeyCode | null;
}): void => {
const store = useStoreApi();
const { deleteElements } = useReactFlow();
const deleteKeyPressed = useKeyPress(deleteKeyCode);
const multiSelectionKeyPressed = useKeyPress(multiSelectionKeyCode);
useEffect(() => {
if (deleteKeyPressed) {
const { edges, getNodes } = store.getState();
const selectedNodes = getNodes().filter((node) => node.selected);
const selectedEdges = edges.filter((edge) => edge.selected);
deleteElements({ nodes: selectedNodes, edges: selectedEdges });
store.setState({ nodesSelectionActive: false });
}
}, [deleteKeyPressed]);
useEffect(() => {
store.setState({ multiSelectionActive: multiSelectionKeyPressed });
}, [multiSelectionKeyPressed]);
};

View File

@@ -0,0 +1,114 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { isInputDOMNode, type KeyCode } from '@xyflow/system';
type Keys = Array<string>;
type PressedKeys = Set<string>;
type KeyOrCode = 'key' | 'code';
export type UseKeyPressOptions = {
target: Window | Document | HTMLElement | ShadowRoot | null;
};
const doc = typeof document !== 'undefined' ? document : null;
// the keycode can be a string 'a' or an array of strings ['a', 'a+d']
// a string means a single key 'a' or a combination when '+' is used 'a+d'
// an array means different possibilites. Explainer: ['a', 'd+s'] here the
// user can use the single key 'a' or the combination 'd' + 's'
export default (keyCode: KeyCode | null = null, options: UseKeyPressOptions = { target: doc }): boolean => {
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<PressedKeys>(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<[Array<Keys>, Keys]>(() => {
if (keyCode !== null) {
const keyCodeArr = Array.isArray(keyCode) ? keyCode : [keyCode];
const keys = keyCodeArr.filter((kc) => typeof kc === 'string').map((kc) => kc.split('+'));
const keysFlat = keys.reduce((res: Keys, item) => res.concat(...item), []);
return [keys, keysFlat];
}
return [[], []];
}, [keyCode]);
useEffect(() => {
if (keyCode !== null) {
const downHandler = (event: KeyboardEvent) => {
modifierPressed.current = event.ctrlKey || event.metaKey || event.shiftKey;
if (!modifierPressed.current && isInputDOMNode(event)) {
return false;
}
const keyOrCode = useKeyOrCode(event.code, keysToWatch);
pressedKeys.current.add(event[keyOrCode]);
if (isMatchingKey(keyCodes, pressedKeys.current, false)) {
event.preventDefault();
setKeyPressed(true);
}
};
const upHandler = (event: KeyboardEvent) => {
if (!modifierPressed.current && isInputDOMNode(event)) {
return false;
}
const keyOrCode = useKeyOrCode(event.code, keysToWatch);
if (isMatchingKey(keyCodes, pressedKeys.current, true)) {
setKeyPressed(false);
pressedKeys.current.clear();
} else {
pressedKeys.current.delete(event[keyOrCode]);
}
modifierPressed.current = false;
};
const resetHandler = () => {
pressedKeys.current.clear();
setKeyPressed(false);
};
options?.target?.addEventListener('keydown', downHandler as EventListenerOrEventListenerObject);
options?.target?.addEventListener('keyup', upHandler as EventListenerOrEventListenerObject);
window.addEventListener('blur', resetHandler);
return () => {
options?.target?.removeEventListener('keydown', downHandler as EventListenerOrEventListenerObject);
options?.target?.removeEventListener('keyup', upHandler as EventListenerOrEventListenerObject);
window.removeEventListener('blur', resetHandler);
};
}
}, [keyCode, setKeyPressed]);
return keyPressed;
};
// utils
function isMatchingKey(keyCodes: Array<Keys>, pressedKeys: PressedKeys, isUp: boolean): boolean {
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: string, keysToWatch: KeyCode): KeyOrCode {
return keysToWatch.includes(eventCode) ? 'code' : 'key';
}

View File

@@ -0,0 +1,14 @@
import { shallow } from 'zustand/shallow';
import { useStore } from '../hooks/useStore';
import type { Node, ReactFlowState } from '../types';
const nodesSelector = (state: ReactFlowState) => state.getNodes();
function useNodes<NodeData>(): Node<NodeData>[] {
const nodes = useStore(nodesSelector, shallow);
return nodes;
}
export default useNodes;

View File

@@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useCallback, type SetStateAction, type Dispatch } from 'react';
import { applyNodeChanges, applyEdgeChanges } from '../utils/changes';
import type { Node, NodeChange, Edge, EdgeChange } from '../types';
type ApplyChanges<ItemType, ChangesType> = (changes: ChangesType[], items: ItemType[]) => ItemType[];
type OnChange<ChangesType> = (changes: ChangesType[]) => void;
// returns a hook that can be used liked this:
// const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
function createUseItemsState(
applyChanges: ApplyChanges<Node, NodeChange>
): <NodeData = any>(
initialItems: Node<NodeData>[]
) => [Node<NodeData>[], Dispatch<SetStateAction<Node<NodeData>[]>>, OnChange<NodeChange>];
function createUseItemsState(
applyChanges: ApplyChanges<Edge, EdgeChange>
): <EdgeData = any>(
initialItems: Edge<EdgeData>[]
) => [Edge<EdgeData>[], Dispatch<SetStateAction<Edge<EdgeData>[]>>, OnChange<EdgeChange>];
function createUseItemsState(
applyChanges: ApplyChanges<any, any>
): (initialItems: any[]) => [any[], Dispatch<SetStateAction<any[]>>, OnChange<any>] {
return (initialItems: any[]) => {
const [items, setItems] = useState(initialItems);
const onItemsChange = useCallback((changes: any[]) => setItems((items: any) => applyChanges(changes, items)), []);
return [items, setItems, onItemsChange];
};
}
export const useNodesState = createUseItemsState(applyNodeChanges);
export const useEdgesState = createUseItemsState(applyEdgeChanges);

View File

@@ -0,0 +1,31 @@
import { internalsSymbol } from '@xyflow/system';
import { useStore } from './useStore';
import type { ReactFlowState } from '../types';
export type UseNodesInitializedOptions = {
includeHiddenNodes?: boolean;
};
const selector = (options: UseNodesInitializedOptions) => (s: ReactFlowState) => {
if (s.nodeInternals.size === 0) {
return false;
}
return s
.getNodes()
.filter((n) => (options.includeHiddenNodes ? true : !n.hidden))
.every((n) => n[internalsSymbol]?.handleBounds !== undefined);
};
const defaultOptions = {
includeHiddenNodes: false,
};
function useNodesInitialized(options: UseNodesInitializedOptions = defaultOptions): boolean {
const initialized = useStore(selector(options));
return initialized;
}
export default useNodesInitialized;

View File

@@ -0,0 +1,18 @@
import { useEffect, useRef } from 'react';
import useReactFlow from './useReactFlow';
import type { OnInit } from '../types';
function useOnInitHandler(onInit: OnInit | undefined) {
const rfInstance = useReactFlow();
const isInitialized = useRef<boolean>(false);
useEffect(() => {
if (!isInitialized.current && rfInstance.viewportInitialized && onInit) {
setTimeout(() => onInit(rfInstance), 1);
isInitialized.current = true;
}
}, [onInit, rfInstance.viewportInitialized]);
}
export default useOnInitHandler;

View File

@@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { useStoreApi } from './useStore';
import type { OnSelectionChangeFunc } from '../types';
export type UseOnSelectionChangeOptions = {
onChange?: OnSelectionChangeFunc;
};
function useOnSelectionChange({ onChange }: UseOnSelectionChangeOptions) {
const store = useStoreApi();
useEffect(() => {
store.setState({ onSelectionChange: onChange });
}, [onChange]);
}
export default useOnSelectionChange;

View File

@@ -0,0 +1,28 @@
import { useEffect } from 'react';
import type { OnViewportChange } from '@xyflow/system';
import { useStoreApi } from './useStore';
export type UseOnViewportChangeOptions = {
onStart?: OnViewportChange;
onChange?: OnViewportChange;
onEnd?: OnViewportChange;
};
function useOnViewportChange({ onStart, onChange, onEnd }: UseOnViewportChangeOptions) {
const store = useStoreApi();
useEffect(() => {
store.setState({ onViewportChangeStart: onStart });
}, [onStart]);
useEffect(() => {
store.setState({ onViewportChange: onChange });
}, [onChange]);
useEffect(() => {
store.setState({ onViewportChangeEnd: onEnd });
}, [onEnd]);
}
export default useOnViewportChange;

View File

@@ -0,0 +1,267 @@
import { useCallback, useMemo } from 'react';
import { getElementsToRemove, getOverlappingArea, isRectObject, nodeToRect, type Rect } from '@xyflow/system';
import useViewportHelper from './useViewportHelper';
import { useStoreApi } from '../hooks/useStore';
import type {
ReactFlowInstance,
Instance,
NodeAddChange,
EdgeAddChange,
NodeResetChange,
EdgeResetChange,
NodeRemoveChange,
EdgeRemoveChange,
NodeChange,
Node,
Edge,
} from '../types';
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export default function useReactFlow<NodeData = any, EdgeData = any>(): ReactFlowInstance<NodeData, EdgeData> {
const viewportHelper = useViewportHelper();
const store = useStoreApi();
const getNodes = useCallback<Instance.GetNodes<NodeData>>(() => {
return store
.getState()
.getNodes()
.map((n) => ({ ...n }));
}, []);
const getNode = useCallback<Instance.GetNode<NodeData>>((id) => {
return store.getState().nodeInternals.get(id);
}, []);
const getEdges = useCallback<Instance.GetEdges<EdgeData>>(() => {
const { edges = [] } = store.getState();
return edges.map((e) => ({ ...e }));
}, []);
const getEdge = useCallback<Instance.GetEdge<EdgeData>>((id) => {
const { edges = [] } = store.getState();
return edges.find((e) => e.id === id);
}, []);
const setNodes = useCallback<Instance.SetNodes<NodeData>>((payload) => {
const { getNodes, setNodes, hasDefaultNodes, onNodesChange } = store.getState();
const nodes = getNodes();
const nextNodes = typeof payload === 'function' ? payload(nodes) : payload;
if (hasDefaultNodes) {
setNodes(nextNodes);
} else if (onNodesChange) {
const changes =
nextNodes.length === 0
? nodes.map((node) => ({ type: 'remove', id: node.id } as NodeRemoveChange))
: nextNodes.map((node) => ({ item: node, type: 'reset' } as NodeResetChange<NodeData>));
onNodesChange(changes);
}
}, []);
const setEdges = useCallback<Instance.SetEdges<EdgeData>>((payload) => {
const { edges = [], setEdges, hasDefaultEdges, onEdgesChange } = store.getState();
const nextEdges = typeof payload === 'function' ? payload(edges) : payload;
if (hasDefaultEdges) {
setEdges(nextEdges);
} else if (onEdgesChange) {
const changes =
nextEdges.length === 0
? edges.map((edge) => ({ type: 'remove', id: edge.id } as EdgeRemoveChange))
: nextEdges.map((edge) => ({ item: edge, type: 'reset' } as EdgeResetChange<EdgeData>));
onEdgesChange(changes);
}
}, []);
const addNodes = useCallback<Instance.AddNodes<NodeData>>((payload) => {
const nodes = Array.isArray(payload) ? payload : [payload];
const { getNodes, setNodes, hasDefaultNodes, onNodesChange } = store.getState();
if (hasDefaultNodes) {
const currentNodes = getNodes();
const nextNodes = [...currentNodes, ...nodes];
setNodes(nextNodes);
} else if (onNodesChange) {
const changes = nodes.map((node) => ({ item: node, type: 'add' } as NodeAddChange<NodeData>));
onNodesChange(changes);
}
}, []);
const addEdges = useCallback<Instance.AddEdges<EdgeData>>((payload) => {
const nextEdges = Array.isArray(payload) ? payload : [payload];
const { edges = [], setEdges, hasDefaultEdges, onEdgesChange } = store.getState();
if (hasDefaultEdges) {
setEdges([...edges, ...nextEdges]);
} else if (onEdgesChange) {
const changes = nextEdges.map((edge) => ({ item: edge, type: 'add' } as EdgeAddChange<EdgeData>));
onEdgesChange(changes);
}
}, []);
const toObject = useCallback<Instance.ToObject<NodeData, EdgeData>>(() => {
const { getNodes, edges = [], transform } = store.getState();
const [x, y, zoom] = transform;
return {
nodes: getNodes().map((n) => ({ ...n })),
edges: edges.map((e) => ({ ...e })),
viewport: {
x,
y,
zoom,
},
};
}, []);
const deleteElements = useCallback<Instance.DeleteElements>(({ nodes: nodesDeleted, edges: edgesDeleted }) => {
const {
nodeInternals,
getNodes,
edges,
hasDefaultNodes,
hasDefaultEdges,
onNodesDelete,
onEdgesDelete,
onNodesChange,
onEdgesChange,
} = store.getState();
const { matchingNodes, matchingEdges } = getElementsToRemove<Node, Edge>({
nodesToRemove: nodesDeleted || [],
edgesToRemove: edgesDeleted || [],
nodes: getNodes(),
edges,
});
if (matchingNodes.length || matchingEdges.length) {
if (hasDefaultEdges || hasDefaultNodes) {
if (hasDefaultEdges) {
store.setState({
edges: edges.filter((e) => !matchingEdges.some((mE) => mE.id === e.id)),
});
}
if (hasDefaultNodes) {
matchingNodes.forEach((node) => {
nodeInternals.delete(node.id);
});
store.setState({
nodeInternals: new Map(nodeInternals),
});
}
}
if (matchingEdges.length > 0) {
onEdgesDelete?.(matchingEdges);
if (onEdgesChange) {
onEdgesChange(
matchingEdges.map((edge) => ({
id: edge.id,
type: 'remove',
}))
);
}
}
if (matchingNodes.length > 0) {
onNodesDelete?.(matchingNodes as Node[]);
if (onNodesChange) {
const nodeChanges: NodeChange[] = matchingNodes.map((node) => ({ id: node.id, type: 'remove' }));
onNodesChange(nodeChanges);
}
}
}
}, []);
const getNodeRect = useCallback(
(
nodeOrRect: (Partial<Node<NodeData>> & { id: Node['id'] }) | Rect
): [Rect | null, Node<NodeData> | null | undefined, boolean] => {
const isRect = isRectObject(nodeOrRect);
const node = isRect ? null : store.getState().nodeInternals.get(nodeOrRect.id);
if (!isRect && !node) {
[null, null, isRect];
}
const nodeRect = isRect ? nodeOrRect : nodeToRect(node!);
return [nodeRect, node, isRect];
},
[]
);
const getIntersectingNodes = useCallback<Instance.GetIntersectingNodes<NodeData>>(
(nodeOrRect, partially = true, nodes) => {
const [nodeRect, node, isRect] = getNodeRect(nodeOrRect);
if (!nodeRect) {
return [];
}
return (nodes || store.getState().getNodes()).filter((n) => {
if (!isRect && (n.id === node!.id || !n.positionAbsolute)) {
return false;
}
const currNodeRect = nodeToRect(n);
const overlappingArea = getOverlappingArea(currNodeRect, nodeRect);
const partiallyVisible = partially && overlappingArea > 0;
return partiallyVisible || overlappingArea >= nodeOrRect.width! * nodeOrRect.height!;
});
},
[]
);
const isNodeIntersecting = useCallback<Instance.IsNodeIntersecting<NodeData>>(
(nodeOrRect, area, partially = true) => {
const [nodeRect] = getNodeRect(nodeOrRect);
if (!nodeRect) {
return false;
}
const overlappingArea = getOverlappingArea(nodeRect, area);
const partiallyVisible = partially && overlappingArea > 0;
return partiallyVisible || overlappingArea >= nodeOrRect.width! * nodeOrRect.height!;
},
[]
);
return useMemo(() => {
return {
...viewportHelper,
getNodes,
getNode,
getEdges,
getEdge,
setNodes,
setEdges,
addNodes,
addEdges,
toObject,
deleteElements,
getIntersectingNodes,
isNodeIntersecting,
};
}, [
viewportHelper,
getNodes,
getNode,
getEdges,
getEdge,
setNodes,
setEdges,
addNodes,
addEdges,
toObject,
deleteElements,
getIntersectingNodes,
isNodeIntersecting,
]);
}

View File

@@ -0,0 +1,44 @@
import { useEffect, type MutableRefObject } from 'react';
import { errorMessages, getDimensions } from '@xyflow/system';
import { useStoreApi } from '../hooks/useStore';
function useResizeHandler(rendererNode: MutableRefObject<HTMLDivElement | null>): void {
const store = useStoreApi();
useEffect(() => {
let resizeObserver: ResizeObserver;
const updateDimensions = () => {
if (!rendererNode.current) {
return;
}
const size = getDimensions(rendererNode.current);
if (size.height === 0 || size.width === 0) {
store.getState().onError?.('004', errorMessages['error004']());
}
store.setState({ width: size.width || 500, height: size.height || 500 });
};
updateDimensions();
window.addEventListener('resize', updateDimensions);
if (rendererNode.current) {
resizeObserver = new ResizeObserver(() => updateDimensions());
resizeObserver.observe(rendererNode.current);
}
return () => {
window.removeEventListener('resize', updateDimensions);
if (resizeObserver && rendererNode.current) {
resizeObserver.unobserve(rendererNode.current!);
}
};
}, []);
}
export default useResizeHandler;

View File

@@ -0,0 +1,43 @@
import { useContext, useMemo } from 'react';
import { useStore as useZustandStore, type StoreApi } from 'zustand';
import { errorMessages } from '@xyflow/system';
import StoreContext from '../contexts/RFStoreContext';
import type { ReactFlowState } from '../types';
const zustandErrorMessage = errorMessages['error001']();
type ExtractState = StoreApi<ReactFlowState> extends { getState: () => infer T } ? T : never;
function useStore<StateSlice = ExtractState>(
selector: (state: ReactFlowState) => StateSlice,
equalityFn?: (a: StateSlice, b: StateSlice) => boolean
) {
const store = useContext(StoreContext);
if (store === null) {
throw new Error(zustandErrorMessage);
}
return useZustandStore(store, selector, equalityFn);
}
const useStoreApi = () => {
const store = useContext(StoreContext);
if (store === null) {
throw new Error(zustandErrorMessage);
}
return useMemo(
() => ({
getState: store.getState,
setState: store.setState,
subscribe: store.subscribe,
destroy: store.destroy,
}),
[store]
);
};
export { useStore, useStoreApi };

View File

@@ -0,0 +1,27 @@
import { useCallback } from 'react';
import type { UpdateNodeInternals, NodeDimensionUpdate } from '@xyflow/system';
import { useStoreApi } from '../hooks/useStore';
function useUpdateNodeInternals(): UpdateNodeInternals {
const store = useStoreApi();
return useCallback<UpdateNodeInternals>((id: string | string[]) => {
const { domNode, updateNodeDimensions } = store.getState();
const updateIds = Array.isArray(id) ? id : [id];
const updates = updateIds.reduce<NodeDimensionUpdate[]>((res, updateId) => {
const nodeElement = domNode?.querySelector(`.react-flow__node[data-id="${updateId}"]`) as HTMLDivElement;
if (nodeElement) {
res.push({ id: updateId, nodeElement, forceUpdate: true });
}
return res;
}, []);
requestAnimationFrame(() => updateNodeDimensions(updates));
}, []);
}
export default useUpdateNodeInternals;

View File

@@ -0,0 +1,49 @@
import { useCallback } from 'react';
import { calcNextPosition } from '@xyflow/system';
import { useStoreApi } from '../hooks/useStore';
function useUpdateNodePositions() {
const store = useStoreApi();
const updatePositions = useCallback((params: { x: number; y: number; isShiftPressed: boolean }) => {
const { nodeExtent, updateNodePositions, getNodes, snapToGrid, snapGrid, onError, nodesDraggable } =
store.getState();
const nodes = getNodes();
const selectedNodes = nodes.filter(
(n) => n.selected && (n.draggable || (nodesDraggable && typeof n.draggable === 'undefined'))
);
// by default a node moves 5px on each key press, or 20px if shift is pressed
// if snap grid is enabled, we use that for the velocity.
const xVelo = snapToGrid ? snapGrid[0] : 5;
const yVelo = snapToGrid ? snapGrid[1] : 5;
const factor = params.isShiftPressed ? 4 : 1;
const positionDiffX = params.x * xVelo * factor;
const positionDiffY = params.y * yVelo * factor;
const nodeUpdates = selectedNodes.map((n) => {
if (n.positionAbsolute) {
const nextPosition = { x: n.positionAbsolute.x + positionDiffX, y: n.positionAbsolute.y + positionDiffY };
if (snapToGrid) {
nextPosition.x = snapGrid[0] * Math.round(nextPosition.x / snapGrid[0]);
nextPosition.y = snapGrid[1] * Math.round(nextPosition.y / snapGrid[1]);
}
const { positionAbsolute, position } = calcNextPosition(n, nextPosition, nodes, nodeExtent, undefined, onError);
n.position = position;
n.positionAbsolute = positionAbsolute;
}
return n;
});
updateNodePositions(nodeUpdates, true, false);
}, []);
return updatePositions;
}
export default useUpdateNodePositions;

View File

@@ -0,0 +1,19 @@
import { shallow } from 'zustand/shallow';
import type { Viewport } from '@xyflow/system';
import { useStore } from '../hooks/useStore';
import type { ReactFlowState } from '../types';
const viewportSelector = (state: ReactFlowState) => ({
x: state.transform[0],
y: state.transform[1],
zoom: state.transform[2],
});
function useViewport(): Viewport {
const viewport = useStore(viewportSelector, shallow);
return viewport;
}
export default useViewport;

View File

@@ -0,0 +1,95 @@
import { useMemo } from 'react';
import { pointToRendererPoint, getTransformForBounds, fitView, type XYPosition } from '@xyflow/system';
import { useStoreApi, useStore } from '../hooks/useStore';
import type { ViewportHelperFunctions, ReactFlowState } from '../types';
const selector = (s: ReactFlowState) => !!s.panZoom;
const useViewportHelper = (): ViewportHelperFunctions => {
const store = useStoreApi();
const panZoomInitialized = useStore(selector);
const viewportHelperFunctions = useMemo<ViewportHelperFunctions>(() => {
return {
zoomIn: (options) => store.getState().panZoom?.scaleBy(1.2, { duration: options?.duration }),
zoomOut: (options) => store.getState().panZoom?.scaleBy(1 / 1.2, { duration: options?.duration }),
zoomTo: (zoomLevel, options) => store.getState().panZoom?.scaleTo(zoomLevel, { duration: options?.duration }),
getZoom: () => store.getState().transform[2],
setViewport: (viewport, options) => {
const {
transform: [tX, tY, tZoom],
panZoom,
} = store.getState();
panZoom?.setViewport(
{
x: viewport.x ?? tX,
y: viewport.y ?? tY,
zoom: viewport.zoom ?? tZoom,
},
{ duration: options?.duration }
);
},
getViewport: () => {
const [x, y, zoom] = store.getState().transform;
return { x, y, zoom };
},
fitView: (options) => {
const { getNodes, width, height, nodeOrigin, minZoom, maxZoom, panZoom } = store.getState();
return panZoom
? fitView(
{
nodes: getNodes(),
width,
height,
nodeOrigin,
minZoom,
maxZoom,
panZoom,
},
options
)
: false;
},
setCenter: (x, y, options) => {
const { width, height, maxZoom, panZoom } = store.getState();
const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : maxZoom;
const centerX = width / 2 - x * nextZoom;
const centerY = height / 2 - y * nextZoom;
panZoom?.setViewport(
{
x: centerX,
y: centerY,
zoom: nextZoom,
},
{ duration: options?.duration }
);
},
fitBounds: (bounds, options) => {
const { width, height, minZoom, maxZoom, panZoom } = store.getState();
const [x, y, zoom] = getTransformForBounds(bounds, width, height, minZoom, maxZoom, options?.padding ?? 0.1);
panZoom?.setViewport(
{
x,
y,
zoom,
},
{ duration: options?.duration }
);
},
project: (position: XYPosition) => {
const { transform, snapToGrid, snapGrid } = store.getState();
return pointToRendererPoint(position, transform, snapToGrid, snapGrid);
},
viewportInitialized: panZoomInitialized,
};
}, [panZoomInitialized]);
return viewportHelperFunctions;
};
export default useViewportHelper;

View File

@@ -0,0 +1,92 @@
import { useCallback } from 'react';
import { internalsSymbol, isNumeric } from '@xyflow/system';
import { useStore } from '../hooks/useStore';
import { isEdgeVisible } from '../container/EdgeRenderer/utils';
import { type ReactFlowState, type NodeInternals, type Edge } from '../types';
const defaultEdgeTree = [{ level: 0, isMaxLevel: true, edges: [] }];
function groupEdgesByZLevel(edges: Edge[], nodeInternals: NodeInternals, elevateEdgesOnSelect = false) {
let maxLevel = -1;
const levelLookup = edges.reduce<Record<string, Edge[]>>((tree, edge) => {
const hasZIndex = isNumeric(edge.zIndex);
let z = hasZIndex ? edge.zIndex! : 0;
if (elevateEdgesOnSelect) {
z = hasZIndex
? edge.zIndex!
: Math.max(
nodeInternals.get(edge.source)?.[internalsSymbol]?.z || 0,
nodeInternals.get(edge.target)?.[internalsSymbol]?.z || 0
);
}
if (tree[z]) {
tree[z].push(edge);
} else {
tree[z] = [edge];
}
maxLevel = z > maxLevel ? z : maxLevel;
return tree;
}, {});
const edgeTree = Object.entries(levelLookup).map(([key, edges]) => {
const level = +key;
return {
edges,
level,
isMaxLevel: level === maxLevel,
};
});
if (edgeTree.length === 0) {
return defaultEdgeTree;
}
return edgeTree;
}
function useVisibleEdges(onlyRenderVisible: boolean, nodeInternals: NodeInternals, elevateEdgesOnSelect: boolean) {
const edges = useStore(
useCallback(
(s: ReactFlowState) => {
if (!onlyRenderVisible) {
return s.edges;
}
return s.edges.filter((e) => {
const sourceNode = nodeInternals.get(e.source);
const targetNode = nodeInternals.get(e.target);
return (
sourceNode?.width &&
sourceNode?.height &&
targetNode?.width &&
targetNode?.height &&
isEdgeVisible({
sourcePos: sourceNode.positionAbsolute || { x: 0, y: 0 },
targetPos: targetNode.positionAbsolute || { x: 0, y: 0 },
sourceWidth: sourceNode.width,
sourceHeight: sourceNode.height,
targetWidth: targetNode.width,
targetHeight: targetNode.height,
width: s.width,
height: s.height,
transform: s.transform,
})
);
});
},
[onlyRenderVisible, nodeInternals]
)
);
return groupEdgesByZLevel(edges, nodeInternals, elevateEdgesOnSelect);
}
export default useVisibleEdges;

View File

@@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { getNodesInside } from '@xyflow/system';
import { useStore } from '../hooks/useStore';
import type { Node, ReactFlowState } from '../types';
function useVisibleNodes(onlyRenderVisible: boolean) {
const nodes = useStore(
useCallback(
(s: ReactFlowState) =>
onlyRenderVisible
? getNodesInside<Node>(s.getNodes(), { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true)
: s.getNodes(),
[onlyRenderVisible]
)
);
return nodes;
}
export default useVisibleNodes;

View File

@@ -0,0 +1,42 @@
export { default as ReactFlow } from './container/ReactFlow';
export { default as Handle } from './components/Handle';
export { default as EdgeText } from './components/Edges/EdgeText';
export { default as StraightEdge } from './components/Edges/StraightEdge';
export { default as StepEdge } from './components/Edges/StepEdge';
export { default as BezierEdge } from './components/Edges/BezierEdge';
export { default as SimpleBezierEdge, getSimpleBezierPath } from './components/Edges/SimpleBezierEdge';
export { default as SmoothStepEdge } from './components/Edges/SmoothStepEdge';
export { default as BaseEdge } from './components/Edges/BaseEdge';
export { default as ReactFlowProvider } from './components/ReactFlowProvider';
export { default as Panel } from './components/Panel';
export { default as EdgeLabelRenderer } from './components/EdgeLabelRenderer';
export { default as useReactFlow } from './hooks/useReactFlow';
export { default as useUpdateNodeInternals } from './hooks/useUpdateNodeInternals';
export { default as useNodes } from './hooks/useNodes';
export { default as useEdges } from './hooks/useEdges';
export { default as useViewport } from './hooks/useViewport';
export { default as useKeyPress } from './hooks/useKeyPress';
export * from './hooks/useNodesEdgesState';
export { useStore, useStoreApi } from './hooks/useStore';
export { default as useOnViewportChange, type UseOnViewportChangeOptions } from './hooks/useOnViewportChange';
export { default as useOnSelectionChange, type UseOnSelectionChangeOptions } from './hooks/useOnSelectionChange';
export { default as useNodesInitialized, type UseNodesInitializedOptions } from './hooks/useNodesInitialized';
export { useNodeId } from './contexts/NodeIdContext';
export * from '@xyflow/system';
// export {
// getTransformForBounds,
// getRectOfNodes,
// getNodePositionWithOrigin,
// rectToBox,
// boxToRect,
// getBoundsOfRects,
// clamp,
// } from '@xyflow/system';
export { applyNodeChanges, applyEdgeChanges } from './utils/changes';
export { isNode, isEdge, getIncomers, getOutgoers, addEdge, updateEdge, getConnectedEdges } from './utils/general';
export * from './additional-components';
export * from './types';

View File

@@ -0,0 +1,321 @@
import { createStore } from 'zustand';
import {
clampPosition,
getDimensions,
fitView,
getHandleBounds,
internalsSymbol,
type CoordinateExtent,
} from '@xyflow/system';
import { applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes';
import { createNodeInternals, updateAbsoluteNodePositions, updateNodesAndEdgesSelections } from './utils';
import initialState from './initialState';
import type {
ReactFlowState,
Node,
Edge,
NodeDimensionChange,
EdgeSelectionChange,
NodeSelectionChange,
NodePositionChange,
UnselectNodesAndEdgesParams,
} from '../types';
const createRFStore = () =>
createStore<ReactFlowState>((set, get) => ({
...initialState,
setNodes: (nodes: Node[]) => {
const { nodeInternals, nodeOrigin, elevateNodesOnSelect } = get();
set({ nodeInternals: createNodeInternals(nodes, nodeInternals, nodeOrigin, elevateNodesOnSelect) });
},
getNodes: () => {
return Array.from(get().nodeInternals.values());
},
setEdges: (edges: Edge[]) => {
const { defaultEdgeOptions = {} } = get();
set({ edges: edges.map((e) => ({ ...defaultEdgeOptions, ...e })) });
},
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => {
const hasDefaultNodes = typeof nodes !== 'undefined';
const hasDefaultEdges = typeof edges !== 'undefined';
const nodeInternals = hasDefaultNodes
? createNodeInternals(nodes, new Map(), get().nodeOrigin, get().elevateNodesOnSelect)
: new Map();
const nextEdges = hasDefaultEdges ? edges : [];
set({ nodeInternals, edges: nextEdges, hasDefaultNodes, hasDefaultEdges });
},
updateNodeDimensions: (updates) => {
const {
onNodesChange,
nodeInternals,
fitViewOnInit,
fitViewOnInitDone,
fitViewOnInitOptions,
domNode,
nodeOrigin,
width,
height,
minZoom,
maxZoom,
panZoom,
} = get();
const viewportNode = domNode?.querySelector('.react-flow__viewport');
if (!viewportNode) {
return;
}
const style = window.getComputedStyle(viewportNode);
const { m22: zoom } = new window.DOMMatrixReadOnly(style.transform);
const changes: NodeDimensionChange[] = updates.reduce<NodeDimensionChange[]>((res, update) => {
const node = nodeInternals.get(update.id);
if (node) {
const dimensions = getDimensions(update.nodeElement);
const doUpdate = !!(
dimensions.width &&
dimensions.height &&
(node.width !== dimensions.width || node.height !== dimensions.height || update.forceUpdate)
);
if (doUpdate) {
nodeInternals.set(node.id, {
...node,
[internalsSymbol]: {
...node[internalsSymbol],
handleBounds: {
source: getHandleBounds('.source', update.nodeElement, zoom, node.origin || nodeOrigin),
target: getHandleBounds('.target', update.nodeElement, zoom, node.origin || nodeOrigin),
},
},
...dimensions,
});
res.push({
id: node.id,
type: 'dimensions',
dimensions,
});
}
}
return res;
}, []);
updateAbsoluteNodePositions(nodeInternals, nodeOrigin);
const nextFitViewOnInitDone =
fitViewOnInitDone ||
(fitViewOnInit &&
!fitViewOnInitDone &&
!!panZoom &&
fitView(
{
nodes: Array.from(nodeInternals.values()),
width,
height,
panZoom,
minZoom,
maxZoom,
nodeOrigin,
},
fitViewOnInitOptions
));
set({ nodeInternals: new Map(nodeInternals), fitViewOnInitDone: nextFitViewOnInitDone });
if (changes?.length > 0) {
onNodesChange?.(changes);
}
},
updateNodePositions: (nodeDragItems, positionChanged = true, dragging = false) => {
const { triggerNodeChanges } = get();
const changes = nodeDragItems.map((node) => {
const change: NodePositionChange = {
id: node.id,
type: 'position',
dragging,
};
if (positionChanged) {
change.positionAbsolute = node.positionAbsolute;
change.position = node.position;
}
return change;
});
triggerNodeChanges(changes);
},
triggerNodeChanges: (changes) => {
const { onNodesChange, nodeInternals, hasDefaultNodes, nodeOrigin, getNodes, elevateNodesOnSelect } = get();
if (changes?.length) {
if (hasDefaultNodes) {
const nodes = applyNodeChanges(changes, getNodes());
const nextNodeInternals = createNodeInternals(nodes, nodeInternals, nodeOrigin, elevateNodesOnSelect);
set({ nodeInternals: nextNodeInternals });
}
onNodesChange?.(changes);
}
},
addSelectedNodes: (selectedNodeIds) => {
const { multiSelectionActive, edges, getNodes } = get();
let changedNodes: NodeSelectionChange[];
let changedEdges: EdgeSelectionChange[] | null = null;
if (multiSelectionActive) {
changedNodes = selectedNodeIds.map((nodeId) => createSelectionChange(nodeId, true)) as NodeSelectionChange[];
} else {
changedNodes = getSelectionChanges(getNodes(), selectedNodeIds);
changedEdges = getSelectionChanges(edges, []);
}
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
addSelectedEdges: (selectedEdgeIds) => {
const { multiSelectionActive, edges, getNodes } = get();
let changedEdges: EdgeSelectionChange[];
let changedNodes: NodeSelectionChange[] | null = null;
if (multiSelectionActive) {
changedEdges = selectedEdgeIds.map((edgeId) => createSelectionChange(edgeId, true)) as EdgeSelectionChange[];
} else {
changedEdges = getSelectionChanges(edges, selectedEdgeIds);
changedNodes = getSelectionChanges(getNodes(), []);
}
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
unselectNodesAndEdges: ({ nodes, edges }: UnselectNodesAndEdgesParams = {}) => {
const { edges: storeEdges, getNodes } = get();
const nodesToUnselect = nodes ? nodes : getNodes();
const edgesToUnselect = edges ? edges : storeEdges;
const changedNodes = nodesToUnselect.map((n) => {
n.selected = false;
return createSelectionChange(n.id, false);
}) as NodeSelectionChange[];
const changedEdges = edgesToUnselect.map((edge) =>
createSelectionChange(edge.id, false)
) as EdgeSelectionChange[];
updateNodesAndEdgesSelections({
changedNodes,
changedEdges,
get,
set,
});
},
setMinZoom: (minZoom) => {
const { panZoom, maxZoom } = get();
panZoom?.setScaleExtent([minZoom, maxZoom]);
set({ minZoom });
},
setMaxZoom: (maxZoom) => {
const { panZoom, minZoom } = get();
panZoom?.setScaleExtent([minZoom, maxZoom]);
set({ maxZoom });
},
setTranslateExtent: (translateExtent) => {
get().panZoom?.setTranslateExtent(translateExtent);
set({ translateExtent });
},
resetSelectedElements: () => {
const { edges, getNodes } = get();
const nodes = getNodes();
const nodesToUnselect = nodes
.filter((e) => e.selected)
.map((n) => createSelectionChange(n.id, false)) as NodeSelectionChange[];
const edgesToUnselect = edges
.filter((e) => e.selected)
.map((e) => createSelectionChange(e.id, false)) as EdgeSelectionChange[];
updateNodesAndEdgesSelections({
changedNodes: nodesToUnselect,
changedEdges: edgesToUnselect,
get,
set,
});
},
setNodeExtent: (nodeExtent) => {
const { nodeInternals } = get();
nodeInternals.forEach((node) => {
node.positionAbsolute = clampPosition(node.position, nodeExtent);
});
set({
nodeExtent,
nodeInternals: new Map(nodeInternals),
});
},
panBy: (delta): boolean => {
const { transform, width, height, panZoom, translateExtent } = get();
if (!panZoom || (!delta.x && !delta.y)) {
return false;
}
const extent: CoordinateExtent = [
[0, 0],
[width, height],
];
const constrainedTransform = panZoom.setViewportConstrained(
{ x: transform[0] + delta.x, y: transform[1] + delta.y, zoom: transform[2] },
extent,
translateExtent
);
const transformChanged =
!!constrainedTransform &&
(transform[0] !== constrainedTransform.x ||
transform[1] !== constrainedTransform.y ||
transform[2] !== constrainedTransform.k);
return transformChanged;
},
cancelConnection: () =>
set({
connectionStatus: initialState.connectionStatus,
connectionStartHandle: initialState.connectionStartHandle,
connectionEndHandle: initialState.connectionEndHandle,
}),
updateConnection: (params) => {
const { connectionStatus, connectionStartHandle, connectionEndHandle, connectionPosition } = get();
const currentConnection = {
connectionPosition: params.connectionPosition ?? connectionPosition,
connectionStatus: params.connectionStatus ?? connectionStatus,
connectionStartHandle: params.connectionStartHandle ?? connectionStartHandle,
connectionEndHandle: params.connectionEndHandle ?? connectionEndHandle,
};
set(currentConnection);
},
reset: () => set({ ...initialState }),
}));
export { createRFStore };

View File

@@ -0,0 +1,64 @@
import { devWarn, infiniteExtent, ConnectionMode } from '@xyflow/system';
import type { ReactFlowStore } from '../types';
const initialState: ReactFlowStore = {
rfId: '1',
width: 0,
height: 0,
transform: [0, 0, 1],
nodeInternals: new Map(),
edges: [],
onNodesChange: null,
onEdgesChange: null,
hasDefaultNodes: false,
hasDefaultEdges: false,
panZoom: null,
minZoom: 0.5,
maxZoom: 2,
translateExtent: infiniteExtent,
nodeExtent: infiniteExtent,
nodesSelectionActive: false,
userSelectionActive: false,
userSelectionRect: null,
connectionPosition: { x: 0, y: 0 },
connectionStatus: null,
connectionMode: ConnectionMode.Strict,
domNode: null,
paneDragging: false,
noPanClassName: 'nopan',
nodeOrigin: [0, 0],
snapGrid: [15, 15],
snapToGrid: false,
nodesDraggable: true,
nodesConnectable: true,
nodesFocusable: true,
edgesFocusable: true,
edgesUpdatable: true,
elementsSelectable: true,
elevateNodesOnSelect: true,
fitViewOnInit: false,
fitViewOnInitDone: false,
fitViewOnInitOptions: undefined,
selectNodesOnDrag: true,
multiSelectionActive: false,
connectionStartHandle: null,
connectionEndHandle: null,
connectionClickStartHandle: null,
connectOnClick: true,
ariaLiveMessage: '',
autoPanOnConnect: true,
autoPanOnNodeDrag: true,
connectionRadius: 20,
onError: devWarn,
isValidConnection: undefined,
lib: 'react',
};
export default initialState;

View File

@@ -0,0 +1,169 @@
import type { StoreApi } from 'zustand';
import {
internalsSymbol,
isNumeric,
getNodePositionWithOrigin,
type XYZPosition,
type NodeOrigin,
} from '@xyflow/system';
import type { Edge, EdgeSelectionChange, Node, NodeInternals, NodeSelectionChange, ReactFlowState } from '../types';
type ParentNodes = Record<string, boolean>;
function calculateXYZPosition(
node: Node,
nodeInternals: NodeInternals,
result: XYZPosition,
nodeOrigin: NodeOrigin
): XYZPosition {
if (!node.parentNode) {
return result;
}
const parentNode = nodeInternals.get(node.parentNode)!;
const parentNodePosition = getNodePositionWithOrigin(parentNode, parentNode?.origin || nodeOrigin);
return calculateXYZPosition(
parentNode,
nodeInternals,
{
x: (result.x ?? 0) + parentNodePosition.x,
y: (result.y ?? 0) + parentNodePosition.y,
z: (parentNode[internalsSymbol]?.z ?? 0) > (result.z ?? 0) ? parentNode[internalsSymbol]?.z ?? 0 : result.z ?? 0,
},
parentNode.origin || nodeOrigin
);
}
export function updateAbsoluteNodePositions(
nodeInternals: NodeInternals,
nodeOrigin: NodeOrigin,
parentNodes?: ParentNodes
) {
nodeInternals.forEach((node) => {
if (node.parentNode && !nodeInternals.has(node.parentNode)) {
throw new Error(`Parent node ${node.parentNode} not found`);
}
if (node.parentNode || parentNodes?.[node.id]) {
const parentNode = node.parentNode ? nodeInternals.get(node.parentNode) : null;
const { x, y, z } = calculateXYZPosition(
node,
nodeInternals,
{
...node.position,
z: node[internalsSymbol]?.z ?? 0,
},
parentNode?.origin || nodeOrigin
);
node.positionAbsolute = {
x,
y,
};
node[internalsSymbol]!.z = z;
if (parentNodes?.[node.id]) {
node[internalsSymbol]!.isParent = true;
}
}
});
}
export function createNodeInternals(
nodes: Node[],
nodeInternals: NodeInternals,
nodeOrigin: NodeOrigin,
elevateNodesOnSelect: boolean
): NodeInternals {
const nextNodeInternals = new Map<string, Node>();
const parentNodes: ParentNodes = {};
const selectedNodeZ: number = elevateNodesOnSelect ? 1000 : 0;
nodes.forEach((node) => {
const z = (isNumeric(node.zIndex) ? node.zIndex : 0) + (node.selected ? selectedNodeZ : 0);
const currInternals = nodeInternals.get(node.id);
const internals: Node = {
width: currInternals?.width,
height: currInternals?.height,
...node,
positionAbsolute: {
x: node.position.x,
y: node.position.y,
},
};
if (node.parentNode) {
internals.parentNode = node.parentNode;
parentNodes[node.parentNode] = true;
}
Object.defineProperty(internals, internalsSymbol, {
enumerable: false,
value: {
handleBounds: currInternals?.[internalsSymbol]?.handleBounds,
z,
},
});
nextNodeInternals.set(node.id, internals);
});
updateAbsoluteNodePositions(nextNodeInternals, nodeOrigin, parentNodes);
return nextNodeInternals;
}
export function handleControlledNodeSelectionChange(nodeChanges: NodeSelectionChange[], nodeInternals: NodeInternals) {
nodeChanges.forEach((change) => {
const node = nodeInternals.get(change.id);
if (node) {
nodeInternals.set(node.id, {
...node,
[internalsSymbol]: node[internalsSymbol],
selected: change.selected,
});
}
});
return new Map(nodeInternals);
}
export function handleControlledEdgeSelectionChange(edgeChanges: EdgeSelectionChange[], edges: Edge[]) {
return edges.map((e) => {
const change = edgeChanges.find((change) => change.id === e.id);
if (change) {
e.selected = change.selected;
}
return e;
});
}
type UpdateNodesAndEdgesParams = {
changedNodes: NodeSelectionChange[] | null;
changedEdges: EdgeSelectionChange[] | null;
get: StoreApi<ReactFlowState>['getState'];
set: StoreApi<ReactFlowState>['setState'];
};
export function updateNodesAndEdgesSelections({ changedNodes, changedEdges, get, set }: UpdateNodesAndEdgesParams) {
const { nodeInternals, edges, onNodesChange, onEdgesChange, hasDefaultNodes, hasDefaultEdges } = get();
if (changedNodes?.length) {
if (hasDefaultNodes) {
set({ nodeInternals: handleControlledNodeSelectionChange(changedNodes, nodeInternals) });
}
onNodesChange?.(changedNodes);
}
if (changedEdges?.length) {
if (hasDefaultEdges) {
set({ edges: handleControlledEdgeSelectionChange(changedEdges, edges) });
}
onEdgesChange?.(changedEdges);
}
}

Some files were not shown because too many files have changed in this diff Show More