refactor(packages): change package structure
This commit is contained in:
4
packages/react/.eslintrc.js
Normal file
4
packages/react/.eslintrc.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['@xyflow/eslint-config'],
|
||||
};
|
||||
304
packages/react/CHANGELOG.md
Normal file
304
packages/react/CHANGELOG.md
Normal 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
10
packages/react/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# @reactflow/core
|
||||
|
||||
Core components and util functions of React Flow.
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
npm install @reactflow/core
|
||||
```
|
||||
|
||||
78
packages/react/package.json
Normal file
78
packages/react/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Background } from './Background';
|
||||
export * from './types';
|
||||
19
packages/react/src/additional-components/Background/types.ts
Normal file
19
packages/react/src/additional-components/Background/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
133
packages/react/src/additional-components/Controls/Controls.tsx
Normal file
133
packages/react/src/additional-components/Controls/Controls.tsx
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Controls } from './Controls';
|
||||
export { default as ControlButton } from './ControlButton';
|
||||
export * from './types';
|
||||
18
packages/react/src/additional-components/Controls/types.ts
Normal file
18
packages/react/src/additional-components/Controls/types.ts
Normal 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>;
|
||||
229
packages/react/src/additional-components/MiniMap/MiniMap.tsx
Normal file
229
packages/react/src/additional-components/MiniMap/MiniMap.tsx
Normal 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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as MiniMap } from './MiniMap';
|
||||
export * from './types';
|
||||
43
packages/react/src/additional-components/MiniMap/types.ts
Normal file
43
packages/react/src/additional-components/MiniMap/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as NodeResizer } from './NodeResizer';
|
||||
export { default as NodeResizeControl } from './ResizeControl';
|
||||
|
||||
export * from './types';
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as NodeToolbar } from './NodeToolbar';
|
||||
export * from './types';
|
||||
@@ -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';
|
||||
5
packages/react/src/additional-components/index.ts
Normal file
5
packages/react/src/additional-components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './Background';
|
||||
export * from './Controls';
|
||||
export * from './MiniMap';
|
||||
export * from './NodeResizer';
|
||||
export * from './NodeToolbar';
|
||||
51
packages/react/src/components/A11yDescriptions/index.tsx
Normal file
51
packages/react/src/components/A11yDescriptions/index.tsx
Normal 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;
|
||||
28
packages/react/src/components/Attribution/index.tsx
Normal file
28
packages/react/src/components/Attribution/index.tsx
Normal 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;
|
||||
174
packages/react/src/components/ConnectionLine/index.tsx
Normal file
174
packages/react/src/components/ConnectionLine/index.tsx
Normal 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;
|
||||
19
packages/react/src/components/EdgeLabelRenderer/index.tsx
Normal file
19
packages/react/src/components/EdgeLabelRenderer/index.tsx
Normal 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;
|
||||
61
packages/react/src/components/Edges/BaseEdge.tsx
Normal file
61
packages/react/src/components/Edges/BaseEdge.tsx
Normal 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;
|
||||
59
packages/react/src/components/Edges/BezierEdge.tsx
Normal file
59
packages/react/src/components/Edges/BezierEdge.tsx
Normal 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;
|
||||
51
packages/react/src/components/Edges/EdgeAnchor.tsx
Normal file
51
packages/react/src/components/Edges/EdgeAnchor.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
67
packages/react/src/components/Edges/EdgeText.tsx
Normal file
67
packages/react/src/components/Edges/EdgeText.tsx
Normal 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);
|
||||
124
packages/react/src/components/Edges/SimpleBezierEdge.tsx
Normal file
124
packages/react/src/components/Edges/SimpleBezierEdge.tsx
Normal 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;
|
||||
60
packages/react/src/components/Edges/SmoothStepEdge.tsx
Normal file
60
packages/react/src/components/Edges/SmoothStepEdge.tsx
Normal 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;
|
||||
15
packages/react/src/components/Edges/StepEdge.tsx
Normal file
15
packages/react/src/components/Edges/StepEdge.tsx
Normal 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;
|
||||
48
packages/react/src/components/Edges/StraightEdge.tsx
Normal file
48
packages/react/src/components/Edges/StraightEdge.tsx
Normal 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;
|
||||
5
packages/react/src/components/Edges/index.ts
Normal file
5
packages/react/src/components/Edges/index.ts
Normal 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';
|
||||
20
packages/react/src/components/Edges/utils.ts
Normal file
20
packages/react/src/components/Edges/utils.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
265
packages/react/src/components/Edges/wrapEdge.tsx
Normal file
265
packages/react/src/components/Edges/wrapEdge.tsx
Normal 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);
|
||||
};
|
||||
216
packages/react/src/components/Handle/index.tsx
Normal file
216
packages/react/src/components/Handle/index.tsx
Normal 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);
|
||||
23
packages/react/src/components/Nodes/DefaultNode.tsx
Normal file
23
packages/react/src/components/Nodes/DefaultNode.tsx
Normal 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);
|
||||
5
packages/react/src/components/Nodes/GroupNode.tsx
Normal file
5
packages/react/src/components/Nodes/GroupNode.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const GroupNode = () => null;
|
||||
|
||||
GroupNode.displayName = 'GroupNode';
|
||||
|
||||
export default GroupNode;
|
||||
15
packages/react/src/components/Nodes/InputNode.tsx
Normal file
15
packages/react/src/components/Nodes/InputNode.tsx
Normal 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);
|
||||
15
packages/react/src/components/Nodes/OutputNode.tsx
Normal file
15
packages/react/src/components/Nodes/OutputNode.tsx
Normal 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);
|
||||
49
packages/react/src/components/Nodes/utils.ts
Normal file
49
packages/react/src/components/Nodes/utils.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
224
packages/react/src/components/Nodes/wrapNode.tsx
Normal file
224
packages/react/src/components/Nodes/wrapNode.tsx
Normal 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);
|
||||
};
|
||||
99
packages/react/src/components/NodesSelection/index.tsx
Normal file
99
packages/react/src/components/NodesSelection/index.tsx
Normal 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);
|
||||
30
packages/react/src/components/Panel/index.tsx
Normal file
30
packages/react/src/components/Panel/index.tsx
Normal 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;
|
||||
20
packages/react/src/components/ReactFlowProvider/index.tsx
Normal file
20
packages/react/src/components/ReactFlowProvider/index.tsx
Normal 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;
|
||||
56
packages/react/src/components/SelectionListener/index.tsx
Normal file
56
packages/react/src/components/SelectionListener/index.tsx
Normal 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;
|
||||
217
packages/react/src/components/StoreUpdater/index.tsx
Normal file
217
packages/react/src/components/StoreUpdater/index.tsx
Normal 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;
|
||||
31
packages/react/src/components/UserSelection/index.tsx
Normal file
31
packages/react/src/components/UserSelection/index.tsx
Normal 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;
|
||||
@@ -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);
|
||||
57
packages/react/src/container/EdgeRenderer/MarkerSymbols.tsx
Normal file
57
packages/react/src/container/EdgeRenderer/MarkerSymbols.tsx
Normal 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;
|
||||
196
packages/react/src/container/EdgeRenderer/index.tsx
Normal file
196
packages/react/src/container/EdgeRenderer/index.tsx
Normal 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);
|
||||
142
packages/react/src/container/EdgeRenderer/utils.ts
Normal file
142
packages/react/src/container/EdgeRenderer/utils.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
124
packages/react/src/container/FlowRenderer/index.tsx
Normal file
124
packages/react/src/container/FlowRenderer/index.tsx
Normal 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);
|
||||
195
packages/react/src/container/GraphView/index.tsx
Normal file
195
packages/react/src/container/GraphView/index.tsx
Normal 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);
|
||||
146
packages/react/src/container/NodeRenderer/index.tsx
Normal file
146
packages/react/src/container/NodeRenderer/index.tsx
Normal 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);
|
||||
34
packages/react/src/container/NodeRenderer/utils.ts
Normal file
34
packages/react/src/container/NodeRenderer/utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
236
packages/react/src/container/Pane/index.tsx
Normal file
236
packages/react/src/container/Pane/index.tsx
Normal 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;
|
||||
20
packages/react/src/container/ReactFlow/Wrapper.tsx
Normal file
20
packages/react/src/container/ReactFlow/Wrapper.tsx
Normal 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;
|
||||
319
packages/react/src/container/ReactFlow/index.tsx
Normal file
319
packages/react/src/container/ReactFlow/index.tsx
Normal 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;
|
||||
28
packages/react/src/container/ReactFlow/utils.ts
Normal file
28
packages/react/src/container/ReactFlow/utils.ts
Normal 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;
|
||||
}
|
||||
22
packages/react/src/container/Viewport/index.tsx
Normal file
22
packages/react/src/container/Viewport/index.tsx
Normal 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;
|
||||
137
packages/react/src/container/ZoomPane/index.tsx
Normal file
137
packages/react/src/container/ZoomPane/index.tsx
Normal 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;
|
||||
12
packages/react/src/contexts/NodeIdContext.ts
Normal file
12
packages/react/src/contexts/NodeIdContext.ts
Normal 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;
|
||||
8
packages/react/src/contexts/RFStoreContext.ts
Normal file
8
packages/react/src/contexts/RFStoreContext.ts
Normal 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
13
packages/react/src/custom.d.ts
vendored
Normal 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 };
|
||||
}
|
||||
72
packages/react/src/hooks/useDrag.ts
Normal file
72
packages/react/src/hooks/useDrag.ts
Normal 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;
|
||||
14
packages/react/src/hooks/useEdges.ts
Normal file
14
packages/react/src/hooks/useEdges.ts
Normal 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;
|
||||
34
packages/react/src/hooks/useGlobalKeyHandler.ts
Normal file
34
packages/react/src/hooks/useGlobalKeyHandler.ts
Normal 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]);
|
||||
};
|
||||
114
packages/react/src/hooks/useKeyPress.ts
Normal file
114
packages/react/src/hooks/useKeyPress.ts
Normal 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';
|
||||
}
|
||||
14
packages/react/src/hooks/useNodes.ts
Normal file
14
packages/react/src/hooks/useNodes.ts
Normal 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;
|
||||
35
packages/react/src/hooks/useNodesEdgesState.ts
Normal file
35
packages/react/src/hooks/useNodesEdgesState.ts
Normal 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);
|
||||
31
packages/react/src/hooks/useNodesInitialized.ts
Normal file
31
packages/react/src/hooks/useNodesInitialized.ts
Normal 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;
|
||||
18
packages/react/src/hooks/useOnInitHandler.ts
Normal file
18
packages/react/src/hooks/useOnInitHandler.ts
Normal 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;
|
||||
18
packages/react/src/hooks/useOnSelectionChange.ts
Normal file
18
packages/react/src/hooks/useOnSelectionChange.ts
Normal 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;
|
||||
28
packages/react/src/hooks/useOnViewportChange.ts
Normal file
28
packages/react/src/hooks/useOnViewportChange.ts
Normal 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;
|
||||
267
packages/react/src/hooks/useReactFlow.ts
Normal file
267
packages/react/src/hooks/useReactFlow.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
44
packages/react/src/hooks/useResizeHandler.ts
Normal file
44
packages/react/src/hooks/useResizeHandler.ts
Normal 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;
|
||||
43
packages/react/src/hooks/useStore.ts
Normal file
43
packages/react/src/hooks/useStore.ts
Normal 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 };
|
||||
27
packages/react/src/hooks/useUpdateNodeInternals.ts
Normal file
27
packages/react/src/hooks/useUpdateNodeInternals.ts
Normal 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;
|
||||
49
packages/react/src/hooks/useUpdateNodePositions.ts
Normal file
49
packages/react/src/hooks/useUpdateNodePositions.ts
Normal 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;
|
||||
19
packages/react/src/hooks/useViewport.ts
Normal file
19
packages/react/src/hooks/useViewport.ts
Normal 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;
|
||||
95
packages/react/src/hooks/useViewportHelper.ts
Normal file
95
packages/react/src/hooks/useViewportHelper.ts
Normal 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;
|
||||
92
packages/react/src/hooks/useVisibleEdges.ts
Normal file
92
packages/react/src/hooks/useVisibleEdges.ts
Normal 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;
|
||||
21
packages/react/src/hooks/useVisibleNodes.ts
Normal file
21
packages/react/src/hooks/useVisibleNodes.ts
Normal 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;
|
||||
42
packages/react/src/index.ts
Normal file
42
packages/react/src/index.ts
Normal 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';
|
||||
321
packages/react/src/store/index.ts
Normal file
321
packages/react/src/store/index.ts
Normal 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 };
|
||||
64
packages/react/src/store/initialState.ts
Normal file
64
packages/react/src/store/initialState.ts
Normal 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;
|
||||
169
packages/react/src/store/utils.ts
Normal file
169
packages/react/src/store/utils.ts
Normal 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
Reference in New Issue
Block a user