diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 842f28e8..2d4ff726 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -4,47 +4,57 @@ body: - type: markdown attributes: value: | - Thank you for reporting an issue :pray:. + Hello! :sparkles: You found the place to submit a bug or issue that you discovered in the xyflow library, thank you for helping us out! - **Please only file issues for v11. Will will no longer fix issues for v10.** + Having trouble making something work in xyflow, but it’s not a bug with the library itself? You’ll get the fastest response by asking in [github discussions](https://github.com/xyflow/xyflow/discussions) or our [discord server](https://discord.gg/RVmnytFmGW). - This issue tracker is for reporting bugs found in react-flow (https://github.com/wbkd/react-flow) - If you have a question about how to achieve something and are struggling, please post a question - inside of react-flow's Discussion's tab: https://github.com/wbkd/react-flow/discussions - If it's an issue about the docs please go to the react-flow-docs (https://github.com/wbkd/react-flow-docs) + **Search our [discussions](https://github.com/xyflow/xyflow/discussions?discussions_q=) and [issues](https://github.com/xyflow/xyflow/issues?q=)** to see if your problem has already been reported and you can add more context, or see if someone else has already solved your issue. And of course, check our [docs](https://reactflow.dev/learn) if you haven’t already. - Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already: - - react-flow's Open Issue's tab: https://github.com/wbkd/react-flow/issues?q=is%3Aissue+sort%3Aupdated-desc+position - - react-flow's Closed Issues tab: https://github.com/wbkd/react-flow/issues?q=is%3Aissue+sort%3Aupdated-desc+position+is%3Aclosed - - react-flow's Discussion's tab: https://github.com/wbkd/react-flow/discussions + **Issue with the xyflow Docs?** Submit an issue in our [web repo](https://github.com/xyflow/web/issues) instead. - The more information you fill in, the better the community can help you. + With that out of the way, let’s get started :bug: - type: textarea - id: description + id: platform attributes: - label: Describe the Bug - description: Provide a clear and concise description of the challenge you are running into. + label: What platform were you using when you found the bug? + description: We only address issues for the most recent xyflow major release or upcoming releases. + value: | + - React Flow / Svelte Flow version: + - Browser and version: + - OS and version: validations: required: true - type: input id: link attributes: - label: Your Example Website or App + label: Live code example description: | - Which website or app were you using when the bug happened? - Note: - - Your bug will may get fixed much faster if we can run your code and it doesn't have dependencies other than the `react-flow-renderer` npm package. - - To create a shareable code example you can use Codesandbox (https://codesandbox.io/s/react-flow-starter-ou8or) or Stackblitz (https://stackblitz.com/). - - Please read these tips for providing a minimal example: https://stackoverflow.com/help/mcve. - placeholder: | - e.g. https://stackblitz.com/edit/...... OR Github Repo + This is the most important part of the bug report! + + React Flow codesandbox starters + - js: https://new.reactflow.dev/js + - ts: https://new.reactflow.dev/ts + + Svelte Flow stackblitz starters + - js: https://new.svelteflow.dev/js + - ts: https://new.svelteflow.dev/ts + + Avoid dependencies in your example for easier debugging. + placeholder: https://stackblitz.com/edit/... or Github Repo validations: required: false + - type: textarea + id: description + attributes: + label: Describe the Bug + description: What exactly is happening when you encounter the bug? + validations: + required: true - type: textarea id: steps attributes: - label: Steps to Reproduce the Bug or Issue - description: Describe the steps we have to take to reproduce the behavior. + label: Steps to reproduce the bug or issue + description: How can we see the reported behavior in the code example you provided? The more detail, the better! placeholder: | 1. Go to '...' 2. Click on '....' @@ -56,9 +66,8 @@ body: id: expected attributes: label: Expected behavior - description: Provide a clear and concise description of what you expected to happen. - placeholder: | - As a user, I expected ___ behavior but i am seeing ___ + description: What would you expect to happen if there was no bug or issue? + placeholder: As a user, I expected ___ behavior, but instead I am seeing ___ validations: required: true - type: textarea @@ -67,22 +76,9 @@ body: label: Screenshots or Videos description: | If applicable, add screenshots or a video to help explain your problem. - For more information on the supported file image/file types and the file size limits, please refer - to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files - placeholder: | - You can drag your video or image files inside of this editor ↓ - - type: textarea - id: platform - attributes: - label: Platform - value: | - - OS: [e.g. macOS, Windows, Linux] - - Browser: [e.g. Chrome, Safari, Firefox] - - Version: [e.g. 91.1] - validations: - required: true + Supported image/file types and file size limits- https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files + placeholder: Drag your video or image files inside of this editor - type: textarea id: additional attributes: label: Additional context - description: Add any other context about the problem here. diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 287ab17b..024a22ac 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -34,7 +34,7 @@ import Subflow from '../examples/Subflow'; import SwitchFlow from '../examples/Switch'; import TouchDevice from '../examples/TouchDevice'; import Undirectional from '../examples/Undirectional'; -import UpdatableEdge from '../examples/UpdatableEdge'; +import ReconnectEdge from '../examples/ReconnectEdge'; import UpdateNode from '../examples/UpdateNode'; import UseUpdateNodeInternals from '../examples/UseUpdateNodeInternals'; import UseReactFlow from '../examples/UseReactFlow'; @@ -271,9 +271,9 @@ const routes: IRoute[] = [ component: Undirectional, }, { - name: 'Updatable Edge', - path: 'updatable-edge', - component: UpdatableEdge, + name: 'Reconnect Edge', + path: 'reconnect-edge', + component: ReconnectEdge, }, { name: 'Update Node', diff --git a/examples/react/src/examples/DefaultNodes/index.tsx b/examples/react/src/examples/DefaultNodes/index.tsx index 354fa994..65ebb060 100644 --- a/examples/react/src/examples/DefaultNodes/index.tsx +++ b/examples/react/src/examples/DefaultNodes/index.tsx @@ -25,6 +25,7 @@ const defaultNodes: Node[] = [ }, { id: '3', + type: 'output', data: { label: 'Node 3' }, position: { x: 400, y: 100 }, className: 'light', diff --git a/examples/react/src/examples/DragNDrop/index.tsx b/examples/react/src/examples/DragNDrop/index.tsx index 83c7bb4e..10a2eff4 100644 --- a/examples/react/src/examples/DragNDrop/index.tsx +++ b/examples/react/src/examples/DragNDrop/index.tsx @@ -77,6 +77,7 @@ const DnDFlow = () => { onInit={onInit} onDrop={onDrop} onDragOver={onDragOver} + nodeOrigin={nodeOrigin} > diff --git a/examples/react/src/examples/MultiSetNodes/index.tsx b/examples/react/src/examples/MultiSetNodes/index.tsx index fcb49924..b85cb33c 100644 --- a/examples/react/src/examples/MultiSetNodes/index.tsx +++ b/examples/react/src/examples/MultiSetNodes/index.tsx @@ -28,10 +28,15 @@ for (let i = 0; i < 100; i++) { }); } -const initEdges: Edge[] = []; +const initEdges: Edge[] = initNodes.reduce((res, node, index) => { + if (index > 0) { + res.push({ id: `${index - 1}-${index}`, source: (index - 1).toString(), target: index.toString() }); + } + return res; +}, []); const CustomNodeFlow = () => { - const { setNodes, updateNodeData } = useReactFlow(); + const { setNodes, updateNodeData, updateEdge } = useReactFlow(); const [nodes, , onNodesChange] = useNodesState(initNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); @@ -55,6 +60,10 @@ const CustomNodeFlow = () => { nodes.forEach((node) => updateNodeData(node.id, { label: 'node update' })); }; + const multiUpdateEdges = () => { + edges.forEach((edge) => updateEdge(edge.id, { label: 'edge update' })); + }; + return ( { + ); diff --git a/examples/react/src/examples/NodeResizer/index.tsx b/examples/react/src/examples/NodeResizer/index.tsx index 8d5b7602..397c1f22 100644 --- a/examples/react/src/examples/NodeResizer/index.tsx +++ b/examples/react/src/examples/NodeResizer/index.tsx @@ -37,6 +37,7 @@ const initialNodes: Node[] = [ type: 'defaultResizer', data: { label: 'default resizer' }, position: { x: 0, y: 0 }, + origin: [1, 1], style: { ...nodeStyle }, }, { @@ -127,7 +128,7 @@ const initialNodes: Node[] = [ data: { label: 'Parent', keepAspectRatio: true }, position: { x: 700, y: 0 }, width: 300, - height: 400, + height: 300, style: { ...nodeStyle }, }, { @@ -147,7 +148,9 @@ const initialNodes: Node[] = [ id: '5b', type: 'defaultResizer', data: { label: 'Child with expandParent' }, - position: { x: 150, y: 100 }, + position: { x: 100, y: 100 }, + width: 100, + height: 100, parentId: '5', expandParent: true, style: { ...nodeStyle }, @@ -155,8 +158,10 @@ const initialNodes: Node[] = [ { id: '5c', type: 'defaultResizer', - data: { label: 'Child with expandParent & keepAspectRatio', keepAspectRatio: true }, - position: { x: 25, y: 200 }, + data: { label: 'Child with expandParent & keepAspectRatio' }, + position: { x: 250, y: 200 }, + height: 100, + width: 100, parentId: '5', expandParent: true, style: { ...nodeStyle }, diff --git a/examples/react/src/examples/NodeTypesObjectChange/index.tsx b/examples/react/src/examples/NodeTypesObjectChange/index.tsx index 46b5cd5e..b3b8fd02 100644 --- a/examples/react/src/examples/NodeTypesObjectChange/index.tsx +++ b/examples/react/src/examples/NodeTypesObjectChange/index.tsx @@ -28,6 +28,14 @@ const initialNodes: Node[] = [ data: { label: 'A Node' }, position: { x: 250, y: 0 }, }, + { + id: '3', + type: 'b', + sourcePosition: Position.Right, + targetPosition: Position.Left, + data: { label: 'B Node' }, + position: { x: 350, y: 0 }, + }, ]; const buttonStyle: CSSProperties = { @@ -78,6 +86,7 @@ const NodeTypeChangeFlow = () => { onEdgesChange={onEdgesChange} onConnect={onConnect} nodeTypes={nodeTypesObjects[nodeTypesId]} + fitView > + ); }; -export default Subflow; +export default () => ( + + + +); diff --git a/examples/react/src/examples/Undirectional/index.tsx b/examples/react/src/examples/Undirectional/index.tsx index c4cf14a5..1a507876 100644 --- a/examples/react/src/examples/Undirectional/index.tsx +++ b/examples/react/src/examples/Undirectional/index.tsx @@ -10,7 +10,7 @@ import { Edge, ConnectionLineType, ConnectionMode, - updateEdge, + reconnectEdge, useNodesState, useEdgesState, } from '@xyflow/react'; @@ -22,55 +22,55 @@ const initialNodes: Node[] = [ id: '00', type: 'custom', position: { x: 300, y: 250 }, - data: null, + data: {}, }, { id: '01', type: 'custom', position: { x: 100, y: 50 }, - data: null, + data: {}, }, { id: '02', type: 'custom', position: { x: 500, y: 50 }, - data: null, + data: {}, }, { id: '03', type: 'custom', position: { x: 500, y: 500 }, - data: null, + data: {}, }, { id: '04', type: 'custom', position: { x: 100, y: 500 }, - data: null, + data: {}, }, { id: '10', type: 'custom', position: { x: 300, y: 5 }, - data: null, + data: {}, }, { id: '20', type: 'custom', position: { x: 600, y: 250 }, - data: null, + data: {}, }, { id: '30', type: 'custom', position: { x: 300, y: 600 }, - data: null, + data: {}, }, { id: '40', type: 'custom', position: { x: 5, y: 250 }, - data: null, + data: {}, }, ]; @@ -186,8 +186,8 @@ const UpdateNodeInternalsFlow = () => { const { screenToFlowPosition } = useReactFlow(); const onConnect = useCallback((params: Edge | Connection) => setEdges((els) => addEdge(params, els)), [setEdges]); - const onEdgeUpdate = useCallback( - (oldEdge: Edge, newConnection: Connection) => setEdges((els) => updateEdge(oldEdge, newConnection, els)), + const onReconnect = useCallback( + (oldEdge: Edge, newConnection: Connection) => setEdges((els) => reconnectEdge(oldEdge, newConnection, els)), [] ); @@ -215,7 +215,7 @@ const UpdateNodeInternalsFlow = () => { onPaneClick={onPaneClick} connectionLineType={ConnectionLineType.Bezier} connectionMode={ConnectionMode.Loose} - onEdgeUpdate={onEdgeUpdate} + onReconnect={onReconnect} /> ); }; diff --git a/examples/react/src/examples/UseKeyPress/index.tsx b/examples/react/src/examples/UseKeyPress/index.tsx index 28a95090..af3f82ea 100644 --- a/examples/react/src/examples/UseKeyPress/index.tsx +++ b/examples/react/src/examples/UseKeyPress/index.tsx @@ -1,9 +1,12 @@ import { useKeyPress } from '@xyflow/react'; +import { useEffect } from 'react'; const UseKeyPressComponent = () => { const metaPressed = useKeyPress(['Meta']); - console.log({ metaPressed }); + useEffect(() => { + console.log({ metaPressed }); + }, [metaPressed]); return
; }; diff --git a/examples/react/src/examples/Validation/index.tsx b/examples/react/src/examples/Validation/index.tsx index 936e1396..25ed4eff 100644 --- a/examples/react/src/examples/Validation/index.tsx +++ b/examples/react/src/examples/Validation/index.tsx @@ -13,7 +13,7 @@ import { OnConnectStart, OnConnectEnd, OnConnect, - updateEdge, + reconnectEdge, Edge, IsValidConnection, OnBeforeDelete, @@ -24,10 +24,10 @@ import ConnectionStatus from './ConnectionStatus'; import styles from './validation.module.css'; const initialNodes: Node[] = [ - { id: '0', type: 'custominput', position: { x: 0, y: 150 }, data: null }, - { id: 'A', type: 'customnode', position: { x: 250, y: 0 }, data: null }, - { id: 'B', type: 'customnode', position: { x: 250, y: 150 }, data: null }, - { id: 'C', type: 'customnode', position: { x: 250, y: 300 }, data: null }, + { id: '0', type: 'custominput', position: { x: 0, y: 150 }, data: {} }, + { id: 'A', type: 'customnode', position: { x: 250, y: 0 }, data: {} }, + { id: 'B', type: 'customnode', position: { x: 250, y: 150 }, data: {} }, + { id: 'C', type: 'customnode', position: { x: 250, y: 300 }, data: {} }, ]; const isValidConnection: IsValidConnection = (connection) => connection.target === 'B'; @@ -41,7 +41,7 @@ const CustomInput: FC = () => ( const CustomNode: FC = ({ id }) => ( <> - +
{id}
@@ -81,8 +81,8 @@ const ValidationFlow = () => { [value] ); - const onEdgeUpdate = useCallback( - (oldEdge: Edge, newConnection: Connection) => setEdges((els) => updateEdge(oldEdge, newConnection, els)), + const onReconnect = useCallback( + (oldEdge: Edge, newConnection: Connection) => setEdges((els) => reconnectEdge(oldEdge, newConnection, els)), [setEdges] ); @@ -102,7 +102,7 @@ const ValidationFlow = () => { nodeTypes={nodeTypes} onConnectStart={onConnectStart} onConnectEnd={onConnectEnd} - onEdgeUpdate={onEdgeUpdate} + onReconnect={onReconnect} isValidConnection={isValidConnection} onBeforeDelete={onBeforeDelete} fitView diff --git a/examples/react/src/generic-tests/edges/general.ts b/examples/react/src/generic-tests/edges/general.ts index 23977657..3daceec8 100644 --- a/examples/react/src/generic-tests/edges/general.ts +++ b/examples/react/src/generic-tests/edges/general.ts @@ -149,7 +149,7 @@ export default { // source: '9', // target: '11', // label: 'focusable', - // updatable: true + // reconnectable: true // }, // { // id: 'not-focusable', diff --git a/examples/svelte/src/generic-tests/edges/general.ts b/examples/svelte/src/generic-tests/edges/general.ts index 0e62f887..5e94d2fb 100644 --- a/examples/svelte/src/generic-tests/edges/general.ts +++ b/examples/svelte/src/generic-tests/edges/general.ts @@ -146,7 +146,7 @@ export default { // source: '9', // target: '11', // label: 'focusable', - // updatable: true + // reconnectable: true // }, // { // id: 'not-focusable', diff --git a/examples/svelte/src/routes/examples/custom-connection-line/ConnectionLine.svelte b/examples/svelte/src/routes/examples/custom-connection-line/ConnectionLine.svelte index 0a7f87b5..d315f572 100644 --- a/examples/svelte/src/routes/examples/custom-connection-line/ConnectionLine.svelte +++ b/examples/svelte/src/routes/examples/custom-connection-line/ConnectionLine.svelte @@ -1,9 +1,24 @@ -{#if $connection.path} - +{#if $connection.inProgress} + {/if} diff --git a/examples/svelte/src/routes/examples/validation/+page.svelte b/examples/svelte/src/routes/examples/validation/+page.svelte index c973e193..d2a7cd2e 100644 --- a/examples/svelte/src/routes/examples/validation/+page.svelte +++ b/examples/svelte/src/routes/examples/validation/+page.svelte @@ -24,8 +24,8 @@ data: { label: 'only connectable with B' }, ...nodeDefaults }, - { id: 'A', position: { x: 250, y: 0 }, data: { label: 'A' }, ...nodeDefaults }, - { id: 'B', position: { x: 250, y: 150 }, data: { label: 'B' }, ...nodeDefaults }, + { id: 'A', position: { x: 250, y: 0 }, data: { label: 'A' } }, + { id: 'B', position: { x: 250, y: 150 }, data: { label: 'B' } }, { id: 'C', position: { x: 250, y: 300 }, data: { label: 'C' }, ...nodeDefaults } ]); diff --git a/package.json b/package.json index d87e4403..e4cc08e9 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,10 @@ "test:svelte:ui": "pnpm --filter=playwright run test:svelte:ui", "test:react": "pnpm --filter=playwright run test:react", "test:react:ui": "pnpm --filter=playwright run test:react:ui", - "build": "turbo run build", - "test": "turbo run test", - "lint": "turbo run lint", - "typecheck": "turbo run typecheck", + "build:all": "turbo run build", + "build": "turbo run build --filter=./packages/**", + "lint": "turbo run lint --filter=./packages/**", + "typecheck": "turbo run typecheck --filter=./packages/**", "release": "changeset publish", "clean": "pnpm -r --parallel exec rimraf dist .turbo node_modules" }, @@ -35,8 +35,9 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "rimraf": "^3.0.2", - "rollup": "^3.23.0", - "turbo": "^1.10.0", + "rollup": "^4.18.0", + "turbo": "^2.0.3", "typescript": "5.1.3" - } + }, + "packageManager": "pnpm@9.2.0" } diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 47908864..64fd9491 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,39 @@ # @xyflow/react +## 12.0.0-next.25 + +- `useConnection` returns `ConnectionState` with `toNode` and `toHandle` +- add `toNode` and `toHandle` to custom connection line props +- node origin is part of position absolute +- refactor connection handling + +## 12.0.0-next.24 + +- fix `window` bug for SSR for real + +## 12.0.0-next.23 + +- fix `window` bug for SSR + +## 12.0.0-next.22 + +- ⚠️ rename `updateEdge` to `reconnectEdge` and realted APIs [#4373](https://github.com/xyflow/xyflow/pull/4373) +- revise selection usability (capture while dragging out of the flow) +- use correct end handle position when drawing a connection lines +- determine correct end positions for connection lines + +## 12.0.0-next.21 + +- fix node origin bug + +## 12.0.0-next.20 + +- add `updateEdge` and `updateEdgeData` helpers to `useReactFlow` +- enable dynamic edge label updates +- prevent zooming on mobile if zoomOnPinch is false +- add straight edge to path built-in-types +- abort drag when multiple touches are detected + ## 12.0.0-next.19 - update internals on node resizer updates diff --git a/packages/react/package.json b/packages/react/package.json index b5273054..437485ec 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/react", - "version": "12.0.0-next.19", + "version": "12.0.0-next.24", "description": "React Flow - A highly customizable React library for building node-based editors and interactive flow charts.", "keywords": [ "react", diff --git a/packages/react/src/additional-components/MiniMap/MiniMap.tsx b/packages/react/src/additional-components/MiniMap/MiniMap.tsx index 0a05890d..bd7d6634 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMap.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMap.tsx @@ -25,12 +25,8 @@ const selector = (s: ReactFlowState) => { return { viewBB, - boundingRect: - s.nodeLookup.size > 0 - ? getBoundsOfRects(getInternalNodesBounds(s.nodeLookup, { nodeOrigin: s.nodeOrigin }), viewBB) - : viewBB, + boundingRect: s.nodeLookup.size > 0 ? getBoundsOfRects(getInternalNodesBounds(s.nodeLookup), viewBB) : viewBB, rfId: s.rfId, - nodeOrigin: s.nodeOrigin, panZoom: s.panZoom, translateExtent: s.translateExtent, flowWidth: s.width, diff --git a/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx b/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx index da6d3745..5bb7e939 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMapNodes.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ComponentType, memo } from 'react'; -import { NodeOrigin, getNodeDimensions, getNodePositionWithOrigin, nodeHasDimensions } from '@xyflow/system'; +import { getNodeDimensions, nodeHasDimensions } from '@xyflow/system'; import { shallow } from 'zustand/shallow'; import { useStore } from '../../hooks/useStore'; @@ -11,7 +11,6 @@ import type { MiniMapNodes as MiniMapNodesProps, GetMiniMapNodeAttribute, MiniMa declare const window: any; -const selector = (s: ReactFlowState) => s.nodeOrigin; const selectorNodeIds = (s: ReactFlowState) => s.nodes.map((node) => node.id); const getAttrFunction = (func: any): GetMiniMapNodeAttribute => func instanceof Function ? func : () => func; @@ -28,7 +27,6 @@ function MiniMapNodes({ onClick, }: MiniMapNodesProps) { const nodeIds = useStore(selectorNodeIds, shallow); - const nodeOrigin = useStore(selector); const nodeColorFunc = getAttrFunction(nodeColor); const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor); const nodeClassNameFunc = getAttrFunction(nodeClassName); @@ -46,7 +44,6 @@ function MiniMapNodes({ key={nodeId} id={nodeId} - nodeOrigin={nodeOrigin} nodeColorFunc={nodeColorFunc} nodeStrokeColorFunc={nodeStrokeColorFunc} nodeClassNameFunc={nodeClassNameFunc} @@ -63,7 +60,6 @@ function MiniMapNodes({ function NodeComponentWrapperInner({ id, - nodeOrigin, nodeColorFunc, nodeStrokeColorFunc, nodeClassNameFunc, @@ -74,7 +70,6 @@ function NodeComponentWrapperInner({ onClick, }: { id: string; - nodeOrigin: NodeOrigin; nodeColorFunc: GetMiniMapNodeAttribute; nodeStrokeColorFunc: GetMiniMapNodeAttribute; nodeClassNameFunc: GetMiniMapNodeAttribute; @@ -86,7 +81,7 @@ function NodeComponentWrapperInner({ }) { const { node, x, y } = useStore((s) => { const node = s.nodeLookup.get(id) as InternalNode; - const { x, y } = getNodePositionWithOrigin(node, nodeOrigin).positionAbsolute; + const { x, y } = node.internals.positionAbsolute; return { node, diff --git a/packages/react/src/additional-components/NodeResizer/NodeResizeControl.tsx b/packages/react/src/additional-components/NodeResizer/NodeResizeControl.tsx index 79746f84..63128c09 100644 --- a/packages/react/src/additional-components/NodeResizer/NodeResizeControl.tsx +++ b/packages/react/src/additional-components/NodeResizer/NodeResizeControl.tsx @@ -67,26 +67,30 @@ function ResizeControl({ }, onChange: (change: XYResizerChange, childChanges: XYResizerChildChange[]) => { const { triggerNodeChanges, nodeLookup, parentLookup, nodeOrigin } = store.getState(); - const changes: NodeChange[] = []; const nextPosition = { x: change.x, y: change.y }; - const node = nodeLookup.get(id); + if (node && node.expandParent && node.parentId) { + const origin = node.origin ?? nodeOrigin; + const width = change.width ?? node.measured.width!; + const height = change.height ?? node.measured.height!; + const child: ParentExpandChild = { id: node.id, parentId: node.parentId, rect: { - width: change.width ?? node.measured.width!, - height: change.height ?? node.measured.height!, + width, + height, ...evaluateAbsolutePosition( { x: change.x ?? node.position.x, y: change.y ?? node.position.y, }, + { width, height }, node.parentId, nodeLookup, - node.origin ?? nodeOrigin + origin ), }, }; @@ -94,9 +98,10 @@ function ResizeControl({ const parentExpandChanges = handleExpandParent([child], nodeLookup, parentLookup, nodeOrigin); changes.push(...parentExpandChanges); - // when the parent was expanded by the child node, its position will be clamped at 0,0 - nextPosition.x = change.x ? Math.max(0, change.x) : undefined; - nextPosition.y = change.y ? Math.max(0, change.y) : undefined; + // when the parent was expanded by the child node, its position will be clamped at + // 0,0 when node origin is 0,0 and to width, height if it's 1,1 + nextPosition.x = change.x ? Math.max(origin[0] * width, change.x) : undefined; + nextPosition.y = change.y ? Math.max(origin[1] * height, change.y) : undefined; } if (nextPosition.x !== undefined && nextPosition.y !== undefined) { diff --git a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx index f14f822e..b4431157 100644 --- a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx +++ b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx @@ -1,7 +1,7 @@ import { useCallback, CSSProperties } from 'react'; import cc from 'classcat'; import { shallow } from 'zustand/shallow'; -import { Rect, Position, getNodeToolbarTransform, getNodesBounds } from '@xyflow/system'; +import { Position, getNodeToolbarTransform, getInternalNodesBounds, NodeLookup } from '@xyflow/system'; import { InternalNode, ReactFlowState } from '../../types'; import { useStore } from '../../hooks/useStore'; @@ -17,21 +17,24 @@ const nodeEqualityFn = (a?: InternalNode, b?: InternalNode) => a?.selected !== b?.selected || a?.internals.z !== b?.internals.z; -const nodesEqualityFn = (a: InternalNode[], b: InternalNode[]) => { - if (a.length !== b.length) { +const nodesEqualityFn = (a: NodeLookup, b: NodeLookup) => { + if (a.size !== b.size) { return false; } - return !a.some((node, i) => nodeEqualityFn(node, b[i])); + for (const [key, node] of a) { + if (nodeEqualityFn(node, b.get(key))) { + return false; + } + } + + return true; }; const storeSelector = (state: ReactFlowState) => ({ - viewport: { - x: state.transform[0], - y: state.transform[1], - zoom: state.transform[2], - }, - nodeOrigin: state.nodeOrigin, + x: state.transform[0], + y: state.transform[1], + zoom: state.transform[2], selectedNodesCount: state.nodes.filter((node) => node.selected).length, }); @@ -49,36 +52,41 @@ export function NodeToolbar({ const contextNodeId = useNodeId(); const nodesSelector = useCallback( - (state: ReactFlowState): InternalNode[] => { + (state: ReactFlowState): NodeLookup => { const nodeIds = Array.isArray(nodeId) ? nodeId : [nodeId || contextNodeId || '']; - - return nodeIds.reduce((acc, id) => { + const internalNodes = nodeIds.reduce((res, id) => { const node = state.nodeLookup.get(id); if (node) { - acc.push(node); + res.set(node.id, node); } - return acc; - }, []); + + return res; + }, new Map()); + + return internalNodes; }, [nodeId, contextNodeId] ); const nodes = useStore(nodesSelector, nodesEqualityFn); - const { viewport, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow); + const { x, y, zoom, selectedNodesCount } = useStore(storeSelector, shallow); // if isVisible is not set, we show the toolbar only if its node is selected and no other node is selected const isActive = - typeof isVisible === 'boolean' ? isVisible : nodes.length === 1 && nodes[0].selected && selectedNodesCount === 1; + typeof isVisible === 'boolean' + ? isVisible + : nodes.size === 1 && nodes.values().next().value.selected && selectedNodesCount === 1; - if (!isActive || !nodes.length) { + if (!isActive || !nodes.size) { return null; } - const nodeRect: Rect = getNodesBounds(nodes, { nodeOrigin }); - const zIndex: number = Math.max(...nodes.map((node) => node.internals.z + 1)); + const nodeRect = getInternalNodesBounds(nodes); + const nodesArray = Array.from(nodes.values()); + const zIndex = Math.max(...nodesArray.map((node) => node.internals.z + 1)); const wrapperStyle: CSSProperties = { position: 'absolute', - transform: getNodeToolbarTransform(nodeRect, viewport, position, offset, align), + transform: getNodeToolbarTransform(nodeRect, { x, y, zoom }, position, offset, align), zIndex, ...style, }; @@ -89,7 +97,8 @@ export function NodeToolbar({ style={wrapperStyle} className={cc(['react-flow__node-toolbar', className])} {...rest} - data-id={nodes.reduce((acc, node) => `${acc}${node.id} `, '').trim()} + // @todo: check if we could only do this for non-prod envs + data-id={nodesArray.reduce((acc, node) => `${acc}${node.id} `, '').trim()} > {children}
diff --git a/packages/react/src/components/ConnectionLine/index.tsx b/packages/react/src/components/ConnectionLine/index.tsx index 4ca5c106..a5f7e1e2 100644 --- a/packages/react/src/components/ConnectionLine/index.tsx +++ b/packages/react/src/components/ConnectionLine/index.tsx @@ -1,129 +1,18 @@ -import { CSSProperties, useCallback } from 'react'; +import { CSSProperties } from 'react'; import { shallow } from 'zustand/shallow'; import cc from 'classcat'; import { - Position, ConnectionLineType, - ConnectionMode, getBezierPath, getSmoothStepPath, - type ConnectionStatus, - type HandleType, + getConnectionStatus, + getStraightPath, } 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.nodeLookup.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?.internals.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.measured.width ?? 0) / 2; - const fromHandleY = fromHandle ? fromHandle.y + fromHandle.height / 2 : fromNode.measured.height ?? 0; - const fromX = fromNode.internals.positionAbsolute.x + fromHandleX; - const fromY = fromNode.internals.positionAbsolute.y + fromHandleY; - const fromPosition = fromHandle?.position; - const toPosition = fromPosition ? oppositePosition[fromPosition] : null; - - if (!fromPosition || !toPosition) { - return null; - } - - if (CustomComponent) { - return ( - - ); - } - - 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 ; -}; - -ConnectionLine.displayName = 'ConnectionLine'; +import type { ConnectionLineComponent, ReactFlowState } from '../../types'; +import { useConnection } from '../../hooks/useConnection'; type ConnectionLineWrapperProps = { type: ConnectionLineType; @@ -133,19 +22,18 @@ type ConnectionLineWrapperProps = { }; const selector = (s: ReactFlowState) => ({ - nodeId: s.connectionStartHandle?.nodeId, - handleType: s.connectionStartHandle?.type, nodesConnectable: s.nodesConnectable, - connectionStatus: s.connectionStatus, + isValid: s.connection.isValid, + inProgress: s.connection.inProgress, width: s.width, height: s.height, }); export function ConnectionLineWrapper({ containerStyle, style, type, component }: ConnectionLineWrapperProps) { - const { nodeId, handleType, nodesConnectable, width, height, connectionStatus } = useStore(selector, shallow); - const isValid = !!(nodeId && handleType && width && nodesConnectable); + const { nodesConnectable, width, height, isValid, inProgress } = useStore(selector, shallow); + const renderConnection = !!(width && nodesConnectable && inProgress); - if (!isValid) { + if (!renderConnection) { return null; } @@ -156,16 +44,79 @@ export function ConnectionLineWrapper({ containerStyle, style, type, component } height={height} className="react-flow__connectionline react-flow__container" > - - + + ); } + +type ConnectionLineProps = { + type: ConnectionLineType; + style?: CSSProperties; + CustomComponent?: ConnectionLineComponent; + isValid: boolean | null; +}; + +const ConnectionLine = ({ style, type = ConnectionLineType.Bezier, CustomComponent, isValid }: ConnectionLineProps) => { + const { inProgress, from, fromNode, fromHandle, fromPosition, to, toNode, toHandle, toPosition } = useConnection(); + + if (!inProgress) { + return; + } + + if (CustomComponent) { + return ( + + ); + } + + let path = ''; + + const pathParams = { + sourceX: from.x, + sourceY: from.y, + sourcePosition: fromPosition, + targetX: to.x, + targetY: to.y, + targetPosition: toPosition, + }; + + switch (type) { + case ConnectionLineType.Bezier: + [path] = getBezierPath(pathParams); + break; + case ConnectionLineType.SimpleBezier: + [path] = getSimpleBezierPath(pathParams); + break; + case ConnectionLineType.Step: + [path] = getSmoothStepPath({ + ...pathParams, + borderRadius: 0, + }); + break; + case ConnectionLineType.SmoothStep: + [path] = getSmoothStepPath(pathParams); + break; + default: + [path] = getStraightPath(pathParams); + } + + return ; +}; + +ConnectionLine.displayName = 'ConnectionLine'; diff --git a/packages/react/src/components/EdgeWrapper/EdgeUpdateAnchors.tsx b/packages/react/src/components/EdgeWrapper/EdgeUpdateAnchors.tsx index e4810f11..236592ee 100644 --- a/packages/react/src/components/EdgeWrapper/EdgeUpdateAnchors.tsx +++ b/packages/react/src/components/EdgeWrapper/EdgeUpdateAnchors.tsx @@ -1,4 +1,4 @@ -// Updatable edges have a anchors around their handles to update the edge. +// Reconnectable edges have a anchors around their handles to reconnect the edge. import { XYHandle, type Connection, EdgePosition } from '@xyflow/system'; import { EdgeAnchor } from '../Edges/EdgeAnchor'; @@ -7,20 +7,20 @@ import { useStoreApi } from '../../hooks/useStore'; type EdgeUpdateAnchorsProps = { edge: EdgeType; - isUpdatable: boolean | 'source' | 'target'; - edgeUpdaterRadius: EdgeWrapperProps['edgeUpdaterRadius']; + isReconnectable: boolean | 'source' | 'target'; + reconnectRadius: EdgeWrapperProps['reconnectRadius']; sourceHandleId: Edge['sourceHandle']; targetHandleId: Edge['targetHandle']; - onEdgeUpdate: EdgeWrapperProps['onEdgeUpdate']; - onEdgeUpdateStart: EdgeWrapperProps['onEdgeUpdateStart']; - onEdgeUpdateEnd: EdgeWrapperProps['onEdgeUpdateEnd']; + onReconnect: EdgeWrapperProps['onReconnect']; + onReconnectStart: EdgeWrapperProps['onReconnectStart']; + onReconnectEnd: EdgeWrapperProps['onReconnectEnd']; setUpdateHover: (hover: boolean) => void; - setUpdating: (updating: boolean) => void; + setReconnecting: (updating: boolean) => void; } & EdgePosition; export function EdgeUpdateAnchors({ - isUpdatable, - edgeUpdaterRadius, + isReconnectable, + reconnectRadius, edge, targetHandleId, sourceHandleId, @@ -30,10 +30,10 @@ export function EdgeUpdateAnchors({ targetY, sourcePosition, targetPosition, - onEdgeUpdate, - onEdgeUpdateStart, - onEdgeUpdateEnd, - setUpdating, + onReconnect, + onReconnectStart, + onReconnectEnd, + setReconnecting, setUpdateHover, }: EdgeUpdateAnchorsProps) { const store = useStoreApi(); @@ -65,15 +65,15 @@ export function EdgeUpdateAnchors({ const isTarget = isSourceHandle; - setUpdating(true); - onEdgeUpdateStart?.(event, edge, handleType); + setReconnecting(true); + onReconnectStart?.(event, edge, handleType); - const _onEdgeUpdateEnd = (evt: MouseEvent | TouchEvent) => { - setUpdating(false); - onEdgeUpdateEnd?.(evt, edge, handleType); + const _onReconnectEnd = (evt: MouseEvent | TouchEvent) => { + setReconnecting(false); + onReconnectEnd?.(evt, edge, handleType); }; - const onConnectEdge = (connection: Connection) => onEdgeUpdate?.(edge, connection); + const onConnectEdge = (connection: Connection) => onReconnect?.(edge, connection); XYHandle.onPointerDown(event.nativeEvent, { autoPanOnConnect, @@ -93,43 +93,43 @@ export function EdgeUpdateAnchors({ onConnect: onConnectEdge, onConnectStart, onConnectEnd, - onEdgeUpdateEnd: _onEdgeUpdateEnd, + onReconnectEnd: _onReconnectEnd, updateConnection, getTransform: () => store.getState().transform, - getConnectionStartHandle: () => store.getState().connectionStartHandle, + getFromHandle: () => store.getState().connection.fromHandle, }); }; - const onEdgeUpdaterSourceMouseDown = (event: React.MouseEvent): void => + const onReconnectSourceMouseDown = (event: React.MouseEvent): void => handleEdgeUpdater(event, true); - const onEdgeUpdaterTargetMouseDown = (event: React.MouseEvent): void => + const onReconnectTargetMouseDown = (event: React.MouseEvent): void => handleEdgeUpdater(event, false); - const onEdgeUpdaterMouseEnter = () => setUpdateHover(true); - const onEdgeUpdaterMouseOut = () => setUpdateHover(false); + const onReconnectMouseEnter = () => setUpdateHover(true); + const onReconnectMouseOut = () => setUpdateHover(false); return ( <> - {(isUpdatable === 'source' || isUpdatable === true) && ( + {(isReconnectable === 'source' || isReconnectable === true) && ( )} - {(isUpdatable === 'target' || isUpdatable === true) && ( + {(isReconnectable === 'target' || isReconnectable === true) && ( )} diff --git a/packages/react/src/components/EdgeWrapper/index.tsx b/packages/react/src/components/EdgeWrapper/index.tsx index 49a9c61b..91b7085a 100644 --- a/packages/react/src/components/EdgeWrapper/index.tsx +++ b/packages/react/src/components/EdgeWrapper/index.tsx @@ -18,7 +18,7 @@ import type { Edge, EdgeWrapperProps } from '../../types'; export function EdgeWrapper({ id, edgesFocusable, - edgesUpdatable, + edgesReconnectable, elementsSelectable, onClick, onDoubleClick, @@ -26,10 +26,10 @@ export function EdgeWrapper({ onMouseEnter, onMouseMove, onMouseLeave, - edgeUpdaterRadius, - onEdgeUpdate, - onEdgeUpdateStart, - onEdgeUpdateEnd, + reconnectRadius, + onReconnect, + onReconnectStart, + onReconnectEnd, rfId, edgeTypes, noPanClassName, @@ -50,14 +50,14 @@ export function EdgeWrapper({ } const isFocusable = !!(edge.focusable || (edgesFocusable && typeof edge.focusable === 'undefined')); - const isUpdatable = - typeof onEdgeUpdate !== 'undefined' && - (edge.updatable || (edgesUpdatable && typeof edge.updatable === 'undefined')); + const isReconnectable = + typeof onReconnect !== 'undefined' && + (edge.reconnectable || (edgesReconnectable && typeof edge.reconnectable === 'undefined')); const isSelectable = !!(edge.selectable || (elementsSelectable && typeof edge.selectable === 'undefined')); const edgeRef = useRef(null); const [updateHover, setUpdateHover] = useState(false); - const [updating, setUpdating] = useState(false); + const [reconnecting, setReconnecting] = useState(false); const store = useStoreApi(); const { zIndex, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = useStore( @@ -207,7 +207,7 @@ export function EdgeWrapper({ aria-describedby={isFocusable ? `${ARIA_EDGE_DESC_KEY}-${rfId}` : undefined} ref={edgeRef} > - {!updating && ( + {!reconnecting && ( ({ interactionWidth={edge.interactionWidth} /> )} - {isUpdatable && ( + {isReconnectable && ( edge={edge} - isUpdatable={isUpdatable} - edgeUpdaterRadius={edgeUpdaterRadius} - onEdgeUpdate={onEdgeUpdate} - onEdgeUpdateStart={onEdgeUpdateStart} - onEdgeUpdateEnd={onEdgeUpdateEnd} + isReconnectable={isReconnectable} + reconnectRadius={reconnectRadius} + onReconnect={onReconnect} + onReconnectStart={onReconnectStart} + onReconnectEnd={onReconnectEnd} sourceX={sourceX} sourceY={sourceY} targetX={targetX} @@ -254,7 +254,7 @@ export function EdgeWrapper({ sourcePosition={sourcePosition} targetPosition={targetPosition} setUpdateHover={setUpdateHover} - setUpdating={setUpdating} + setReconnecting={setReconnecting} sourceHandleId={edge.sourceHandle} targetHandleId={edge.targetHandle} /> diff --git a/packages/react/src/components/Edges/EdgeText.tsx b/packages/react/src/components/Edges/EdgeText.tsx index d6c68caa..2e3ad041 100644 --- a/packages/react/src/components/Edges/EdgeText.tsx +++ b/packages/react/src/components/Edges/EdgeText.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useCallback } from 'react'; +import { memo, useState, useEffect, useRef } from 'react'; import cc from 'classcat'; import type { Rect } from '@xyflow/system'; @@ -19,19 +19,20 @@ function EdgeTextComponent({ }: EdgeTextProps) { const [edgeTextBbox, setEdgeTextBbox] = useState({ x: 1, y: 0, width: 0, height: 0 }); const edgeTextClasses = cc(['react-flow__edge-textwrapper', className]); + const edgeTextRef = useRef(null); - const onEdgeTextRefChange = useCallback((edgeRef: SVGTextElement) => { - if (edgeRef === null) return; + useEffect(() => { + if (edgeTextRef.current) { + const textBbox = edgeTextRef.current.getBBox(); - const textBbox = edgeRef.getBBox(); - - setEdgeTextBbox({ - x: textBbox.x, - y: textBbox.y, - width: textBbox.width, - height: textBbox.height, - }); - }, []); + setEdgeTextBbox({ + x: textBbox.x, + y: textBbox.y, + width: textBbox.width, + height: textBbox.height, + }); + } + }, [label]); if (typeof label === 'undefined' || !label) { return null; @@ -60,7 +61,7 @@ function EdgeTextComponent({ className="react-flow__edge-text" y={edgeTextBbox.height / 2} dy="0.3em" - ref={onEdgeTextRefChange} + ref={edgeTextRef} style={labelStyle} > {label} diff --git a/packages/react/src/components/Handle/index.tsx b/packages/react/src/components/Handle/index.tsx index 760733a0..02ba9ced 100644 --- a/packages/react/src/components/Handle/index.tsx +++ b/packages/react/src/components/Handle/index.tsx @@ -39,28 +39,22 @@ const selector = (s: ReactFlowState) => ({ const connectingSelector = (nodeId: string | null, handleId: string | null, type: HandleType) => (state: ReactFlowState) => { - const { - connectionStartHandle: startHandle, - connectionEndHandle: endHandle, - connectionClickStartHandle: clickHandle, - connectionMode, - connectionStatus, - } = state; + const { connectionClickStartHandle: clickHandle, connectionMode, connection } = state; - const connectingTo = endHandle?.nodeId === nodeId && endHandle?.handleId === handleId && endHandle?.type === type; + const { fromHandle, toHandle, isValid } = connection; + + const connectingTo = toHandle?.nodeId === nodeId && toHandle?.id === handleId && toHandle?.type === type; return { - connectingFrom: - startHandle?.nodeId === nodeId && startHandle?.handleId === handleId && startHandle?.type === type, + connectingFrom: fromHandle?.nodeId === nodeId && fromHandle?.id === handleId && fromHandle?.type === type, connectingTo, - clickConnecting: - clickHandle?.nodeId === nodeId && clickHandle?.handleId === handleId && clickHandle?.type === type, + clickConnecting: clickHandle?.nodeId === nodeId && clickHandle?.id === handleId && clickHandle?.type === type, isPossibleEndHandle: connectionMode === ConnectionMode.Strict - ? startHandle?.type !== type - : nodeId !== startHandle?.nodeId || handleId !== startHandle?.handleId, - connectionInProcess: !!startHandle, - valid: connectingTo && connectionStatus === 'valid', + ? fromHandle?.type !== type + : nodeId !== fromHandle?.nodeId || handleId !== fromHandle?.id, + connectionInProcess: !!fromHandle, + valid: connectingTo && isValid, }; }; @@ -144,7 +138,7 @@ function HandleComponent( onConnect: onConnectExtended, isValidConnection: isValidConnection || currentStore.isValidConnection, getTransform: () => store.getState().transform, - getConnectionStartHandle: () => store.getState().connectionStartHandle, + getFromHandle: () => store.getState().connection.fromHandle, }); } @@ -172,7 +166,7 @@ function HandleComponent( if (!connectionClickStartHandle) { onClickConnectStart?.(event.nativeEvent, { nodeId, handleId, handleType: type }); - store.setState({ connectionClickStartHandle: { nodeId, type, handleId } }); + store.setState({ connectionClickStartHandle: { nodeId, type, id: handleId } }); return; } @@ -186,7 +180,7 @@ function HandleComponent( }, connectionMode, fromNodeId: connectionClickStartHandle.nodeId, - fromHandleId: connectionClickStartHandle.handleId || null, + fromHandleId: connectionClickStartHandle.id || null, fromType: connectionClickStartHandle.type, isValidConnection: isValidConnectionHandler, flowId, diff --git a/packages/react/src/components/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx index 279cda82..655233c9 100644 --- a/packages/react/src/components/NodeWrapper/index.tsx +++ b/packages/react/src/components/NodeWrapper/index.tsx @@ -6,7 +6,6 @@ import { elementSelectionKeys, errorMessages, getNodeDimensions, - getPositionWithOrigin, isInputDOMNode, nodeHasDimensions, } from '@xyflow/system'; @@ -40,7 +39,6 @@ export function NodeWrapper({ rfId, nodeTypes, nodeExtent, - nodeOrigin, onError, }: NodeWrapperProps) { const { node, internals, isParent } = useStore((s) => { @@ -87,15 +85,11 @@ export function NodeWrapper({ const nodeDimensions = getNodeDimensions(node); const inlineDimensions = getNodeInlineStyleDimensions(node); + // TODO: clamping should happen earlier const clampedPosition = nodeExtent ? clampPosition(internals.positionAbsolute, nodeExtent) : internals.positionAbsolute; - const positionWithOrigin = getPositionWithOrigin({ - ...clampedPosition, - ...nodeDimensions, - origin: node.origin || nodeOrigin, - }); const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave; const onMouseEnterHandler = onMouseEnter @@ -181,7 +175,7 @@ export function NodeWrapper({ ref={nodeRef} style={{ zIndex: internals.z, - transform: `translate(${positionWithOrigin.x}px,${positionWithOrigin.y}px)`, + transform: `translate(${clampedPosition.x}px,${clampedPosition.y}px)`, pointerEvents: hasPointerEvents ? 'all' : 'none', visibility: hasDimensions ? 'visible' : 'hidden', ...node.style, diff --git a/packages/react/src/components/NodesSelection/index.tsx b/packages/react/src/components/NodesSelection/index.tsx index fda3a8c9..e2bcd238 100644 --- a/packages/react/src/components/NodesSelection/index.tsx +++ b/packages/react/src/components/NodesSelection/index.tsx @@ -21,7 +21,6 @@ export type NodesSelectionProps = { const selector = (s: ReactFlowState) => { const { width, height, x, y } = getInternalNodesBounds(s.nodeLookup, { - nodeOrigin: s.nodeOrigin, filter: (node) => !!node.selected, }); diff --git a/packages/react/src/components/ReactFlowProvider/index.tsx b/packages/react/src/components/ReactFlowProvider/index.tsx index f04ebebd..4843afeb 100644 --- a/packages/react/src/components/ReactFlowProvider/index.tsx +++ b/packages/react/src/components/ReactFlowProvider/index.tsx @@ -4,6 +4,7 @@ import { Provider } from '../../contexts/StoreContext'; import { createStore } from '../../store'; import { BatchProvider } from '../BatchProvider'; import type { Node, Edge } from '../../types'; +import { NodeOrigin } from '@xyflow/system'; export type ReactFlowProviderProps = { initialNodes?: Node[]; @@ -13,6 +14,7 @@ export type ReactFlowProviderProps = { initialWidth?: number; initialHeight?: number; fitView?: boolean; + nodeOrigin?: NodeOrigin; children: ReactNode; }; @@ -24,6 +26,7 @@ export function ReactFlowProvider({ initialWidth: width, initialHeight: height, fitView, + nodeOrigin, children, }: ReactFlowProviderProps) { const [store] = useState(() => @@ -35,6 +38,7 @@ export function ReactFlowProvider({ width, height, fitView, + nodeOrigin, }) ); diff --git a/packages/react/src/components/StoreUpdater/index.tsx b/packages/react/src/components/StoreUpdater/index.tsx index 624d10a5..56a37134 100644 --- a/packages/react/src/components/StoreUpdater/index.tsx +++ b/packages/react/src/components/StoreUpdater/index.tsx @@ -26,7 +26,7 @@ const reactFlowFieldsToTrack = [ 'nodesConnectable', 'nodesFocusable', 'edgesFocusable', - 'edgesUpdatable', + 'edgesReconnectable', 'elevateNodesOnSelect', 'elevateEdgesOnSelect', 'minZoom', diff --git a/packages/react/src/container/EdgeRenderer/index.tsx b/packages/react/src/container/EdgeRenderer/index.tsx index 6636e833..6e898cbb 100644 --- a/packages/react/src/container/EdgeRenderer/index.tsx +++ b/packages/react/src/container/EdgeRenderer/index.tsx @@ -14,14 +14,14 @@ type EdgeRendererProps = Pick< | 'onEdgeDoubleClick' | 'defaultMarkerColor' | 'onlyRenderVisibleElements' - | 'onEdgeUpdate' + | 'onReconnect' | 'onEdgeContextMenu' | 'onEdgeMouseEnter' | 'onEdgeMouseMove' | 'onEdgeMouseLeave' - | 'onEdgeUpdateStart' - | 'onEdgeUpdateEnd' - | 'edgeUpdaterRadius' + | 'onReconnectStart' + | 'onReconnectEnd' + | 'reconnectRadius' | 'noPanClassName' | 'rfId' | 'disableKeyboardA11y' @@ -34,7 +34,7 @@ const selector = (s: ReactFlowState) => ({ width: s.width, height: s.height, edgesFocusable: s.edgesFocusable, - edgesUpdatable: s.edgesUpdatable, + edgesReconnectable: s.edgesReconnectable, elementsSelectable: s.elementsSelectable, connectionMode: s.connectionMode, onError: s.onError, @@ -46,19 +46,19 @@ function EdgeRendererComponent({ rfId, edgeTypes, noPanClassName, - onEdgeUpdate, + onReconnect, onEdgeContextMenu, onEdgeMouseEnter, onEdgeMouseMove, onEdgeMouseLeave, onEdgeClick, - edgeUpdaterRadius, + reconnectRadius, onEdgeDoubleClick, - onEdgeUpdateStart, - onEdgeUpdateEnd, + onReconnectStart, + onReconnectEnd, disableKeyboardA11y, }: EdgeRendererProps) { - const { edgesFocusable, edgesUpdatable, elementsSelectable, onError } = useStore(selector, shallow); + const { edgesFocusable, edgesReconnectable, elementsSelectable, onError } = useStore(selector, shallow); const edgeIds = useVisibleEdgeIds(onlyRenderVisibleElements); return ( @@ -71,19 +71,19 @@ function EdgeRendererComponent({ key={id} id={id} edgesFocusable={edgesFocusable} - edgesUpdatable={edgesUpdatable} + edgesReconnectable={edgesReconnectable} elementsSelectable={elementsSelectable} noPanClassName={noPanClassName} - onEdgeUpdate={onEdgeUpdate} + onReconnect={onReconnect} onContextMenu={onEdgeContextMenu} onMouseEnter={onEdgeMouseEnter} onMouseMove={onEdgeMouseMove} onMouseLeave={onEdgeMouseLeave} onClick={onEdgeClick} - edgeUpdaterRadius={edgeUpdaterRadius} + reconnectRadius={reconnectRadius} onDoubleClick={onEdgeDoubleClick} - onEdgeUpdateStart={onEdgeUpdateStart} - onEdgeUpdateEnd={onEdgeUpdateEnd} + onReconnectStart={onReconnectStart} + onReconnectEnd={onReconnectEnd} rfId={rfId} onError={onError} edgeTypes={edgeTypes} diff --git a/packages/react/src/container/FlowRenderer/index.tsx b/packages/react/src/container/FlowRenderer/index.tsx index 14216ac2..f3e0a23d 100644 --- a/packages/react/src/container/FlowRenderer/index.tsx +++ b/packages/react/src/container/FlowRenderer/index.tsx @@ -22,12 +22,13 @@ export type FlowRendererProps = Omit< | 'selectNodesOnDrag' | 'defaultMarkerColor' | 'rfId' - | 'nodeOrigin' > & { isControlledViewport: boolean; children: ReactNode; }; +const win = typeof window !== 'undefined' ? window : undefined; + const selector = (s: ReactFlowState) => { return { nodesSelectionActive: s.nodesSelectionActive, userSelectionActive: s.userSelectionActive }; }; @@ -70,8 +71,8 @@ function FlowRendererComponent({ isControlledViewport, }: FlowRendererProps) { const { nodesSelectionActive, userSelectionActive } = useStore(selector); - const selectionKeyPressed = useKeyPress(selectionKeyCode); - const panActivationKeyPressed = useKeyPress(panActivationKeyCode); + const selectionKeyPressed = useKeyPress(selectionKeyCode, { target: win }); + const panActivationKeyPressed = useKeyPress(panActivationKeyCode, { target: win }); const panOnDrag = panActivationKeyPressed || _panOnDrag; const panOnScroll = panActivationKeyPressed || _panOnScroll; @@ -113,6 +114,7 @@ function FlowRendererComponent({ panOnDrag={panOnDrag} isSelecting={!!isSelecting} selectionMode={selectionMode} + selectionKeyPressed={selectionKeyPressed} > {children} {nodesSelectionActive && ( diff --git a/packages/react/src/container/GraphView/index.tsx b/packages/react/src/container/GraphView/index.tsx index a2663851..d74a0fdb 100644 --- a/packages/react/src/container/GraphView/index.tsx +++ b/packages/react/src/container/GraphView/index.tsx @@ -32,7 +32,6 @@ export type GraphViewProps > & { rfId: string; @@ -85,19 +84,18 @@ function GraphViewComponent diff --git a/packages/react/src/container/NodeRenderer/index.tsx b/packages/react/src/container/NodeRenderer/index.tsx index 39b20323..2b178ae5 100644 --- a/packages/react/src/container/NodeRenderer/index.tsx +++ b/packages/react/src/container/NodeRenderer/index.tsx @@ -22,7 +22,6 @@ export type NodeRendererProps = Pick< | 'noDragClassName' | 'rfId' | 'disableKeyboardA11y' - | 'nodeOrigin' | 'nodeExtent' | 'nodeTypes' >; @@ -72,7 +71,6 @@ function NodeRendererComponent(props: NodeRendererProps ({ export function Pane({ isSelecting, + selectionKeyPressed, selectionMode = SelectionMode.Full, panOnDrag, onSelectionStart, @@ -72,6 +79,10 @@ export function Pane({ const edgeIdLookup = useRef>>(new Map()); const { userSelectionActive, elementsSelectable, dragging } = useStore(selector, shallow); + const hasActiveSelection = elementsSelectable && (isSelecting || userSelectionActive); + + // Used to prevent click events when the user lets go of the selectionKey during a selection + const selectionInProgress = useRef(false); const resetUserSelection = () => { store.setState({ userSelectionActive: false, userSelectionRect: null }); @@ -81,6 +92,12 @@ export function Pane({ }; const onClick = (event: ReactMouseEvent) => { + // We prevent click events when the user let go of the selectionKey during a selection + if (selectionInProgress.current) { + selectionInProgress.current = false; + return; + } + onPaneClick?.(event); store.getState().resetSelectedElements(); store.setState({ nodesSelectionActive: false }); @@ -97,9 +114,10 @@ export function Pane({ const onWheel = onPaneScroll ? (event: React.WheelEvent) => onPaneScroll(event) : undefined; - const onMouseDown = (event: ReactMouseEvent): void => { + const onPointerDown = (event: ReactPointerEvent): void => { const { resetSelectedElements, domNode, edgeLookup } = store.getState(); containerBounds.current = domNode?.getBoundingClientRect(); + container.current?.setPointerCapture(event.pointerId); if ( !elementsSelectable || @@ -136,14 +154,16 @@ export function Pane({ onSelectionStart?.(event); }; - const onMouseMove = (event: ReactMouseEvent): void => { - const { userSelectionRect, edgeLookup, transform, nodeOrigin, nodeLookup, triggerNodeChanges, triggerEdgeChanges } = + const onPointerMove = (event: ReactPointerEvent): void => { + const { userSelectionRect, edgeLookup, transform, nodeLookup, triggerNodeChanges, triggerEdgeChanges } = store.getState(); - if (!isSelecting || !containerBounds.current || !userSelectionRect) { + if (!containerBounds.current || !userSelectionRect) { return; } + selectionInProgress.current = true; + const { x: mouseX, y: mouseY } = getEventPosition(event.nativeEvent, containerBounds.current); const { startX, startY } = userSelectionRect; @@ -161,8 +181,7 @@ export function Pane({ nextUserSelectRect, transform, selectionMode === SelectionMode.Partial, - true, - nodeOrigin + true ); const selectedEdgeIds = new Set(); @@ -199,10 +218,11 @@ export function Pane({ }); }; - const onMouseUp = (event: ReactMouseEvent) => { + const onPointerUp = (event: ReactPointerEvent) => { if (event.button !== 0) { return; } + container.current?.releasePointerCapture(event.pointerId); const { userSelectionRect } = store.getState(); // We only want to trigger click functions when in selection mode if // the user did not move the mouse. @@ -214,30 +234,25 @@ export function Pane({ resetUserSelection(); onSelectionEnd?.(event); - }; - const onMouseLeave = (event: ReactMouseEvent) => { - if (userSelectionActive) { - store.setState({ nodesSelectionActive: prevSelectedNodesCount.current > 0 }); - onSelectionEnd?.(event); + // If the user kept holding the selectionKey during the selection, + // we need to reset the selectionInProgress, so the next click event is not prevented + if (selectionKeyPressed) { + selectionInProgress.current = false; } - - resetUserSelection(); }; - const hasActiveSelection = elementsSelectable && (isSelecting || userSelectionActive); - return (
diff --git a/packages/react/src/container/ReactFlow/Wrapper.tsx b/packages/react/src/container/ReactFlow/Wrapper.tsx index af9ad4a4..ce1b5a74 100644 --- a/packages/react/src/container/ReactFlow/Wrapper.tsx +++ b/packages/react/src/container/ReactFlow/Wrapper.tsx @@ -3,6 +3,7 @@ import { useContext, type ReactNode } from 'react'; import StoreContext from '../../contexts/StoreContext'; import { ReactFlowProvider } from '../../components/ReactFlowProvider'; import type { Node, Edge } from '../../types'; +import { NodeOrigin } from '@xyflow/system'; export function Wrapper({ children, @@ -13,6 +14,7 @@ export function Wrapper({ width, height, fitView, + nodeOrigin, }: { children: ReactNode; nodes?: Node[]; @@ -22,6 +24,7 @@ export function Wrapper({ width?: number; height?: number; fitView?: boolean; + nodeOrigin?: NodeOrigin; }) { const isWrapped = useContext(StoreContext); @@ -40,6 +43,7 @@ export function Wrapper({ initialWidth={width} initialHeight={height} fitView={fitView} + nodeOrigin={nodeOrigin} > {children} diff --git a/packages/react/src/container/ReactFlow/index.tsx b/packages/react/src/container/ReactFlow/index.tsx index eb6e3a1d..c301d9d4 100644 --- a/packages/react/src/container/ReactFlow/index.tsx +++ b/packages/react/src/container/ReactFlow/index.tsx @@ -81,7 +81,7 @@ function ReactFlow( nodesFocusable, nodeOrigin = defaultNodeOrigin, edgesFocusable, - edgesUpdatable, + edgesReconnectable, elementsSelectable = true, defaultViewport = initViewport, minZoom = 0.5, @@ -104,15 +104,15 @@ function ReactFlow( onPaneScroll, onPaneContextMenu, children, - onEdgeUpdate, + onReconnect, + onReconnectStart, + onReconnectEnd, onEdgeContextMenu, onEdgeDoubleClick, onEdgeMouseEnter, onEdgeMouseMove, onEdgeMouseLeave, - onEdgeUpdateStart, - onEdgeUpdateEnd, - edgeUpdaterRadius = 10, + reconnectRadius = 10, onNodesChange, onEdgesChange, noDragClassName = 'nodrag', @@ -157,7 +157,7 @@ function ReactFlow( data-testid="rf__wrapper" id={id} > - + onInit={onInit} onNodeClick={onNodeClick} @@ -202,22 +202,21 @@ function ReactFlow( onSelectionContextMenu={onSelectionContextMenu} onSelectionStart={onSelectionStart} onSelectionEnd={onSelectionEnd} - onEdgeUpdate={onEdgeUpdate} + onReconnect={onReconnect} + onReconnectStart={onReconnectStart} + onReconnectEnd={onReconnectEnd} onEdgeContextMenu={onEdgeContextMenu} onEdgeDoubleClick={onEdgeDoubleClick} onEdgeMouseEnter={onEdgeMouseEnter} onEdgeMouseMove={onEdgeMouseMove} onEdgeMouseLeave={onEdgeMouseLeave} - onEdgeUpdateStart={onEdgeUpdateStart} - onEdgeUpdateEnd={onEdgeUpdateEnd} - edgeUpdaterRadius={edgeUpdaterRadius} + reconnectRadius={reconnectRadius} defaultMarkerColor={defaultMarkerColor} noDragClassName={noDragClassName} noWheelClassName={noWheelClassName} noPanClassName={noPanClassName} rfId={rfId} disableKeyboardA11y={disableKeyboardA11y} - nodeOrigin={nodeOrigin} nodeExtent={nodeExtent} viewport={viewport} onViewportChange={onViewportChange} @@ -236,7 +235,7 @@ function ReactFlow( nodesConnectable={nodesConnectable} nodesFocusable={nodesFocusable} edgesFocusable={edgesFocusable} - edgesUpdatable={edgesUpdatable} + edgesReconnectable={edgesReconnectable} elementsSelectable={elementsSelectable} elevateNodesOnSelect={elevateNodesOnSelect} elevateEdgesOnSelect={elevateEdgesOnSelect} diff --git a/packages/react/src/hooks/useConnection.ts b/packages/react/src/hooks/useConnection.ts index 9b458e87..8a4b377b 100644 --- a/packages/react/src/hooks/useConnection.ts +++ b/packages/react/src/hooks/useConnection.ts @@ -2,33 +2,19 @@ import { shallow } from 'zustand/shallow'; import { useStore } from './useStore'; import type { ReactFlowStore } from '../types/store'; +import { ConnectionState, pointToRendererPoint } from '@xyflow/system'; -const selector = (s: ReactFlowStore) => ({ - startHandle: s.connectionStartHandle, - endHandle: s.connectionEndHandle, - status: s.connectionStatus, - position: s.connectionStartHandle ? s.connectionPosition : null, -}); - -type UseConnectionResult = { - /** The start handle where the user interaction started or null */ - startHandle: ReactFlowStore['connectionStartHandle']; - /** The target handle that's inside the connection radius or null */ - endHandle: ReactFlowStore['connectionEndHandle']; - /** The current connection status 'valid', 'invalid' or null*/ - status: ReactFlowStore['connectionStatus']; - /** The current connection position or null */ - position: ReactFlowStore['connectionPosition'] | null; +const selector = (s: ReactFlowStore): ConnectionState => { + return s.connection.inProgress + ? { ...s.connection, to: pointToRendererPoint(s.connection.to, s.transform) } + : { ...s.connection }; }; - /** - * Hook for accessing the ongoing connection. + * Hook for accessing the connection state. * * @public - * @returns ongoing connection + * @returns ConnectionState */ -export function useConnection(): UseConnectionResult { - const ongoingConnection = useStore(selector, shallow); - - return ongoingConnection; +export function useConnection(): ConnectionState { + return useStore(selector, shallow); } diff --git a/packages/react/src/hooks/useGlobalKeyHandler.ts b/packages/react/src/hooks/useGlobalKeyHandler.ts index ffc7a2aa..3e350a6a 100644 --- a/packages/react/src/hooks/useGlobalKeyHandler.ts +++ b/packages/react/src/hooks/useGlobalKeyHandler.ts @@ -9,6 +9,7 @@ import { Edge, Node } from '../types'; const selected = (item: Node | Edge) => item.selected; const deleteKeyOptions: UseKeyPressOptions = { actInsideInputWithModifier: false }; +const win = typeof window !== 'undefined' ? window : undefined; /** * Hook for handling global key events. @@ -26,7 +27,7 @@ export function useGlobalKeyHandler({ const { deleteElements } = useReactFlow(); const deleteKeyPressed = useKeyPress(deleteKeyCode, deleteKeyOptions); - const multiSelectionKeyPressed = useKeyPress(multiSelectionKeyCode); + const multiSelectionKeyPressed = useKeyPress(multiSelectionKeyCode, { target: win }); useEffect(() => { if (deleteKeyPressed) { diff --git a/packages/react/src/hooks/useReactFlow.ts b/packages/react/src/hooks/useReactFlow.ts index 4f1404a4..faf9d31f 100644 --- a/packages/react/src/hooks/useReactFlow.ts +++ b/packages/react/src/hooks/useReactFlow.ts @@ -13,7 +13,7 @@ import { import useViewportHelper from './useViewportHelper'; import { useStore, useStoreApi } from './useStore'; import { useBatchContext } from '../components/BatchProvider'; -import { elementToRemoveChange, isNode } from '../utils'; +import { elementToRemoveChange, isEdge, isNode } from '../utils'; import type { ReactFlowInstance, Node, Edge, InternalNode, ReactFlowState, GeneralHelpers } from '../types'; const selector = (s: ReactFlowState) => !!s.panZoom; @@ -41,12 +41,16 @@ export function useReactFlow['setEdges'] = (payload) => { + batchContext.edgeQueue.push(payload as EdgeType[]); + }; + const getNodeRect = (node: NodeType | { id: string }): Rect | null => { const { nodeLookup, nodeOrigin } = store.getState(); const nodeToUse = isNode(node) ? node : nodeLookup.get(node.id)!; const position = nodeToUse.parentId - ? evaluateAbsolutePosition(nodeToUse.position, nodeToUse.parentId, nodeLookup, nodeOrigin) + ? evaluateAbsolutePosition(nodeToUse.position, nodeToUse.measured, nodeToUse.parentId, nodeLookup, nodeOrigin) : nodeToUse.position; const nodeWithPosition = { @@ -77,6 +81,23 @@ export function useReactFlow['updateEdge'] = ( + id, + edgeUpdate, + options = { replace: false } + ) => { + setEdges((prevEdges) => + prevEdges.map((edge) => { + if (edge.id === id) { + const nextEdge = typeof edgeUpdate === 'function' ? edgeUpdate(edge as EdgeType) : edgeUpdate; + return options.replace && isEdge(nextEdge) ? (nextEdge as EdgeType) : { ...edge, ...nextEdge }; + } + + return edge; + }) + ); + }; + return { getNodes: () => store.getState().nodes.map((n) => ({ ...n })) as NodeType[], getNode: (id) => getInternalNode(id)?.internals.userNode as NodeType, @@ -87,9 +108,7 @@ export function useReactFlow store.getState().edgeLookup.get(id) as EdgeType, setNodes, - setEdges: (payload) => { - batchContext.edgeQueue.push(payload as EdgeType[]); - }, + setEdges, addNodes: (payload) => { const newNodes = Array.isArray(payload) ? payload : [payload]; batchContext.nodeQueue.push((nodes) => [...nodes, ...newNodes]); @@ -200,6 +219,17 @@ export function useReactFlow { + updateEdge( + id, + (edge) => { + const nextData = typeof dataUpdate === 'function' ? dataUpdate(edge) : dataUpdate; + return options.replace ? { ...edge, data: nextData } : { ...edge, data: { ...edge.data, ...nextData } }; + }, + options + ); + }, }; }, []); diff --git a/packages/react/src/hooks/useViewportHelper.ts b/packages/react/src/hooks/useViewportHelper.ts index ef4f6f38..245fafd7 100644 --- a/packages/react/src/hooks/useViewportHelper.ts +++ b/packages/react/src/hooks/useViewportHelper.ts @@ -45,22 +45,23 @@ const useViewportHelper = (): ViewportHelperFunctions => { return { x, y, zoom }; }, fitView: (options) => { - const { nodeLookup, width, height, nodeOrigin, minZoom, maxZoom, panZoom } = store.getState(); + const { nodeLookup, width, height, minZoom, maxZoom, panZoom } = store.getState(); - return panZoom - ? fitView( - { - nodeLookup, - width, - height, - nodeOrigin, - minZoom, - maxZoom, - panZoom, - }, - options - ) - : false; + if (!panZoom) { + return false; + } + + return fitView( + { + nodeLookup, + width, + height, + minZoom, + maxZoom, + panZoom, + }, + options + ); }, setCenter: (x, y, options) => { const { width, height, maxZoom, panZoom } = store.getState(); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 85b582d6..e15d9ca2 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -49,7 +49,6 @@ export { type OnMoveStart, type OnMoveEnd, type Connection, - type ConnectionStatus, ConnectionMode, type OnConnectStartParams, type OnConnectStart, @@ -116,6 +115,6 @@ export { getIncomers, getOutgoers, addEdge, - updateEdge, + reconnectEdge, getConnectedEdges, } from '@xyflow/system'; diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 29f99999..57beb3f9 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -12,6 +12,8 @@ import { EdgeSelectionChange, NodeSelectionChange, ParentExpandChild, + initialConnection, + NodeOrigin, } from '@xyflow/system'; import { applyEdgeChanges, applyNodeChanges, createSelectionChange, getSelectionChanges } from '../utils/changes'; @@ -26,6 +28,7 @@ const createStore = ({ width, height, fitView, + nodeOrigin, }: { nodes?: Node[]; edges?: Edge[]; @@ -34,10 +37,11 @@ const createStore = ({ width?: number; height?: number; fitView?: boolean; + nodeOrigin?: NodeOrigin; }) => createWithEqualityFn( (set, get) => ({ - ...getInitialState({ nodes, edges, width, height, fitView, defaultNodes, defaultEdges }), + ...getInitialState({ nodes, edges, width, height, fitView, nodeOrigin, defaultNodes, defaultEdges }), setNodes: (nodes: Node[]) => { const { nodeLookup, parentLookup, nodeOrigin, elevateNodesOnSelect } = get(); // setNodes() is called exclusively in response to user actions: @@ -98,7 +102,7 @@ const createStore = ({ return; } - updateAbsolutePositions(nodeLookup, { nodeOrigin }); + updateAbsolutePositions(nodeLookup, parentLookup, { nodeOrigin }); // we call fitView once initially after all dimensions are set let nextFitViewDone = fitViewDone; @@ -155,8 +159,8 @@ const createStore = ({ } if (parentExpandChildren.length > 0) { - const { nodeLookup, parentLookup } = get(); - const parentExpandChanges = handleExpandParent(parentExpandChildren, nodeLookup, parentLookup); + const { nodeLookup, parentLookup, nodeOrigin } = get(); + const parentExpandChanges = handleExpandParent(parentExpandChildren, nodeLookup, parentLookup, nodeOrigin); changes.push(...parentExpandChanges); } @@ -288,7 +292,7 @@ const createStore = ({ return panBySystem({ delta, panZoom, transform, translateExtent, width, height }); }, fitView: (options?: FitViewOptions): boolean => { - const { panZoom, width, height, minZoom, maxZoom, nodeOrigin, nodeLookup } = get(); + const { panZoom, width, height, minZoom, maxZoom, nodeLookup } = get(); if (!panZoom) { return false; @@ -302,26 +306,17 @@ const createStore = ({ panZoom, minZoom, maxZoom, - nodeOrigin, }, options ); }, - cancelConnection: () => + cancelConnection: () => { set({ - connectionStatus: null, - connectionStartHandle: null, - connectionEndHandle: null, - }), - updateConnection: (params) => { - const { connectionPosition } = get(); - - const currentConnection = { - ...params, - connectionPosition: params.connectionPosition ?? connectionPosition, - }; - - set(currentConnection); + connection: { ...initialConnection }, + }); + }, + updateConnection: (connection) => { + set({ connection }); }, reset: () => set({ ...getInitialState() }), diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 57844405..7d2789a2 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -7,6 +7,8 @@ import { updateConnectionLookup, devWarn, getInternalNodesBounds, + NodeOrigin, + initialConnection, } from '@xyflow/system'; import type { Edge, InternalNode, Node, ReactFlowStore } from '../types'; @@ -19,6 +21,7 @@ const getInitialState = ({ width, height, fitView, + nodeOrigin, }: { nodes?: Node[]; edges?: Edge[]; @@ -27,6 +30,7 @@ const getInitialState = ({ width?: number; height?: number; fitView?: boolean; + nodeOrigin?: NodeOrigin; } = {}): ReactFlowStore => { const nodeLookup = new Map(); const parentLookup = new Map(); @@ -34,21 +38,21 @@ const getInitialState = ({ const edgeLookup = new Map(); const storeEdges = defaultEdges ?? edges ?? []; const storeNodes = defaultNodes ?? nodes ?? []; + const storeNodeOrigin = nodeOrigin ?? [0, 0]; updateConnectionLookup(connectionLookup, edgeLookup, storeEdges); adoptUserNodes(storeNodes, nodeLookup, parentLookup, { - nodeOrigin: [0, 0], + nodeOrigin: storeNodeOrigin, elevateNodesOnSelect: false, }); let transform: Transform = [0, 0, 1]; if (fitView && width && height) { - // @todo users nodeOrigin should be used here const bounds = getInternalNodesBounds(nodeLookup, { - nodeOrigin: [0, 0], filter: (node) => !!((node.width || node.initialWidth) && (node.height || node.initialHeight)), }); + const { x, y, zoom } = getViewportForBounds(bounds, width, height, 0.5, 2, 0.1); transform = [x, y, zoom]; } @@ -76,13 +80,11 @@ const getInitialState = ({ 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], + nodeOrigin: storeNodeOrigin, nodeDragThreshold: 1, snapGrid: [15, 15], @@ -92,7 +94,7 @@ const getInitialState = ({ nodesConnectable: true, nodesFocusable: true, edgesFocusable: true, - edgesUpdatable: true, + edgesReconnectable: true, elementsSelectable: true, elevateNodesOnSelect: true, elevateEdgesOnSelect: false, @@ -103,8 +105,7 @@ const getInitialState = ({ multiSelectionActive: false, - connectionStartHandle: null, - connectionEndHandle: null, + connection: { ...initialConnection }, connectionClickStartHandle: null, connectOnClick: true, diff --git a/packages/react/src/types/component-props.ts b/packages/react/src/types/component-props.ts index 6deca270..5dd6d57e 100644 --- a/packages/react/src/types/component-props.ts +++ b/packages/react/src/types/component-props.ts @@ -29,7 +29,7 @@ import type { Node, Edge, ConnectionLineComponent, - OnEdgeUpdateFunc, + OnReconnect, OnInit, DefaultEdgeOptions, FitViewOptions, @@ -129,9 +129,9 @@ export interface ReactFlowProps; /** This event handler is called when a user double clicks on an edge */ onEdgeDoubleClick?: EdgeMouseHandler; - onEdgeUpdateStart?: (event: ReactMouseEvent, edge: EdgeType, handleType: HandleType) => void; - onEdgeUpdateEnd?: (event: MouseEvent | TouchEvent, edge: EdgeType, handleType: HandleType) => void; - onEdgeUpdate?: OnEdgeUpdateFunc; + onReconnect?: OnReconnect; + onReconnectStart?: (event: ReactMouseEvent, edge: EdgeType, handleType: HandleType) => void; + onReconnectEnd?: (event: MouseEvent | TouchEvent, edge: EdgeType, handleType: HandleType) => void; /** This event handler is called when a Node is updated * @example // Use NodesState hook to create edges and get onNodesChange handler * import ReactFlow, { useNodesState } from '@xyflow/react'; @@ -330,7 +330,7 @@ export interface ReactFlowProps = Record pathOptions?: StepPathOptions; }; -export type BuiltInEdge = SmoothStepEdge | BezierEdge | StepEdge; +type StraightEdge = Record> = Edge; + +export type BuiltInEdge = SmoothStepEdge | BezierEdge | StepEdge | StraightEdge; export type EdgeMouseHandler = (event: ReactMouseEvent, edge: EdgeType) => void; export type EdgeWrapperProps = { id: string; edgesFocusable: boolean; - edgesUpdatable: boolean; + edgesReconnectable: boolean; elementsSelectable: boolean; noPanClassName: string; onClick?: EdgeMouseHandler; onDoubleClick?: EdgeMouseHandler; - onEdgeUpdate?: OnEdgeUpdateFunc; + onReconnect?: OnReconnect; onContextMenu?: EdgeMouseHandler; onMouseEnter?: EdgeMouseHandler; onMouseMove?: EdgeMouseHandler; onMouseLeave?: EdgeMouseHandler; - edgeUpdaterRadius?: number; - onEdgeUpdateStart?: (event: ReactMouseEvent, edge: EdgeType, handleType: HandleType) => void; - onEdgeUpdateEnd?: (event: MouseEvent | TouchEvent, edge: EdgeType, handleType: HandleType) => void; + reconnectRadius?: number; + onReconnectStart?: (event: ReactMouseEvent, edge: EdgeType, handleType: HandleType) => void; + onReconnectEnd?: (event: MouseEvent | TouchEvent, edge: EdgeType, handleType: HandleType) => void; rfId?: string; edgeTypes?: EdgeTypes; onError?: OnError; @@ -189,20 +188,22 @@ export type StraightEdgeProps = Omit = (oldEdge: EdgeType, newConnection: Connection) => void; +export type OnReconnect = (oldEdge: EdgeType, newConnection: Connection) => void; export type ConnectionLineComponentProps = { connectionLineStyle?: CSSProperties; connectionLineType: ConnectionLineType; - fromNode?: Node; - fromHandle?: HandleElement; + fromNode: Node; + fromHandle: Handle; fromX: number; fromY: number; toX: number; toY: number; fromPosition: Position; toPosition: Position; - connectionStatus: ConnectionStatus | null; + connectionStatus: 'valid' | 'invalid' | null; + toNode: Node | null; + toHandle: Handle | null; }; export type ConnectionLineComponent = ComponentType; diff --git a/packages/react/src/types/instance.ts b/packages/react/src/types/instance.ts index bd7c1d1b..5f4aa3b3 100644 --- a/packages/react/src/types/instance.ts +++ b/packages/react/src/types/instance.ts @@ -143,6 +143,36 @@ export type GeneralHelpers | ((node: NodeType) => Partial), options?: { replace: boolean } ) => void; + /** + * Updates an edge. + * + * @param id - id of the edge to update + * @param edgeUpdate - the edge update as an object or a function that receives the current edge and returns the edge update + * @param options.replace - if true, the edge is replaced with the edge update, otherwise the changes get merged + * + * @example + * updateEdge('edge-1', (edge) => ({ label: 'A new label' })); + */ + updateEdge: ( + id: string, + edgeUpdate: Partial | ((edge: EdgeType) => Partial), + options?: { replace: boolean } + ) => void; + /** + * Updates the data attribute of a edge. + * + * @param id - id of the edge to update + * @param dataUpdate - the data update as an object or a function that receives the current data and returns the data update + * @param options.replace - if true, the data is replaced with the data update, otherwise the changes get merged + * + * @example + * updateEdgeData('edge-1', { label: 'A new label' }); + */ + updateEdgeData: ( + id: string, + dataUpdate: Partial | ((edge: EdgeType) => Partial), + options?: { replace: boolean } + ) => void; }; export type ReactFlowInstance = GeneralHelpers< diff --git a/packages/react/src/types/nodes.ts b/packages/react/src/types/nodes.ts index a01a894a..71e03036 100644 --- a/packages/react/src/types/nodes.ts +++ b/packages/react/src/types/nodes.ts @@ -1,12 +1,5 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; -import type { - CoordinateExtent, - NodeBase, - NodeOrigin, - OnError, - NodeProps as NodePropsBase, - InternalNodeBase, -} from '@xyflow/system'; +import type { CoordinateExtent, NodeBase, OnError, NodeProps as NodePropsBase, InternalNodeBase } from '@xyflow/system'; import { NodeTypes } from './general'; @@ -59,7 +52,6 @@ export type NodeWrapperProps = { disableKeyboardA11y: boolean; nodeTypes?: NodeTypes; nodeExtent?: CoordinateExtent; - nodeOrigin: NodeOrigin; onError?: OnError; }; diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index bd5f5720..7ce5fabf 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -1,6 +1,6 @@ import { ConnectionMode, - type ConnectionStatus, + type ConnectionState, type CoordinateExtent, type InternalNodeUpdate, type UpdateNodePositions, @@ -10,9 +10,8 @@ import { type OnViewportChange, type SelectionRect, type SnapGrid, - type ConnectingHandle, + type Handle, type Transform, - type XYPosition, type PanZoomInstance, type PanBy, type OnConnectStart, @@ -25,8 +24,9 @@ import { type EdgeLookup, type ConnectionLookup, type NodeLookup, - NodeChange, - EdgeChange, + type NodeChange, + type EdgeChange, + type ParentLookup, } from '@xyflow/system'; import type { @@ -54,7 +54,7 @@ export type ReactFlowStore>; - parentLookup: Map[]>; + parentLookup: ParentLookup>; edges: Edge[]; edgeLookup: EdgeLookup; connectionLookup: ConnectionLookup; @@ -78,9 +78,9 @@ export type ReactFlowStore & Required>) | null; snapToGrid: boolean; snapGrid: SnapGrid; @@ -89,7 +89,7 @@ export type ReactFlowStore; onNodeDrag?: OnNodeDrag; onNodeDragStop?: OnNodeDrag; diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 85011689..0abd4924 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -2,5 +2,8 @@ "extends": "@xyflow/tsconfig/react.json", "display": "@xyflow/react", "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "outDir": "dist" + } } diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 292c7e22..81de9ea1 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,29 @@ # @xyflow/svelte +## 0.1.8 + +- `useConnection` returns `ConnectionState` with `toNode` and `toHandle` +- node origin is part of position absolute +- refactor connection handling + +## 0.1.7 + +- revise selection usability (capture while dragging out of the flow) +- only prevent shift scrolling when selection is actually in progress +- use correct end handle position when drawing a connection lines +- determine correct end positions for connection lines + +## 0.1.6 + +- fix node origin bug + +## 0.1.5 + +- prevent zooming on mobile if zoomOnPinch is false +- add straight edge to path built-in-types +- abort drag when multiple touches are detected +- fix merge_styles error + ## 0.1.4 - add `selectable`, `deletable` and `draggable` to node and edge props @@ -7,7 +31,7 @@ - add `on:edgemouseenter` and `on:edgemouseleave` event handler - fix deselection of edges - remove pointer events from panel when user selection is active -- fix viewport initialization with user viewport +- fix viewport initialization with user viewport - fix parent node lookup in `evaluateAbsolutePosition`- thanks @lcsfort ## 0.1.3 @@ -31,16 +55,16 @@ This is a bigger update for Svelte Flow to keep up with the latest changes we ma - rename `node.computed` to `node.measured` - this attribute only includes `width` and `height` and no `positionAbsolute` anymore. For this we added the helpers `getInternalNode` and `useInternalNode` - rename `node.parentNode` to `node.parentId` -### More updates: +### More updates: - add `isValidConnection` for `` component - add `fitViewOptions` for `` component - add `getInternalNode` to `useSvelteFlow` - add `useInternalNode` hook -- don't reset nodes and edges when svelte flow unmounts - thanks @darabos +- don't reset nodes and edges when svelte flow unmounts - thanks @darabos - fix node event types - thanks @RedPhoenixQ - make handleId and isTarget reactive - thanks @darabos -- fix MiniMap interaction for touch devices +- fix MiniMap interaction for touch devices - fix pane: pinch zoom on windows - fix nodes: return user node in node event handlers diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 4c4c378f..86bd4daa 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@xyflow/svelte", - "version": "0.1.4", + "version": "0.1.7", "description": "Svelte Flow - A highly customizable Svelte library for building node-based editors, workflow systems, diagrams and more.", "keywords": [ "svelte", @@ -18,8 +18,8 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "css": "postcss src/styles/{base,style}.css --config ./../../tooling/postcss-config --dir dist", "css-watch": "pnpm css --watch", - "lint": "prettier --plugin-search-dir . --check . && eslint .", - "format": "prettier --plugin-search-dir . --write .", + "lint": "prettier --check . && eslint ./src", + "format": "prettier --write .", "typecheck": "pnpm check" }, "type": "module", diff --git a/packages/svelte/src/lib/actions/drag/index.ts b/packages/svelte/src/lib/actions/drag/index.ts index 1405ccd9..f5649a29 100644 --- a/packages/svelte/src/lib/actions/drag/index.ts +++ b/packages/svelte/src/lib/actions/drag/index.ts @@ -34,7 +34,7 @@ export default function drag(domNode: Element, params: UseDragParams) { nodeExtent: get(store.nodeExtent), snapGrid: snapGrid ? snapGrid : [0, 0], snapToGrid: !!snapGrid, - nodeOrigin: [0, 0], + nodeOrigin: get(store.nodeOrigin), multiSelectionActive: get(store.multiselectionKeyPressed), domNode: get(store.domNode), transform: [vp.x, vp.y, vp.zoom], diff --git a/packages/svelte/src/lib/components/ConnectionLine/ConnectionLine.svelte b/packages/svelte/src/lib/components/ConnectionLine/ConnectionLine.svelte index 904a45d3..71e3f2c9 100644 --- a/packages/svelte/src/lib/components/ConnectionLine/ConnectionLine.svelte +++ b/packages/svelte/src/lib/components/ConnectionLine/ConnectionLine.svelte @@ -2,21 +2,59 @@ import cc from 'classcat'; import { useStore } from '$lib/store'; + import { + ConnectionLineType, + getBezierPath, + getConnectionStatus, + getSmoothStepPath, + getStraightPath + } from '@xyflow/system'; export let containerStyle: string = ''; export let style: string = ''; export let isCustomComponent: boolean = false; - const { width, height, connection } = useStore(); + const { width, height, connection, connectionLineType } = useStore(); + + let path: string | null = null; + + $: if ($connection.inProgress && !isCustomComponent) { + const { from, to, fromPosition, toPosition } = $connection; + const pathParams = { + sourceX: from.x, + sourceY: from.y, + sourcePosition: fromPosition, + targetX: to.x, + targetY: to.y, + targetPosition: toPosition + }; + + switch ($connectionLineType) { + case ConnectionLineType.Bezier: + [path] = getBezierPath(pathParams); + break; + case ConnectionLineType.Step: + [path] = getSmoothStepPath({ + ...pathParams, + borderRadius: 0 + }); + break; + case ConnectionLineType.SmoothStep: + [path] = getSmoothStepPath(pathParams); + break; + default: + [path] = getStraightPath(pathParams); + } + } -{#if $connection.path} +{#if $connection.inProgress} - + {#if !isCustomComponent} - + {/if} diff --git a/packages/svelte/src/lib/components/Handle/Handle.svelte b/packages/svelte/src/lib/components/Handle/Handle.svelte index 472ca2c2..90482aa1 100644 --- a/packages/svelte/src/lib/components/Handle/Handle.svelte +++ b/packages/svelte/src/lib/components/Handle/Handle.svelte @@ -103,7 +103,7 @@ $onConnectEndAction?.(event); }, getTransform: () => [$viewport.x, $viewport.y, $viewport.zoom], - getConnectionStartHandle: () => $connection.startHandle + getFromHandle: () => $connection.fromHandle }); } } @@ -128,21 +128,20 @@ prevConnections = connections ?? new Map(); } - $: connectionInProcess = !!$connection.startHandle; + $: connectionInProcess = !!$connection.fromHandle; $: connectingFrom = - $connection.startHandle?.nodeId === nodeId && - $connection.startHandle?.type === type && - $connection.startHandle?.handleId === handleId; + $connection.fromHandle?.nodeId === nodeId && + $connection.fromHandle?.type === type && + $connection.fromHandle?.id === handleId; $: connectingTo = - $connection.endHandle?.nodeId === nodeId && - $connection.endHandle?.type === type && - $connection.endHandle?.handleId === handleId; + $connection.toHandle?.nodeId === nodeId && + $connection.toHandle?.type === type && + $connection.toHandle?.id === handleId; $: isPossibleEndHandle = $connectionMode === ConnectionMode.Strict - ? $connection.startHandle?.type !== type - : nodeId !== $connection.startHandle?.nodeId || - handleId !== $connection.startHandle?.handleId; - $: valid = connectingTo && $connection.status === 'valid'; + ? $connection.fromHandle?.type !== type + : nodeId !== $connection.fromHandle?.nodeId || handleId !== $connection.fromHandle?.id; + $: valid = connectingTo && $connection.isValid;