diff --git a/.changeset/config.json b/.changeset/config.json index 5980f269..68528c0e 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,11 +1,11 @@ { - "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", - "changelog": ["@changesets/changelog-github", { "repo": "wbkd/react-flow" }], + "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", + "changelog": ["@changesets/changelog-github", { "repo": "xyflow/xyflow" }], "commit": false, "fixed": [], "linked": [], - "access": "public", + "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": ["react-examples", "svelte-examples", "astro-examples", "playwright"] } 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/.github/actions/ci-checks/action.yml b/.github/actions/ci-checks/action.yml new file mode 100644 index 00000000..d4a87a86 --- /dev/null +++ b/.github/actions/ci-checks/action.yml @@ -0,0 +1,11 @@ +name: 'CI checks' + +runs: + using: 'composite' + steps: + - name: Build + run: pnpm build + shell: bash + - name: Typecheck + run: pnpm typecheck + shell: bash diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml new file mode 100644 index 00000000..5ac07699 --- /dev/null +++ b/.github/actions/ci-setup/action.yml @@ -0,0 +1,14 @@ +name: 'CI setup' + +runs: + using: 'composite' + steps: + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + - name: Install dependencies + run: pnpm install + shell: bash diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..2e94ce0e --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,25 @@ +name: Playwright Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + playwright: + name: 'Playwright Tests' + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.45.0-jammy + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - uses: ./.github/actions/ci-setup + - uses: ./.github/actions/ci-checks + - name: Run your tests + run: CI=true pnpm test:react + env: + HOME: /root diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..1ce9d920 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release + +on: + workflow_dispatch: +# push: +# branches: +# - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - uses: ./.github/actions/ci-setup + - uses: ./.github/actions/ci-checks + + - name: Create release PR or publish to npm + id: changesets + uses: xyflow/changeset-action@v1 + with: + publish: pnpm release + title: Release packages + commit: 'chore(packages): bump' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.npmrc b/.npmrc index 8d10578a..acb88cc7 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,2 @@ -registry="https://registry.npmjs.com/" legacy-peer-deps=true strict-peer-dependencies=false \ No newline at end of file diff --git a/LICENSE b/LICENSE index a8e03fd7..8540abfb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2023 webkid GmbH +Copyright (c) 2019-2024 webkid GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 722fa001..b398b065 100644 --- a/README.md +++ b/README.md @@ -17,22 +17,22 @@ Powerful open source libraries for building node-based UIs with React or Svelte. ## The xyflow mono repo The xyflow repository is the home of four packages: -* React Flow v11 `reactflow` [v11 branch](https://github.com/xyflow/xyflow/tree/v11) -* React Flow v12 (not published yet) [packages/react](./packages/react) +* React Flow 11 `reactflow` [v11 branch](https://github.com/xyflow/xyflow/tree/v11) +* React Flow 12 (beta) `@xyflow/react` [packages/react](./packages/react) * Svelte Flow `@xyflow/svelte` [packages/svelte](./packages/svelte) * Shared helper library `@xyflow/system` [packages/system](./packages/system) **We just moved repositories** from the @wbkd org to this one. React Flow v11 will remain on the [v11 branch](https://github.com/xyflow/xyflow/tree/v11). When we have a stable v12, the package name of React Flow will change from `reactflow` to `@xyflow/react`. -## Commercial Usage +## Commercial usage **Are you using React Flow or Svelte Flow for a personal project?** Great! No sponsorship needed, you can support us by reporting any bugs you find, sending us screenshots of your projects, and starring us on Github 🌟 **Are you using React Flow or Svelte Flow at your organization and making money from it?** Awesome! We rely on your support to keep our libraries developed and maintained under an MIT License, just how we like it. For React Flow you can do that on the [React Flow Pro website](https://reactflow.dev/pro) and for both of our libraries you can do it through [Github Sponsors](https://github.com/sponsors/xyflow). -## Getting Started +## Getting started -The best way to get started is to check out the [React Flow](https://reactflow.dev/learn) or [Svelte Flow](https://svelteflow.dev/learn) learn section. However if you want to get a sneak peek of how to install the and use the libraries you can see it here: +The best way to get started is to check out the [React Flow](https://reactflow.dev/learn) or [Svelte Flow](https://svelteflow.dev/learn) learn section. However if you want to get a sneak peek of how to install and use the libraries you can see it here:
React Flow basic usage @@ -151,6 +151,15 @@ npm install @xyflow/svelte ```
+## Releases + +For releasing packages we are using [changesets](https://github.com/changesets/changesets) in combination with the [changeset Github action](https://github.com/changesets/action). The rough idea is: + +1. create PRs for new features, updates and fixes (with a changeset if relevant for changelog) +2. merge into main +3. changset creates a PR that bumps all packages based on the changesets +4. merge changeset PR if you want to release to Github and npm + ## The xyflow team React Flow and Svelte Flow are maintained by the team behind [xyflow](https://xyflow.com). If you need help or want to talk to us about a collaboration, reach out through our [contact form](https://xyflow.com/contact) or by joining our [Discord Server](https://discord.gg/Bqt6xrs). diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..df8f1670 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,13 @@ +# Examples for Testing & Development +These examples are used for feature development and E2E testing. + +## Overview +SvelteKit App with Svelte Flow examples at [`examples/svelte`](./svelte) + +Vite App with React Flow examples at [`examples/react`](./react) + +Astro App used for SSR testing at [`examples/astro-xyflow`](./astro-xyflow) + +## E2E + +For furhter documentation of E2E tests have a look at [`/tests/playwright`](/tests/playwright). \ No newline at end of file diff --git a/examples/astro-xyflow/package.json b/examples/astro-xyflow/package.json index c5e498db..94d6e199 100644 --- a/examples/astro-xyflow/package.json +++ b/examples/astro-xyflow/package.json @@ -1,5 +1,5 @@ { - "name": "astro", + "name": "astro-examples", "type": "module", "private": true, "version": "0.0.1", diff --git a/examples/astro-xyflow/src/components/ReactFlowExample/CustomNode.tsx b/examples/astro-xyflow/src/components/ReactFlowExample/CustomNode.tsx index dc371239..6dc30709 100644 --- a/examples/astro-xyflow/src/components/ReactFlowExample/CustomNode.tsx +++ b/examples/astro-xyflow/src/components/ReactFlowExample/CustomNode.tsx @@ -7,7 +7,7 @@ const sourceHandleStyleB: CSSProperties = { left: 'auto', }; -const CustomNode: FC = ({ data, xPos, yPos }) => { +const CustomNode: FC = ({ data, positionAbsoluteX, positionAbsoluteY }) => { return ( <> @@ -18,7 +18,7 @@ const CustomNode: FC = ({ data, xPos, yPos }) => {
Position:{' '} - {xPos.toFixed(2)},{yPos.toFixed(2)} + {positionAbsoluteX.toFixed(2)},{positionAbsoluteY.toFixed(2)}
diff --git a/examples/astro-xyflow/src/components/ReactFlowExample/index.tsx b/examples/astro-xyflow/src/components/ReactFlowExample/index.tsx index ae5a5c87..863458fb 100644 --- a/examples/astro-xyflow/src/components/ReactFlowExample/index.tsx +++ b/examples/astro-xyflow/src/components/ReactFlowExample/index.tsx @@ -27,7 +27,7 @@ const initialNodes: Node[] = [ type: 'input', data: { label: 'Node 1' }, position: { x: 250, y: 5 }, - size: nodeSize, + ...nodeSize, handles: [ { type: 'source', @@ -41,23 +41,19 @@ const initialNodes: Node[] = [ id: '2', data: { label: 'Node 2' }, position: { x: 100, y: 100 }, - size: nodeSize, + ...nodeSize, handles: [ { type: 'source', position: Position.Bottom, x: nodeSize.width * 0.5, y: nodeSize.height, - width: 1, - height: 1, }, { type: 'target', position: Position.Top, x: nodeSize.width * 0.5, y: 0, - width: 1, - height: 1, }, ], }, @@ -65,23 +61,19 @@ const initialNodes: Node[] = [ id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 }, - size: nodeSize, + ...nodeSize, handles: [ { type: 'source', position: Position.Bottom, x: nodeSize.width * 0.5, y: nodeSize.height, - width: 1, - height: 1, }, { type: 'target', position: Position.Top, x: nodeSize.width * 0.5, y: 0, - width: 1, - height: 1, }, ], }, @@ -89,6 +81,8 @@ const initialNodes: Node[] = [ id: '4', data: { label: 'Node 4' }, position: { x: 400, y: 200 }, + width: 200, + height: 50, type: 'custom', }, ]; diff --git a/examples/astro-xyflow/src/components/ReactFlowInitialExample/CustomNode.tsx b/examples/astro-xyflow/src/components/ReactFlowInitialExample/CustomNode.tsx new file mode 100644 index 00000000..c7fae3d8 --- /dev/null +++ b/examples/astro-xyflow/src/components/ReactFlowInitialExample/CustomNode.tsx @@ -0,0 +1,21 @@ +import { memo, useState } from 'react'; +import { Handle, Position } from '@xyflow/react'; + +function CustomNode() { + const [text, setText] = useState('this is a pretty long text'); + + return ( + <> + +
+
+ setText(e.target.value)} /> +
text: {text}
+
+
+ + + ); +} + +export default memo(CustomNode); diff --git a/examples/astro-xyflow/src/components/ReactFlowInitialExample/index.tsx b/examples/astro-xyflow/src/components/ReactFlowInitialExample/index.tsx new file mode 100644 index 00000000..b3f99a6b --- /dev/null +++ b/examples/astro-xyflow/src/components/ReactFlowInitialExample/index.tsx @@ -0,0 +1,69 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + addEdge, + useEdgesState, + useNodesState, + Background, + Controls, + type Connection, + type Edge, + type Node, +} from '@xyflow/react'; + +import CustomNode from './CustomNode'; + +import '@xyflow/react/dist/style.css'; + +const initialNodes: Node[] = [ + { + id: '1', + data: {}, + position: { x: 0, y: 0 }, + initialWidth: 200, + initialHeight: 50, + type: 'custom', + }, + { + id: '2', + data: {}, + position: { x: 0, y: 200 }, + width: 200, + initialHeight: 50, + type: 'custom', + }, +]; + +const initialEdges: Edge[] = [{ id: 'e1-2', source: '1', target: '2' }]; + +const nodeTypes = { + custom: CustomNode, +}; + +function Flow() { + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]); + + return ( +
+ + + + +
+ ); +} + +export default Flow; diff --git a/examples/astro-xyflow/src/components/SvelteFlowExample/index.svelte b/examples/astro-xyflow/src/components/SvelteFlowExample/index.svelte index cfa8a449..73196d53 100644 --- a/examples/astro-xyflow/src/components/SvelteFlowExample/index.svelte +++ b/examples/astro-xyflow/src/components/SvelteFlowExample/index.svelte @@ -11,10 +11,8 @@ data: { label: 'Node 0' }, sourcePosition: Position.Right, targetPosition: Position.Left, - size: { - width: 100, - height: 40, - }, + width: 100, + height: 40, handles: [ { type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'target', x: 0, y: 20, position: Position.Left }, @@ -26,10 +24,8 @@ data: { label: 'A' }, sourcePosition: Position.Right, targetPosition: Position.Left, - size: { - width: 100, - height: 40, - }, + width: 100, + height: 40, handles: [ { type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'target', x: 0, y: 20, position: Position.Left }, @@ -41,10 +37,8 @@ data: { label: 'B' }, sourcePosition: Position.Right, targetPosition: Position.Left, - size: { - width: 100, - height: 40, - }, + width: 100, + height: 40, handles: [ { type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'target', x: 0, y: 20, position: Position.Left }, @@ -56,10 +50,8 @@ data: { label: 'C' }, sourcePosition: Position.Right, targetPosition: Position.Left, - size: { - width: 100, - height: 40, - }, + width: 100, + height: 40, handles: [ { type: 'source', x: 100, y: 20, position: Position.Right }, { type: 'target', x: 0, y: 20, position: Position.Left }, diff --git a/examples/astro-xyflow/src/components/SvelteFlowInitialExample/CustomNode.svelte b/examples/astro-xyflow/src/components/SvelteFlowInitialExample/CustomNode.svelte new file mode 100644 index 00000000..5bef87c9 --- /dev/null +++ b/examples/astro-xyflow/src/components/SvelteFlowInitialExample/CustomNode.svelte @@ -0,0 +1,25 @@ + + + +
+
+ text: {text} +
+ +
+ + + diff --git a/examples/astro-xyflow/src/components/SvelteFlowInitialExample/index.svelte b/examples/astro-xyflow/src/components/SvelteFlowInitialExample/index.svelte new file mode 100644 index 00000000..8fbe4905 --- /dev/null +++ b/examples/astro-xyflow/src/components/SvelteFlowInitialExample/index.svelte @@ -0,0 +1,39 @@ + + +
+ + + + +
diff --git a/examples/astro-xyflow/src/pages/index.astro b/examples/astro-xyflow/src/pages/index.astro index be9d1885..d7b14558 100644 --- a/examples/astro-xyflow/src/pages/index.astro +++ b/examples/astro-xyflow/src/pages/index.astro @@ -1,6 +1,8 @@ --- -import ReactFlowApp from '../components/ReactFlowExample' -import SvelteFlowApp from '../components/SvelteFlowExample/index.svelte' +import ReactFlowApp from '../components/ReactFlowExample'; +import ReactFlowInitialApp from '../components/ReactFlowInitialExample'; +import SvelteFlowApp from '../components/SvelteFlowExample/index.svelte'; +import SvelteFlowInitialApp from '../components/SvelteFlowInitialExample/index.svelte'; --- @@ -18,10 +20,24 @@ import SvelteFlowApp from '../components/SvelteFlowExample/index.svelte' +

React Flow

+

no client hydration

+ + +

client hydration on load (client:load)

+ + +

client hydration on load (client:load) and initialWidth / initialHeight

+ +

Svelte Flow

-

React Flow

- +

client hydration on load (client:load)

+ + +

client hydration on load (client:load) and initialWidth / initialHeight

+ + diff --git a/examples/react/README.md b/examples/react/README.md index 5a9a880c..c1207ba7 100644 --- a/examples/react/README.md +++ b/examples/react/README.md @@ -1,6 +1,6 @@ # React Flow examples -This Vite app is used internally to test the library. +This Vite app is used internally to develop and test the library. ## Start local dev server @@ -8,5 +8,15 @@ This Vite app is used internally to test the library. pnpm dev ``` +## Adding new example + +Development of the library is done against `src/examples`. Feel free to add new implementations for features that you develop. + +1. Create a new folder & flow at `src/examples/` +2. Register the new route in `src/App/routes.ts` + +## Adding new E2E implementation +E2E testing is done against the flows implemented in `src/generic-tests`. Adding a new configuration file automatically adds a new route under [`http://localhost:3000/tests/generic/$foldername/$filename`](http://localhost:5173/tests/generic/nodes/general). For further documentation visit [`xyflow/tests/playwright`](/tests/playwright). + diff --git a/examples/react/cypress.config.ts b/examples/react/cypress.config.ts index b46eaa76..027bd690 100644 --- a/examples/react/cypress.config.ts +++ b/examples/react/cypress.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'cypress'; export default defineConfig({ e2e: { - baseUrl: 'http://localhost:3000', + baseUrl: 'http://localhost:3000/examples', viewportWidth: 1280, viewportHeight: 720, video: false, diff --git a/examples/react/cypress/components/hooks/useEdges.cy.tsx b/examples/react/cypress/components/hooks/useEdges.cy.tsx index 34b0ae3c..c1123d4d 100644 --- a/examples/react/cypress/components/hooks/useEdges.cy.tsx +++ b/examples/react/cypress/components/hooks/useEdges.cy.tsx @@ -13,7 +13,6 @@ describe('useEdges.cy.tsx', () => { ); - cy.get('@onChangeSpy').should('have.been.calledWith', []); cy.get('@onChangeSpy').should('have.been.calledWith', initialEdges); }); diff --git a/examples/react/cypress/components/hooks/useNodes.cy.tsx b/examples/react/cypress/components/hooks/useNodes.cy.tsx index 4f813e00..340b4b10 100644 --- a/examples/react/cypress/components/hooks/useNodes.cy.tsx +++ b/examples/react/cypress/components/hooks/useNodes.cy.tsx @@ -15,8 +15,9 @@ const initialNodes: Node[] = nodes.map((n) => ({ const expectedNodes: Node[] = initialNodes.map((n) => ({ ...n, - positionAbsolute: n.position, - ...nodeDimensions, + measured: { + ...nodeDimensions, + }, })); describe('useNodes.cy.tsx', () => { @@ -29,7 +30,6 @@ describe('useNodes.cy.tsx', () => { ); - cy.get('@onChangeSpy').should('have.been.calledWith', []); cy.get('@onChangeSpy').should('have.been.calledWith', expectedNodes); }); diff --git a/examples/react/cypress/components/reactflow/basic-props.cy.tsx b/examples/react/cypress/components/reactflow/basic-props.cy.tsx index c8ac818a..a0738eff 100644 --- a/examples/react/cypress/components/reactflow/basic-props.cy.tsx +++ b/examples/react/cypress/components/reactflow/basic-props.cy.tsx @@ -6,7 +6,7 @@ import * as simpleflow from '../../fixtures/simpleflow'; describe(': Basic Props', () => { describe('uses defaultNodes and defaultEdges', () => { beforeEach(() => { - cy.mount(); + cy.mount(); }); it('mounts nodes and edges', () => { diff --git a/examples/react/cypress/components/reactflow/on-nodes-change.cy.tsx b/examples/react/cypress/components/reactflow/on-nodes-change.cy.tsx new file mode 100644 index 00000000..bceb4cbd --- /dev/null +++ b/examples/react/cypress/components/reactflow/on-nodes-change.cy.tsx @@ -0,0 +1,138 @@ +import { useCallback, useState } from 'react'; +import { OnNodesChange, Panel, ReactFlow, ReactFlowProps, applyNodeChanges, useReactFlow, Node } from '@xyflow/react'; + +const nodes = [ + { + id: '1', + data: { label: 'Node 1' }, + position: { x: 0, y: 0 }, + style: { width: 100, height: 40 }, + }, + { + id: '2', + data: { label: 'Node 2' }, + position: { x: 200, y: 200 }, + style: { width: 80, height: 50 }, + }, +]; + +describe(': onNodesChange', () => { + beforeEach(() => { + const onNodesChange = cy.spy().as('onNodesChange'); + cy.mount().wait(500); + }); + + it('receive initial dimension change', () => { + const expectedChanges = nodes.map((n) => ({ + type: 'dimensions', + id: n.id, + dimensions: n.style, + })); + + cy.get('@onNodesChange').should('have.callCount', 1); + cy.get('@onNodesChange').should('have.calledWith', expectedChanges); + }); + + it('receive replace and dimension change', () => { + const expectedDimensionChanges = [ + { + type: 'dimensions', + id: '1', + dimensions: { width: 200, height: 100 }, + }, + ]; + + const expectedReplaceChanges = [ + { + type: 'replace', + id: '1', + item: { + ...nodes[0], + measured: { width: 200, height: 100 }, + style: { width: 200, height: 100 }, + }, + }, + ]; + + cy.get('[data-id=update-btn]').click(); + + cy.get('@onNodesChange').should('have.callCount', 3); + cy.get('@onNodesChange').then((s) => { + // dimension change (already checked above) + // const firstCall = s.getCall(0); + // replace change + // @ts-ignore + const secondChangeCallArgs = s.getCall(1).args[0]; + // dimension change + // @ts-ignore + const thirdChangeCallArgs = s.getCall(2).args[0]; + + expect(secondChangeCallArgs).to.be.deep.eq(expectedReplaceChanges); + expect(thirdChangeCallArgs).to.be.deep.eq(expectedDimensionChanges); + }); + }); + + it('receive select and unselect change', () => { + const expectedSelectChanges = [{ id: '1', type: 'select', selected: true }]; + const expectedUnselectChanges = [{ id: '1', type: 'select', selected: false }]; + + cy.get('.react-flow__node').first().click(); + cy.get('@onNodesChange').should('have.calledWith', expectedSelectChanges); + + cy.get('.react-flow__pane').first().click({ force: true }); + cy.get('@onNodesChange').should('have.calledWith', expectedUnselectChanges); + }); + + it('receive position change', () => { + const endPosition = { x: 200, y: 25 }; + const expectedChanges = [ + { id: '1', type: 'position', dragging: false, position: endPosition, positionAbsolute: endPosition }, + ]; + + cy.drag('.react-flow__node:first', endPosition).then(() => { + cy.get('@onNodesChange').should('have.calledWith', expectedChanges); + }); + }); + + it('receive remove change', () => { + const expectedChanges = [{ id: '1', type: 'remove' }]; + + cy.get('.react-flow__node').first().click(); + cy.realPress('Backspace'); + + cy.get('@onNodesChange').should('have.calledWith', expectedChanges); + }); +}); + +// test specific helpers + +function UpdateButton() { + const { updateNode } = useReactFlow(); + const updateNodeDimensions = () => { + updateNode('1', { style: { width: 200, height: 100 } }); + }; + + return ( + + + + ); +} + +type CompProps = ReactFlowProps & { onNodesChange: (changes: any) => void }; + +function Comp(props: CompProps) { + const [nodes, setNodes] = useState(props.nodes || []); + const onNodesChange: OnNodesChange = useCallback((changes) => { + props.onNodesChange(changes); + setNodes((nds) => applyNodeChanges(changes, nds)); + }, []); + + return ( + + + + ); +} diff --git a/examples/react/cypress/components/utils/apply-changes.cy.ts b/examples/react/cypress/components/utils/apply-changes.cy.ts new file mode 100644 index 00000000..0a75db7b --- /dev/null +++ b/examples/react/cypress/components/utils/apply-changes.cy.ts @@ -0,0 +1,178 @@ +import { + applyNodeChanges, + applyEdgeChanges, + type Node, + type Edge, + NodeChange, + EdgeChange, + XYPosition, +} from '@xyflow/react'; + +const nodes: Node[] = [ + { + id: '1', + type: 'input', + data: { label: 'Node 1' }, + position: { x: 250, y: 5 }, + }, + { id: '2', data: { label: 'Node 2' }, position: { x: 100, y: 100 } }, + { id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 } }, + { id: '4', data: { label: 'Node 4' }, position: { x: 400, y: 200 } }, +]; + +const edges: Edge[] = [ + { id: 'e1-2', source: '1', target: '2', animated: true }, + { id: 'e1-3', source: '1', target: '3' }, + { id: 'e2-3', source: '2', target: '3' }, +]; + +describe('applyChanges Testing', () => { + it('adds a node/edge', () => { + const currentNodes: Node[] = []; + const nodeChanges: NodeChange[] = [{ type: 'add', item: nodes[0] }]; + const nextNodes = applyNodeChanges(nodeChanges, currentNodes); + expect(nextNodes.length).to.be.equal(nodeChanges.length); + + const currentEdges: Edge[] = []; + const edgeChanges: EdgeChange[] = [{ type: 'add', item: edges[0] }]; + const nextEdges = applyEdgeChanges(edgeChanges, currentEdges); + + expect(nextEdges.length).to.be.equal(1); + }); + + it('adds multiple nodes/edges', () => { + const currentNodes: Node[] = []; + const nodeChanges: NodeChange[] = [ + { type: 'add', item: nodes[0] }, + { type: 'add', item: nodes[1] }, + ]; + const nextNodes = applyNodeChanges(nodeChanges, currentNodes); + expect(nextNodes.length).to.be.equal(nodeChanges.length); + + const currentEdges: Edge[] = []; + const edgeChanges: EdgeChange[] = [ + { type: 'add', item: edges[0] }, + { type: 'add', item: edges[1] }, + ]; + const nextEdges = applyEdgeChanges(edgeChanges, currentEdges); + expect(nextEdges.length).to.be.equal(edgeChanges.length); + }); + + it('removes a node/edge', () => { + const nodesLength = nodes.length; + const nodeChanges: NodeChange[] = [{ type: 'remove', id: '1' }]; + const nextNodes = applyNodeChanges(nodeChanges, nodes); + expect(nextNodes.length).to.be.equal(nodes.length - nodeChanges.length); + expect(nodes.length).to.be.equal(nodesLength); + + const edgesLength = edges.length; + const edgeChanges: EdgeChange[] = [{ type: 'remove', id: 'e1-2' }]; + const nextEdges = applyEdgeChanges(edgeChanges, edges); + expect(nextEdges.length).to.be.equal(edges.length - 1); + expect(edges.length).to.be.equal(edgesLength); + }); + + it('removes multiple node/edge', () => { + const nodesLength = nodes.length; + const nodeChanges: NodeChange[] = [ + { type: 'remove', id: '1' }, + { type: 'remove', id: '2' }, + ]; + const nextNodes = applyNodeChanges(nodeChanges, nodes); + expect(nextNodes.length).to.be.equal(nodes.length - nodeChanges.length); + expect(nodes.length).to.be.equal(nodesLength); + + const edgesLength = edges.length; + const edgeChanges: EdgeChange[] = [ + { type: 'remove', id: 'e1-2' }, + { type: 'remove', id: 'e1-3' }, + ]; + const nextEdges = applyEdgeChanges(edgeChanges, edges); + expect(nextEdges.length).to.be.equal(edges.length - edgeChanges.length); + expect(edges.length).to.be.equal(edgesLength); + }); + + it('selects a node/edge', () => { + const nodeChanges: NodeChange[] = [{ type: 'select', id: '1', selected: true }]; + const nextNodes = applyNodeChanges(nodeChanges, nodes); + expect(nextNodes[0].selected).to.be.true; + + const edgeChanges: EdgeChange[] = [{ type: 'select', id: 'e1-2', selected: true }]; + const nextEdges = applyEdgeChanges(edgeChanges, edges); + expect(nextEdges[0].selected).to.be.true; + }); + + it('deselects a node/edge', () => { + const nodeChanges: NodeChange[] = [{ type: 'select', id: '1', selected: false }]; + const nextNodes = applyNodeChanges( + nodeChanges, + nodes.map((n) => ({ ...n, selected: true })) + ); + expect(nextNodes[0].selected).to.be.false; + expect(nextNodes[1].selected).to.be.true; + + const edgeChanges: EdgeChange[] = [{ type: 'select', id: 'e1-2', selected: false }]; + const nextEdges = applyEdgeChanges( + edgeChanges, + edges.map((e) => ({ ...e, selected: true })) + ); + expect(nextEdges[0].selected).to.be.false; + expect(nextEdges[1].selected).to.be.true; + }); + + it('updates node position', () => { + const newPosition: XYPosition = { x: 100, y: 100 }; + const nodeChanges: NodeChange[] = [{ type: 'position', id: '1', position: newPosition }]; + const nextNodes = applyNodeChanges(nodeChanges, nodes); + + expect(nextNodes[0].position).to.be.deep.equal(newPosition); + }); + + it('updates node dimensions', () => { + const newWidth = 200; + const newHeight = 200; + const nodeChanges: NodeChange[] = [ + { type: 'dimensions', id: '1', dimensions: { width: newWidth, height: newHeight } }, + ]; + const nextNodes = applyNodeChanges(nodeChanges, nodes); + + expect(nodes[0].measured).to.be.undefined; + expect(nextNodes[0].measured).to.be.deep.equal({ width: newWidth, height: newHeight }); + expect(nextNodes[0].width).to.be.undefined; + expect(nextNodes[0].height).to.be.undefined; + }); + + it('updates node position and dimensions', () => { + const newPosition: XYPosition = { x: 100, y: 100 }; + const newWidth = 200; + const newHeight = 200; + const nodeChanges: NodeChange[] = [ + { type: 'position', id: '1', position: newPosition }, + { type: 'dimensions', id: '1', dimensions: { width: newWidth, height: newHeight } }, + ]; + const nextNodes = applyNodeChanges(nodeChanges, nodes); + + expect(nextNodes[0].position).to.be.deep.equal(newPosition); + expect(nextNodes[0].measured).to.be.deep.equal({ width: newWidth, height: newHeight }); + }); + + it('replaces nodes/edges', () => { + const nodesLength = nodes.length; + const nodeChanges: NodeChange[] = [{ type: 'replace', id: nodes[0].id, item: nodes[1] }]; + const nextNodes = applyNodeChanges(nodeChanges, nodes); + + expect(nodes.length).length.to.be.equal(nodesLength); + expect(nextNodes.length).to.be.equal(nodesLength); + expect(nextNodes[0]).to.be.deep.equal(nodes[1]); + + const edgesLength = edges.length; + const edgeChange: EdgeChange[] = [{ type: 'replace', id: edges[0].id, item: edges[1] }]; + const nextEdges = applyEdgeChanges(edgeChange, edges); + + expect(edges.length).length.to.be.equal(edgesLength); + expect(nextEdges.length).to.be.equal(edgesLength); + expect(nextEdges[0]).to.be.deep.equal(edges[1]); + }); +}); + +export {}; diff --git a/examples/react/cypress/e2e/basic.cy.ts b/examples/react/cypress/e2e/basic.cy.ts index 8328a307..10c0ce62 100644 --- a/examples/react/cypress/e2e/basic.cy.ts +++ b/examples/react/cypress/e2e/basic.cy.ts @@ -49,21 +49,16 @@ describe('Basic Flow Rendering', { testIsolation: false }, () => { it('selects one node with a selection', () => { cy.get('body') - .type('{shift}', { release: false }) - .wait(50) + .type('{Shift}', { release: false }) .get('.react-flow__pane') .trigger('mousedown', 1, 10, { button: 0, force: true }) - .trigger('mousemove', 1000, 450, { button: 0 }) - .wait(50) - .trigger('mouseup', 1000, 450, { button: 0, force: true }); + .trigger('mousemove', 1000, 200, { button: 0 }) + .trigger('mouseup', 1000, 200, { button: 0 }); - cy.wait(200); - - cy.get('.react-flow__node').eq(1).should('have.class', 'selected'); + cy.get('.react-flow__node').eq(0).should('have.class', 'selected'); cy.get('.react-flow__node').eq(3).should('have.not.class', 'selected'); cy.get('.react-flow__nodesselection-rect'); - cy.get('body').type('{shift}', { release: true, force: true }); }); @@ -97,19 +92,19 @@ describe('Basic Flow Rendering', { testIsolation: false }, () => { it('drags a node', () => { const styleBeforeDrag = Cypress.$('.react-flow__node:first').css('transform'); - cy.drag('.react-flow__node:first', { x: 500, y: 25 }).then(($el: any) => { + cy.drag('.react-flow__node:first', { x: 10, y: 10 }).then(($el: any) => { const styleAfterDrag = $el.css('transform'); expect(styleBeforeDrag).to.not.equal(styleAfterDrag); }); }); - // @TODO: why does this fail since react18? - // it('removes a node', () => { - // cy.get('.react-flow__node').contains('Node 1').click().should('have.class', 'selected'); - // cy.get('html').type('{backspace}').wait(100); - // cy.get('.react-flow__node').should('have.length', 3); - // cy.get('.react-flow__edge').should('have.length', 1); - // }); + it('removes a node', () => { + cy.get('.react-flow__node').contains('Node 1').click().should('have.class', 'selected'); + cy.get('html').realPress('Backspace'); + + cy.get('.react-flow__node').should('have.length', 3); + cy.get('.react-flow__edge').should('have.length', 0); + }); it('connects nodes', () => { cy.get('.react-flow__node') @@ -125,16 +120,15 @@ describe('Basic Flow Rendering', { testIsolation: false }, () => { .trigger('mouseup', { force: true, button: 0 }); cy.get('.react-flow__edge').as('edge'); - cy.get('@edge').should('have.length', 3); + cy.get('@edge').should('have.length', 1); }); - // @TODO: why does this fail since react18? - // it('removes an edge', () => { - // cy.get('.react-flow__edge:first').click(); - // cy.get('body').type('{backspace}'); + it('removes an edge', () => { + cy.get('.react-flow__edge:first').click(); + cy.get('html').realPress('Backspace'); - // cy.get('.react-flow__edge').should('have.length', 1); - // }); + cy.get('.react-flow__edge').should('have.length', 0); + }); it('drags the pane', () => { const styleBeforeDrag = Cypress.$('.react-flow__viewport').css('transform'); diff --git a/examples/react/cypress/support/ControlledFlow.tsx b/examples/react/cypress/support/ControlledFlow.tsx index eaa560bf..8474b81e 100644 --- a/examples/react/cypress/support/ControlledFlow.tsx +++ b/examples/react/cypress/support/ControlledFlow.tsx @@ -59,7 +59,7 @@ function ControlledFlow({ handlers.onConnect = onConnect; } - return ; + return ; } export default ControlledFlow; diff --git a/examples/react/cypress/support/commands.ts b/examples/react/cypress/support/commands.ts index e138e4cf..3a350dc5 100644 --- a/examples/react/cypress/support/commands.ts +++ b/examples/react/cypress/support/commands.ts @@ -9,7 +9,7 @@ Cypress.Commands.add('drag', (selector, { x, y }) => const nextY: number = centerY + y; return elementToDrag - .trigger('mousedown', { view: window }) + .trigger('mousedown', { view: window, force: true }) .trigger('mousemove', nextX, nextY, { force: true }) .wait(50) .trigger('mouseup', { view: window, force: true }); diff --git a/examples/react/cypress/support/e2e.ts b/examples/react/cypress/support/e2e.ts index d153dd3e..29be4a69 100644 --- a/examples/react/cypress/support/e2e.ts +++ b/examples/react/cypress/support/e2e.ts @@ -15,6 +15,7 @@ // Import commands.js using ES2015 syntax: import './commands'; +import 'cypress-real-events/support'; const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/; diff --git a/examples/react/index.html b/examples/react/index.html index f1cba741..828516b9 100644 --- a/examples/react/index.html +++ b/examples/react/index.html @@ -2,7 +2,7 @@ - + React Flow Examples diff --git a/examples/react/package.json b/examples/react/package.json index 73d91382..6b7362f5 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -1,5 +1,5 @@ { - "name": "@xyflow/react-examples", + "name": "react-examples", "private": true, "version": "0.0.0", "type": "module", @@ -14,26 +14,29 @@ "test-e2e": "start-server-and-test 'pnpm serve' http-get://localhost:3000 'pnpm test-e2e-cypress'" }, "dependencies": { + "@reduxjs/toolkit": "^2.2.3", "@xyflow/react": "workspace:*", - "classcat": "^5.0.3", + "classcat": "^5.0.4", "dagre": "^0.8.5", "localforage": "^1.10.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.3.0", - "zustand": "^4.4.1" + "react-redux": "^9.1.1", + "react-router-dom": "^6.18.0", + "redux": "^5.0.1", + "zustand": "^4.4.6" }, "devDependencies": { "@cypress/skip-test": "^2.6.1", - "@types/dagre": "^0.7.48", - "@types/react": "^18.0.17", - "@types/react-dom": "^18.0.6", - "@vitejs/plugin-react": "4.0.4", - "@vitejs/plugin-react-swc": "^3.3.2", - "cypress": "12.17.3", - "cypress-real-events": "1.10.0", - "start-server-and-test": "^1.14.0", - "typescript": "5.1.3", - "vite": "4.4.9" + "@types/dagre": "^0.7.52", + "@types/react": "^18.2.36", + "@types/react-dom": "^18.2.14", + "@vitejs/plugin-react": "4.1.1", + "@vitejs/plugin-react-swc": "^3.4.1", + "cypress": "13.6.6", + "cypress-real-events": "1.12.0", + "start-server-and-test": "^2.0.2", + "typescript": "5.2.2", + "vite": "4.5.0" } } diff --git a/examples/react/public/favicon.ico b/examples/react/public/favicon.ico new file mode 100644 index 00000000..9ef0fbfd Binary files /dev/null and b/examples/react/public/favicon.ico differ diff --git a/examples/react/public/vite.svg b/examples/react/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/examples/react/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/react/src/App/header.tsx b/examples/react/src/App/header.tsx new file mode 100644 index 00000000..37e57910 --- /dev/null +++ b/examples/react/src/App/header.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; + +import routes from './routes'; + +export default function Header() { + const location = useLocation(); + const navigate = useNavigate(); + const [currentPath, setCurrentPath] = useState(location.pathname); + + useEffect(() => { + const name = routes.find((route) => route.path === currentPath)?.name; + document.title = `React Flow Examples${name ? ' - ' + name : ''}`; + navigate(currentPath); + }, [currentPath]); + + return ( + <> +
+ + React Flow Dev + + +
+ + + ); +} diff --git a/examples/react/src/App/index.tsx b/examples/react/src/App/index.tsx index b6bc5be7..98b70cf8 100644 --- a/examples/react/src/App/index.tsx +++ b/examples/react/src/App/index.tsx @@ -1,326 +1,21 @@ -import { useEffect, useState } from 'react'; -import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import routes from './routes'; +import GenericTestRoutes from '../generic-tests'; import Basic from '../examples/Basic'; -import Backgrounds from '../examples/Backgrounds'; -import ControlledUncontrolled from '../examples/ControlledUncontrolled'; -import ControlledViewport from '../examples/ControlledViewport'; -import CustomConnectionLine from '../examples/CustomConnectionLine'; -import CustomMiniMapNode from '../examples/CustomMiniMapNode'; -import CustomNode from '../examples/CustomNode'; -import DefaultNodes from '../examples/DefaultNodes'; -import DragHandle from '../examples/DragHandle'; -import DragNDrop from '../examples/DragNDrop'; -import EasyConnect from '../examples/EasyConnect'; -import Edges from '../examples/Edges'; -import EdgeRenderer from '../examples/EdgeRenderer'; -import EdgeTypes from '../examples/EdgeTypes'; -import Empty from '../examples/Empty'; -import Figma from '../examples/Figma'; -import FloatingEdges from '../examples/FloatingEdges'; -import Hidden from '../examples/Hidden'; -import Interaction from '../examples/Interaction'; -import Intersection from '../examples/Intersection'; -import Layouting from '../examples/Layouting'; -import MultiFlows from '../examples/MultiFlows'; -import NestedNodes from '../examples/NestedNodes'; -import NodeResizer from '../examples/NodeResizer'; -import NodeTypeChange from '../examples/NodeTypeChange'; -import NodeTypesObjectChange from '../examples/NodeTypesObjectChange'; -import Overview from '../examples/Overview'; -import Provider from '../examples/Provider'; -import SaveRestore from '../examples/SaveRestore'; -import Stress from '../examples/Stress'; -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 UpdateNode from '../examples/UpdateNode'; -import UseUpdateNodeInternals from '../examples/UseUpdateNodeInternals'; -import UseReactFlow from '../examples/UseReactFlow'; -import Validation from '../examples/Validation'; -import UseKeyPress from '../examples/UseKeyPress'; -import EdgeRouting from '../examples/EdgeRouting'; -import CancelConnection from '../examples/CancelConnection'; -import InteractiveMinimap from '../examples/InteractiveMinimap'; -import UseOnSelectionChange from '../examples/UseOnSelectionChange'; -import NodeToolbar from '../examples/NodeToolbar'; -import useNodesInitialized from '../examples/UseNodesInit'; - -interface IRoute { - name: string; - path: string; - component: React.ComponentType; -} - -const routes: IRoute[] = [ - { - name: 'Basic', - path: '/', - component: Basic, - }, - { - name: 'Backgrounds', - path: '/backgrounds', - component: Backgrounds, - }, - { - name: 'Cancel Connection', - path: '/cancel-connection', - component: CancelConnection, - }, - { - name: 'Controlled/Uncontrolled', - path: '/controlled-uncontrolled', - component: ControlledUncontrolled, - }, - { - name: 'Controlled Viewport', - path: '/controlled-viewport', - component: ControlledViewport, - }, - { - name: 'Custom Connection Line', - path: '/custom-connectionline', - component: CustomConnectionLine, - }, - { - name: 'Custom Minimap Node', - path: '/custom-minimap-node', - component: CustomMiniMapNode, - }, - { - name: 'Custom Node', - path: '/custom-node', - component: CustomNode, - }, - { - name: 'Default Nodes', - path: '/default-nodes', - component: DefaultNodes, - }, - { - name: 'Drag Handle', - path: '/draghandle', - component: DragHandle, - }, - { - name: 'Drag and Drop', - path: '/dragndrop', - component: DragNDrop, - }, - { - name: 'EasyConnect', - path: '/easy-connect', - component: EasyConnect, - }, - { - name: 'Edges', - path: '/edges', - component: Edges, - }, - { - name: 'Edge Renderer', - path: '/edge-renderer', - component: EdgeRenderer, - }, - { - name: 'Edge Types', - path: '/edge-types', - component: EdgeTypes, - }, - { - name: 'Edge Routing', - path: '/edge-routing', - component: EdgeRouting, - }, - { - name: 'Empty', - path: '/empty', - component: Empty, - }, - { - name: 'Figma', - path: '/figma', - component: Figma, - }, - { - name: 'Floating Edges', - path: '/floating-edges', - component: FloatingEdges, - }, - { - name: 'Hidden', - path: '/hidden', - component: Hidden, - }, - { - name: 'Interaction', - path: '/interaction', - component: Interaction, - }, - { - name: 'Intersection', - path: '/intersection', - component: Intersection, - }, - { - name: 'Interactive Minimap', - path: '/interactive-minimap', - component: InteractiveMinimap, - }, - { - name: 'Layouting', - path: '/layouting', - component: Layouting, - }, - { - name: 'Multi Flows', - path: '/multiflows', - component: MultiFlows, - }, - { - name: 'Nested Nodes', - path: '/nested-nodes', - component: NestedNodes, - }, - { - name: 'Node Type Change', - path: '/nodetype-change', - component: NodeTypeChange, - }, - { - name: 'nodeTypes Object Change', - path: '/nodetypesobject-change', - component: NodeTypesObjectChange, - }, - { - name: 'NodeToolbar', - path: '/node-toolbar', - component: NodeToolbar, - }, - { - name: 'NodeResizer', - path: '/node-resizer', - component: NodeResizer, - }, - { - name: 'Overview', - path: '/overview', - component: Overview, - }, - { - name: 'Provider', - path: '/provider', - component: Provider, - }, - { - name: 'Save/Restore', - path: '/save-restore', - component: SaveRestore, - }, - { - name: 'Stress', - path: '/stress', - component: Stress, - }, - { - name: 'Subflow', - path: '/subflow', - component: Subflow, - }, - { - name: 'Switch Flow', - path: '/switch', - component: SwitchFlow, - }, - { - name: 'Touch Device', - path: '/touch-device', - component: TouchDevice, - }, - { - name: 'Undirectional', - path: '/undirectional', - component: Undirectional, - }, - { - name: 'Updatable Edge', - path: '/updatable-edge', - component: UpdatableEdge, - }, - { - name: 'Update Node', - path: '/update-node', - component: UpdateNode, - }, - { - name: 'useNodesInitialized', - path: '/use-nodes-initialized', - component: useNodesInitialized, - }, - { - name: 'useOnSelectionChange', - path: '/use-on-selection-change', - component: UseOnSelectionChange, - }, - { - name: 'useReactFlow', - path: '/usereactflow', - component: UseReactFlow, - }, - { - name: 'useUpdateNodeInternals', - path: '/useupdatenodeinternals', - component: UseUpdateNodeInternals, - }, - { - name: 'Validation', - path: '/validation', - component: Validation, - }, - { - name: 'useKeyPress', - path: '/use-key-press', - component: UseKeyPress, - }, -]; - -const Header = () => { - const location = useLocation(); - const navigate = useNavigate(); - const [currentPath, setCurrentPath] = useState(location.pathname); - - useEffect(() => { - const name = routes.find((route) => route.path === currentPath)?.name; - document.title = `React Flow Examples${name ? ' - ' + name : ''}`; - navigate(currentPath); - }, [currentPath]); - - return ( -
- - React Flow Dev - - -
- ); -}; +import Header from './header'; export default () => ( -
- {routes.map((route) => ( - } /> - ))} + } /> + }> + } /> + {routes.map((route) => ( + } /> + ))} + + } /> ); diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts new file mode 100644 index 00000000..024a22ac --- /dev/null +++ b/examples/react/src/App/routes.ts @@ -0,0 +1,335 @@ +import Basic from '../examples/Basic'; +import Backgrounds from '../examples/Backgrounds'; +import ColorMode from '../examples/ColorMode'; +import ControlledUncontrolled from '../examples/ControlledUncontrolled'; +import ControlledViewport from '../examples/ControlledViewport'; +import CustomConnectionLine from '../examples/CustomConnectionLine'; +import CustomMiniMapNode from '../examples/CustomMiniMapNode'; +import CustomNode from '../examples/CustomNode'; +import DefaultNodes from '../examples/DefaultNodes'; +import DragHandle from '../examples/DragHandle'; +import DragNDrop from '../examples/DragNDrop'; +import EasyConnect from '../examples/EasyConnect'; +import Edges from '../examples/Edges'; +import EdgeRenderer from '../examples/EdgeRenderer'; +import EdgeTypes from '../examples/EdgeTypes'; +import Empty from '../examples/Empty'; +import Figma from '../examples/Figma'; +import FloatingEdges from '../examples/FloatingEdges'; +import Hidden from '../examples/Hidden'; +import Interaction from '../examples/Interaction'; +import Intersection from '../examples/Intersection'; +import Layouting from '../examples/Layouting'; +import MultiFlows from '../examples/MultiFlows'; +import MultiSetNodes from '../examples/MultiSetNodes'; +import NodeResizer from '../examples/NodeResizer'; +import NodeTypeChange from '../examples/NodeTypeChange'; +import NodeTypesObjectChange from '../examples/NodeTypesObjectChange'; +import Overview from '../examples/Overview'; +import Provider from '../examples/Provider'; +import SaveRestore from '../examples/SaveRestore'; +import SetNodesBatching from '../examples/SetNodesBatching'; +import Stress from '../examples/Stress'; +import Subflow from '../examples/Subflow'; +import SwitchFlow from '../examples/Switch'; +import TouchDevice from '../examples/TouchDevice'; +import Undirectional from '../examples/Undirectional'; +import ReconnectEdge from '../examples/ReconnectEdge'; +import UpdateNode from '../examples/UpdateNode'; +import UseUpdateNodeInternals from '../examples/UseUpdateNodeInternals'; +import UseReactFlow from '../examples/UseReactFlow'; +import Validation from '../examples/Validation'; +import UseKeyPress from '../examples/UseKeyPress'; +import EdgeRouting from '../examples/EdgeRouting'; +import CancelConnection from '../examples/CancelConnection'; +import InteractiveMinimap from '../examples/InteractiveMinimap'; +import UseOnSelectionChange from '../examples/UseOnSelectionChange'; +import NodeToolbar from '../examples/NodeToolbar'; +import UseConnection from '../examples/UseConnection'; +import UseNodesInitialized from '../examples/UseNodesInit'; +import UseNodesData from '../examples/UseNodesData'; +import UseHandleConnections from '../examples/UseHandleConnections'; +import AddNodeOnEdgeDrop from '../examples/AddNodeOnEdgeDrop'; +import DevTools from '../examples/DevTools'; +import Redux from '../examples/Redux'; + +export interface IRoute { + name: string; + path: string; + component: React.ComponentType; +} + +const routes: IRoute[] = [ + { + name: 'Add Node on edge Drop', + path: 'add-node-edge-drop', + component: AddNodeOnEdgeDrop, + }, + { + name: 'Basic', + path: 'basic', + component: Basic, + }, + { + name: 'Backgrounds', + path: 'backgrounds', + component: Backgrounds, + }, + { + name: 'Color Mode', + path: 'color-mode', + component: ColorMode, + }, + { + name: 'Cancel Connection', + path: 'cancel-connection', + component: CancelConnection, + }, + { + name: 'Controlled/Uncontrolled', + path: 'controlled-uncontrolled', + component: ControlledUncontrolled, + }, + { + name: 'Controlled Viewport', + path: 'controlled-viewport', + component: ControlledViewport, + }, + { + name: 'Custom Connection Line', + path: 'custom-connectionline', + component: CustomConnectionLine, + }, + { + name: 'Custom Minimap Node', + path: 'custom-minimap-node', + component: CustomMiniMapNode, + }, + { + name: 'Custom Node', + path: 'custom-node', + component: CustomNode, + }, + { + name: 'Default Nodes', + path: 'default-nodes', + component: DefaultNodes, + }, + { + name: 'DevTools', + path: 'devtools', + component: DevTools, + }, + { + name: 'Drag Handle', + path: 'draghandle', + component: DragHandle, + }, + { + name: 'Drag and Drop', + path: 'dragndrop', + component: DragNDrop, + }, + { + name: 'EasyConnect', + path: 'easy-connect', + component: EasyConnect, + }, + { + name: 'Edges', + path: 'edges', + component: Edges, + }, + { + name: 'Edge Renderer', + path: 'edge-renderer', + component: EdgeRenderer, + }, + { + name: 'Edge Types', + path: 'edge-types', + component: EdgeTypes, + }, + { + name: 'Edge Routing', + path: 'edge-routing', + component: EdgeRouting, + }, + { + name: 'Empty', + path: 'empty', + component: Empty, + }, + { + name: 'Figma', + path: 'figma', + component: Figma, + }, + { + name: 'Floating Edges', + path: 'floating-edges', + component: FloatingEdges, + }, + { + name: 'Hidden', + path: 'hidden', + component: Hidden, + }, + { + name: 'Interaction', + path: 'interaction', + component: Interaction, + }, + { + name: 'Intersection', + path: 'intersection', + component: Intersection, + }, + { + name: 'Interactive Minimap', + path: 'interactive-minimap', + component: InteractiveMinimap, + }, + { + name: 'Layouting', + path: 'layouting', + component: Layouting, + }, + { + name: 'Multi setNodes', + path: 'multi-setnodes', + component: MultiSetNodes, + }, + { + name: 'Multi Flows', + path: 'multiflows', + component: MultiFlows, + }, + { + name: 'Node Type Change', + path: 'nodetype-change', + component: NodeTypeChange, + }, + { + name: 'nodeTypes Object Change', + path: 'nodetypesobject-change', + component: NodeTypesObjectChange, + }, + { + name: 'NodeToolbar', + path: 'node-toolbar', + component: NodeToolbar, + }, + { + name: 'NodeResizer', + path: 'node-resizer', + component: NodeResizer, + }, + { + name: 'Overview', + path: 'overview', + component: Overview, + }, + { + name: 'Provider', + path: 'provider', + component: Provider, + }, + { + name: 'Save/Restore', + path: 'save-restore', + component: SaveRestore, + }, + { + name: 'SetNodes Batching', + path: 'setnodes-batching', + component: SetNodesBatching, + }, + { + name: 'Stress', + path: 'stress', + component: Stress, + }, + { + name: 'Subflow', + path: 'subflow', + component: Subflow, + }, + { + name: 'Switch Flow', + path: 'switch', + component: SwitchFlow, + }, + { + name: 'Touch Device', + path: 'touch-device', + component: TouchDevice, + }, + { + name: 'Undirectional', + path: 'undirectional', + component: Undirectional, + }, + { + name: 'Reconnect Edge', + path: 'reconnect-edge', + component: ReconnectEdge, + }, + { + name: 'Update Node', + path: 'update-node', + component: UpdateNode, + }, + { + name: 'useConnection', + path: 'use-connection', + component: UseConnection, + }, + { + name: 'useNodesInitialized', + path: 'use-nodes-initialized', + component: UseNodesInitialized, + }, + { + name: 'useOnSelectionChange', + path: 'use-on-selection-change', + component: UseOnSelectionChange, + }, + { + name: 'useReactFlow', + path: 'usereactflow', + component: UseReactFlow, + }, + { + name: 'useHandleConnections', + path: 'usehandleconnections', + component: UseHandleConnections, + }, + { + name: 'useNodesData', + path: 'usenodesdata', + component: UseNodesData, + }, + { + name: 'useUpdateNodeInternals', + path: 'useupdatenodeinternals', + component: UseUpdateNodeInternals, + }, + { + name: 'redux', + path: 'redux', + component: Redux, + }, + { + name: 'Validation', + path: 'validation', + component: Validation, + }, + { + name: 'useKeyPress', + path: 'use-key-press', + component: UseKeyPress, + }, +]; + +export default routes; diff --git a/examples/react/src/app.d.ts b/examples/react/src/app.d.ts new file mode 100644 index 00000000..372850d2 --- /dev/null +++ b/examples/react/src/app.d.ts @@ -0,0 +1,11 @@ +import { BackgroundProps, ControlProps, Edge, MiniMapProps, Node, PanelProps, ReactFlowProps } from '@xyflow/react'; + +declare global { + interface FlowConfig { + flowProps?: Omit & { nodes: Node[]; edges: Edge[] }; + panelProps?: PanelProps; + backgroundProps?: BackgroundProps; + controlsProps?: ControlProps; + minimapProps?: MiniMapProps; + } +} diff --git a/examples/react/src/examples/AddNodeOnEdgeDrop/index.tsx b/examples/react/src/examples/AddNodeOnEdgeDrop/index.tsx new file mode 100644 index 00000000..cdd55a4b --- /dev/null +++ b/examples/react/src/examples/AddNodeOnEdgeDrop/index.tsx @@ -0,0 +1,98 @@ +import { useCallback, useRef } from 'react'; +import { + ReactFlow, + useNodesState, + useEdgesState, + addEdge, + useReactFlow, + ReactFlowProvider, + OnConnect, + OnConnectStart, + OnConnectEnd, + Node, + Edge, +} from '@xyflow/react'; + +const initialNodes: Node[] = [ + { + id: '0', + type: 'input', + data: { label: 'Node' }, + position: { x: 0, y: 50 }, + }, +]; + +const initialEdges: Edge[] = []; + +let id = 1; +const getId = () => `${id++}`; + +const AddNodeOnEdgeDrop = () => { + const reactFlowWrapper = useRef(null); + const connectingNodeId = useRef(null); + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const { screenToFlowPosition } = useReactFlow(); + const onConnect: OnConnect = useCallback((params) => { + // reset the start node on connections + connectingNodeId.current = null; + setEdges((eds) => addEdge(params, eds)); + }, []); + + const onConnectStart: OnConnectStart = useCallback((_, { nodeId }) => { + connectingNodeId.current = nodeId; + }, []); + + const onConnectEnd: OnConnectEnd = useCallback( + (event) => { + if (!connectingNodeId.current) return; + + const targetIsPane = (event.target as HTMLDivElement)?.classList.contains('react-flow__pane'); + + if (targetIsPane && 'clientX' in event && 'clientY' in event) { + // we need to remove the wrapper bounds, in order to get the correct position + const id = getId(); + const newNode: Node = { + id, + position: screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }), + data: { label: `Node ${id}` }, + origin: [0.5, 0.0], + }; + + const newEdge: Edge = { + id, + source: connectingNodeId.current, + target: id, + }; + + setNodes((nds) => nds.concat(newNode)); + setEdges((eds) => eds.concat(newEdge)); + } + }, + [screenToFlowPosition] + ); + + return ( +
+ +
+ ); +}; + +export default () => ( + + + +); diff --git a/examples/react/src/examples/Basic/index.tsx b/examples/react/src/examples/Basic/index.tsx index 21f48ec5..62232a35 100644 --- a/examples/react/src/examples/Basic/index.tsx +++ b/examples/react/src/examples/Basic/index.tsx @@ -1,4 +1,4 @@ -import { MouseEvent } from 'react'; +import { MouseEvent, useCallback } from 'react'; import { ReactFlow, MiniMap, @@ -10,12 +10,16 @@ import { Edge, useReactFlow, Panel, + OnNodeDrag, } from '@xyflow/react'; -const onNodeDrag = (_: MouseEvent, node: Node) => console.log('drag', node); -const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', node); +const onNodeDrag: OnNodeDrag = (_, node: Node, nodes: Node[]) => console.log('drag', node, nodes); +const onNodeDragStart = (_: MouseEvent, node: Node, nodes: Node[]) => console.log('drag start', node, nodes); +const onNodeDragStop = (_: MouseEvent, node: Node, nodes: Node[]) => console.log('drag stop', node, nodes); const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node); +const printSelectionEvent = (name: string) => (_: MouseEvent, nodes: Node[]) => console.log(name, nodes); + const initialNodes: Node[] = [ { id: '1', @@ -52,41 +56,80 @@ const initialEdges: Edge[] = [ const defaultEdgeOptions = {}; const BasicFlow = () => { - const instance = useReactFlow(); + const { addNodes, setNodes, getNodes, setEdges, getEdges, deleteElements, updateNodeData, toObject, setViewport } = + useReactFlow(); const updatePos = () => { - instance.setNodes((nodes) => + setNodes((nodes) => nodes.map((node) => { - node.position = { - x: Math.random() * 400, - y: Math.random() * 400, + return { + ...node, + position: { + x: Math.random() * 400, + y: Math.random() * 400, + }, }; - - return node; }) ); }; - const logToObject = () => console.log(instance.toObject()); - const resetTransform = () => instance.setViewport({ x: 0, y: 0, zoom: 1 }); + const logToObject = () => console.log(toObject()); + const resetTransform = () => setViewport({ x: 0, y: 0, zoom: 1 }); const toggleClassnames = () => { - instance.setNodes((nodes) => + setNodes((nodes) => nodes.map((node) => { - node.className = node.className === 'light' ? 'dark' : 'light'; - - return node; + return { + ...node, + className: node.className === 'light' ? 'dark' : 'light', + }; }) ); }; + const deleteSelectedElements = useCallback(() => { + const selectedNodes = getNodes().filter((node) => node.selected); + const selectedEdges = getEdges().filter((edge) => edge.selected); + deleteElements({ nodes: selectedNodes, edges: selectedEdges }); + }, [deleteElements]); + + const deleteSomeElements = useCallback(() => { + deleteElements({ nodes: [{ id: '2' }], edges: [{ id: 'e1-3' }] }); + }, []); + + const onSetNodes = () => { + setNodes([ + { id: 'a', position: { x: 0, y: 0 }, data: { label: 'Node a' } }, + { id: 'b', position: { x: 0, y: 150 }, data: { label: 'Node b' } }, + ]); + + setEdges([{ id: 'a-b', source: 'a', target: 'b' }]); + }; + + const onUpdateNode = () => { + updateNodeData('1', { label: 'update' }); + updateNodeData('2', { label: 'update' }); + }; + const addNode = () => { + addNodes({ + id: `${Math.random()}`, + data: { label: 'Node' }, + position: { x: Math.random() * 300, y: Math.random() * 300 }, + className: 'light', + }); + }; + return ( { selectNodesOnDrag={false} elevateEdgesOnSelect elevateNodesOnSelect={false} + nodeDragThreshold={0} > @@ -105,6 +149,12 @@ const BasicFlow = () => { + + + + + + ); diff --git a/examples/react/src/examples/ColorMode/index.tsx b/examples/react/src/examples/ColorMode/index.tsx new file mode 100644 index 00000000..eae8ad86 --- /dev/null +++ b/examples/react/src/examples/ColorMode/index.tsx @@ -0,0 +1,90 @@ +import { ChangeEventHandler, useCallback, useState } from 'react'; +import { + ReactFlow, + addEdge, + Node, + useNodesState, + useEdgesState, + OnConnect, + Edge, + MiniMap, + Background, + Controls, + Panel, + ColorMode, + Position, +} from '@xyflow/react'; + +import './style.css'; + +const nodeDefaults = { + sourcePosition: Position.Right, + targetPosition: Position.Left, +}; + +const initialNodes: Node[] = [ + { id: 'A', type: 'input', position: { x: 0, y: 150 }, data: { label: 'A' }, ...nodeDefaults }, + { id: 'B', position: { x: 250, y: 0 }, data: { label: 'B' }, ...nodeDefaults }, + { id: 'C', position: { x: 250, y: 150 }, data: { label: 'C' }, ...nodeDefaults }, + { id: 'D', position: { x: 250, y: 300 }, data: { label: 'D' }, ...nodeDefaults }, +]; + +const initialEdges: Edge[] = [ + { + id: 'A-B', + source: 'A', + target: 'B', + }, + { + id: 'A-C', + source: 'A', + target: 'C', + }, + { + id: 'A-D', + source: 'A', + target: 'D', + }, +]; + +const ColorModeFlow = () => { + const [colorMode, setColorMode] = useState('light'); + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const onConnect: OnConnect = useCallback( + (params) => { + console.log('on connect', params); + setEdges((eds) => addEdge(params, eds)); + }, + [setEdges] + ); + + const onChange: ChangeEventHandler = (evt) => setColorMode(evt.target.value as ColorMode); + + return ( + + + + + + + + + + ); +}; + +export default ColorModeFlow; diff --git a/examples/react/src/examples/ColorMode/style.css b/examples/react/src/examples/ColorMode/style.css new file mode 100644 index 00000000..e69de29b diff --git a/examples/react/src/examples/ControlledUncontrolled/index.tsx b/examples/react/src/examples/ControlledUncontrolled/index.tsx index 170df3a4..590dfd18 100644 --- a/examples/react/src/examples/ControlledUncontrolled/index.tsx +++ b/examples/react/src/examples/ControlledUncontrolled/index.tsx @@ -60,12 +60,13 @@ const ControlledUncontrolled = () => { const updateNodePositions = () => { instance.setNodes((nodes) => nodes.map((node) => { - node.position = { - x: Math.random() * 400, - y: Math.random() * 400, + return { + ...node, + position: { + x: Math.random() * 400, + y: Math.random() * 400, + }, }; - - return node; }) ); }; @@ -73,11 +74,12 @@ const ControlledUncontrolled = () => { const updateEdgeColors = () => { instance.setEdges((edges) => edges.map((edge) => { - edge.style = { - stroke: '#ff5050', + return { + ...edge, + style: { + stroke: '#ff5050', + }, }; - - return edge; }) ); }; diff --git a/examples/react/src/examples/CustomMiniMapNode/index.tsx b/examples/react/src/examples/CustomMiniMapNode/index.tsx index d8f584b6..21b1d073 100644 --- a/examples/react/src/examples/CustomMiniMapNode/index.tsx +++ b/examples/react/src/examples/CustomMiniMapNode/index.tsx @@ -27,13 +27,13 @@ const buttonStyle: CSSProperties = { zIndex: 4, }; -const CustomMiniMapNode = ({ x, y, width, height, color }: MiniMapNodeProps) => ( - -); +const CustomMiniMapNode = ({ x, y, width, height }: MiniMapNodeProps) => { + return ; +}; const CustomMiniMapNodeFlow = () => { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnect = useCallback((params: Connection | Edge) => setEdges((els) => addEdge(params, els)), [setEdges]); const addRandomNode = () => { @@ -59,7 +59,7 @@ const CustomMiniMapNodeFlow = () => { onNodeClick={onNodeClick} onConnect={(p) => onConnect(p)} onNodeDragStop={onNodeDragStop} - onlyRenderVisibleElements={false} + onlyRenderVisibleElements={true} > diff --git a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx index aebc8e0e..1265d408 100644 --- a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx +++ b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx @@ -1,6 +1,8 @@ -import React, { memo, FC, CSSProperties, useCallback } from 'react'; +import React, { memo, CSSProperties, useCallback } from 'react'; import { Handle, Position, NodeProps, Connection, Edge, useOnViewportChange, Viewport } from '@xyflow/react'; +import type { ColorSelectorNode } from '.'; + const targetHandleStyle: CSSProperties = { background: '#555' }; const sourceHandleStyleA: CSSProperties = { ...targetHandleStyle, top: 10 }; const sourceHandleStyleB: CSSProperties = { @@ -11,7 +13,7 @@ const sourceHandleStyleB: CSSProperties = { const onConnect = (params: Connection | Edge) => console.log('handle onConnect', params); -const ColorSelectorNode: FC = ({ data, isConnectable }) => { +function ColorSelectorNode({ data, isConnectable }: NodeProps) { const onStart = useCallback((viewport: Viewport) => console.log('onStart', viewport), []); const onChange = useCallback((viewport: Viewport) => console.log('onChange', viewport), []); const onEnd = useCallback((viewport: Viewport) => console.log('onEnd', viewport), []); @@ -42,6 +44,6 @@ const ColorSelectorNode: FC = ({ data, isConnectable }) => { ); -}; +} export default memo(ColorSelectorNode); diff --git a/examples/react/src/examples/CustomNode/index.tsx b/examples/react/src/examples/CustomNode/index.tsx index 9c62f46a..03934e40 100644 --- a/examples/react/src/examples/CustomNode/index.tsx +++ b/examples/react/src/examples/CustomNode/index.tsx @@ -1,40 +1,64 @@ -import { useState, useEffect, MouseEvent, ChangeEvent, useCallback } from 'react'; +import { useState, useEffect, MouseEvent, ChangeEvent, useCallback, useRef } from 'react'; import { ReactFlow, MiniMap, Controls, addEdge, Node, - ReactFlowInstance, Position, SnapGrid, - Connection, - useNodesState, useEdgesState, Background, + OnNodeDrag, + OnInit, + applyNodeChanges, + OnNodesChange, + OnConnect, + OnBeforeDelete, + BuiltInNode, + BuiltInEdge, + NodeTypes, + ReactFlowProvider, } from '@xyflow/react'; import ColorSelectorNode from './ColorSelectorNode'; -const onInit = (reactFlowInstance: ReactFlowInstance) => { +export type ColorSelectorNode = Node< + { color: string; onChange: (event: ChangeEvent) => void }, + 'selectorNode' +>; +export type MyNode = BuiltInNode | ColorSelectorNode; +export type MyEdge = BuiltInEdge; + +const onInit: OnInit = (reactFlowInstance) => { console.log('flow loaded:', reactFlowInstance); }; -const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', node); -const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node); +const onNodeDragStop: OnNodeDrag = (_, node) => console.log('drag stop', node); +const onNodeClick = (_: MouseEvent, node: MyNode) => console.log('click', node); const initBgColor = '#1A192B'; const connectionLineStyle = { stroke: '#fff' }; const snapGrid: SnapGrid = [16, 16]; -const nodeTypes = { +const nodeTypes: NodeTypes = { selectorNode: ColorSelectorNode, }; const CustomNodeFlow = () => { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const ref = useRef(null); + const [nodes, setNodes] = useState([]); + const onNodesChange: OnNodesChange = useCallback( + (changes) => + setNodes((nds) => { + const nextNodes = applyNodeChanges(changes, nds); + return nextNodes; + }), + [setNodes] + ); + + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [bgColor, setBgColor] = useState(initBgColor); @@ -42,7 +66,7 @@ const CustomNodeFlow = () => { const onChange = (event: ChangeEvent) => { setNodes((nds) => nds.map((node) => { - if (node.id !== '2') { + if (node.id !== '2' || node.type !== 'selectorNode') { return node; } @@ -119,12 +143,13 @@ const CustomNodeFlow = () => { ]); }, []); - const onConnect = useCallback( - (connection: Connection) => - setEdges((eds) => addEdge({ ...connection, animated: true, style: { stroke: '#fff' } }, eds)), + const onConnect: OnConnect = useCallback( + (connection) => setEdges((eds) => addEdge({ ...connection, animated: true, style: { stroke: '#fff' } }, eds)), [setEdges] ); + const onBeforeDelete: OnBeforeDelete = useCallback(async (params) => true, []); + return ( { fitView minZoom={0.3} maxZoom={2} + onBeforeDelete={onBeforeDelete} + ref={ref} > - { + + nodeStrokeColor={(n: MyNode): string => { if (n.type === 'input') return '#0041d0'; if (n.type === 'selectorNode') return bgColor; if (n.type === 'output') return '#ff0072'; return '#eee'; }} - nodeColor={(n: Node): string => { + nodeColor={(n: MyNode): string => { if (n.type === 'selectorNode') return bgColor; return '#fff'; @@ -163,4 +190,8 @@ const CustomNodeFlow = () => { ); }; -export default CustomNodeFlow; +export default () => ( + + + +); diff --git a/examples/react/src/examples/DefaultNodes/index.tsx b/examples/react/src/examples/DefaultNodes/index.tsx index 46ef8e0c..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', @@ -54,26 +55,24 @@ const DefaultNodes = () => { const updateNodePositions = () => { instance.setNodes((nodes) => - nodes.map((node) => { - node.position = { + nodes.map((node) => ({ + ...node, + position: { x: Math.random() * 400, y: Math.random() * 400, - }; - - return node; - }) + }, + })) ); }; const updateEdgeColors = () => { instance.setEdges((edges) => - edges.map((edge) => { - edge.style = { + edges.map((edge) => ({ + ...edge, + style: { stroke: '#ff5050', - }; - - return edge; - }) + }, + })) ); }; diff --git a/examples/react/src/examples/DevTools/DevTools/ChangeLogger.tsx b/examples/react/src/examples/DevTools/DevTools/ChangeLogger.tsx new file mode 100644 index 00000000..d4fffcda --- /dev/null +++ b/examples/react/src/examples/DevTools/DevTools/ChangeLogger.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from 'react'; +import { NodeChange, OnNodesChange, useStore, useStoreApi } from '@xyflow/react'; + +type ChangeLoggerProps = { + color?: string; + limit?: number; +}; + +type ChangeInfoProps = { + change: NodeChange; +}; + +function ChangeInfo({ change }: ChangeInfoProps) { + const id = 'id' in change ? change.id : '-'; + const { type } = change; + + return ( +
+
node id: {id}
+
+ {type === 'add' ? JSON.stringify(change.item, null, 2) : null} + {type === 'dimensions' ? `${change.dimensions?.width} × ${change.dimensions?.height}` : null} + {type === 'position' ? `position: ${change.position?.x.toFixed(1)}, ${change.position?.y.toFixed(1)}` : null} + {type === 'remove' ? 'remove' : null} + {type === 'replace' ? JSON.stringify(change.item, null, 2) : null} + {type === 'select' ? (change.selected ? 'select' : 'unselect') : null} +
+
+ ); +} + +export default function ChangeLogger({ limit = 20 }: ChangeLoggerProps) { + const [changes, setChanges] = useState([]); + const onNodesChangeIntercepted = useRef(false); + const onNodesChange = useStore((s) => s.onNodesChange); + const store = useStoreApi(); + + useEffect(() => { + if (!onNodesChange || onNodesChangeIntercepted.current) { + return; + } + + onNodesChangeIntercepted.current = true; + const userOnNodesChange = onNodesChange; + + const onNodesChangeLogger: OnNodesChange = (changes) => { + userOnNodesChange(changes); + + setChanges((c) => { + changes.forEach((change) => { + if (c.length >= limit) { + c.pop(); + } + + c = [change, ...c]; + }); + return c; + }); + }; + + store.setState({ onNodesChange: onNodesChangeLogger }); + }, [onNodesChange]); + + return ( +
+
Change Logger
+ {changes.length === 0 ? ( + <>no changes triggered + ) : ( + changes.map((change, index) => ) + )} +
+ ); +} diff --git a/examples/react/src/examples/DevTools/DevTools/NodeInspector.tsx b/examples/react/src/examples/DevTools/DevTools/NodeInspector.tsx new file mode 100644 index 00000000..e0897509 --- /dev/null +++ b/examples/react/src/examples/DevTools/DevTools/NodeInspector.tsx @@ -0,0 +1,68 @@ +import { useNodes, ViewportPortal } from '@xyflow/react'; + +type NodeInfoProps = { + id: string; + type: string; + x: number; + y: number; + width?: number; + height?: number; + data: any; +}; + +function NodeInfo({ id, type, x, y, width, height, data }: NodeInfoProps) { + if (!width || !height) { + return null; + } + + return ( +
+
id: {id}
+
type: {type}
+
+ position: {x.toFixed(1)}, {y.toFixed(1)} +
+
+ dimensions: {width} × {height} +
+
data: {JSON.stringify(data, null, 2)}
+
+ ); +} + +export default function NodeInspector() { + const nodes = useNodes(); + + return ( + +
+ {nodes.map((node) => { + const x = node?.position?.x || 0; + const y = node?.position?.y || 0; + const width = node.measured?.width || 0; + const height = node.measured?.height || 0; + + return ( + + ); + })} +
+
+ ); +} diff --git a/examples/react/src/examples/DevTools/DevTools/index.tsx b/examples/react/src/examples/DevTools/DevTools/index.tsx new file mode 100644 index 00000000..a00a6f2e --- /dev/null +++ b/examples/react/src/examples/DevTools/DevTools/index.tsx @@ -0,0 +1,44 @@ +import { useState, type Dispatch, type SetStateAction, type ReactNode, HTMLAttributes } from 'react'; +import { Panel, PanelPosition } from '@xyflow/react'; + +import NodeInspector from './NodeInspector'; +import ChangeLogger from './ChangeLogger'; + +import './style.css'; + +export default function ReactFlowDevTools({ position = 'top-left' }: { position?: PanelPosition }) { + const [nodeInspectorActive, setNodeInspectorActive] = useState(false); + const [changeLoggerActive, setChangeLoggerActive] = useState(false); + + return ( +
+ + + Node Inspector + + + Change Logger + + + {changeLoggerActive && } + {nodeInspectorActive && } +
+ ); +} + +function DevToolButton({ + active, + setActive, + children, + ...rest +}: { + active: boolean; + setActive: Dispatch>; + children: ReactNode; +} & HTMLAttributes) { + return ( + + ); +} diff --git a/examples/react/src/examples/DevTools/DevTools/style.css b/examples/react/src/examples/DevTools/DevTools/style.css new file mode 100644 index 00000000..39c65969 --- /dev/null +++ b/examples/react/src/examples/DevTools/DevTools/style.css @@ -0,0 +1,63 @@ +.react-flow__devtools { + --border-radius: 4px; + --highlight-color: rgba(238, 58, 115, 1); + --font: monospace, sans-serif; + + border-radius: var(--border-radius); + font-size: 11px; + font-family: var(--font); +} + +.react-flow__devtools button { + background: white; + border: none; + padding: 5px 15px; + color: #222; + font-weight: bold; + font-size: 12px; + cursor: pointer; + font-family: var(--font); + background-color: #f4f4f4; +} + +.react-flow__devtools button:hover { + background: var(--highlight-color); + color: white; +} + +.react-flow__devtools button.active { + background: var(--highlight-color); + color: white; +} + +.react-flow__devtools button:first-child { + border-radius: var(--border-radius) 0 0 var(--border-radius); + border-right: 1px solid #ddd; +} + +.react-flow__devtools button:last-child { + border-radius: 0 var(--border-radius) var(--border-radius) 0; +} + +.react-flow__devtools-changelogger { + pointer-events: none; + position: relative; + top: 50px; + left: 20px; + font-family: var(--font); +} + +.react-flow__devtools-title { + font-weight: bold; + margin-bottom: 5px; +} + +.react-flow__devtools-nodeinspector { + pointer-events: none; + font-family: monospace, sans-serif; + font-size: 10px; +} + +.react-flow__devtools-nodeinfo { + top: 5px; +} diff --git a/examples/react/src/examples/DevTools/index.tsx b/examples/react/src/examples/DevTools/index.tsx new file mode 100644 index 00000000..ff75ec9d --- /dev/null +++ b/examples/react/src/examples/DevTools/index.tsx @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import { ReactFlow, addEdge, Node, Connection, Edge, useNodesState, useEdgesState } from '@xyflow/react'; + +import DevTools from './DevTools'; + +const initNodes: Node[] = [ + { + id: '1a', + type: 'input', + data: { label: 'Node 1' }, + position: { x: 250, y: 5 }, + }, + { + id: '2a', + data: { label: 'Node 2' }, + position: { x: 100, y: 100 }, + }, + { + id: '3a', + data: { label: 'Node 3' }, + position: { x: 400, y: 100 }, + }, + { + id: '4a', + data: { label: 'Node 4' }, + position: { x: 400, y: 200 }, + }, +]; + +const initEdges: Edge[] = [ + { id: 'e1-2', source: '1a', target: '2a' }, + { id: 'e1-3', source: '1a', target: '3a' }, +]; + +const BasicFlow = () => { + const [nodes, , onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]); + + return ( + + + + ); +}; + +export default BasicFlow; diff --git a/examples/react/src/examples/DragHandle/index.tsx b/examples/react/src/examples/DragHandle/index.tsx index b76d8ae7..2f7b9502 100644 --- a/examples/react/src/examples/DragHandle/index.tsx +++ b/examples/react/src/examples/DragHandle/index.tsx @@ -14,7 +14,7 @@ const initialNodes: Node[] = [ dragHandle: '.custom-drag-handle', style: { border: '1px solid #ddd', padding: '20px 40px' }, position: { x: 200, y: 200 }, - data: null, + data: {}, }, ]; @@ -33,6 +33,7 @@ const DragHandleFlow = () => { edges={edges} nodeTypes={nodeTypes} onNodeClick={onNodeClick} + nodeDragThreshold={0} /> ); }; diff --git a/examples/react/src/examples/DragNDrop/index.tsx b/examples/react/src/examples/DragNDrop/index.tsx index 83e39d88..10a2eff4 100644 --- a/examples/react/src/examples/DragNDrop/index.tsx +++ b/examples/react/src/examples/DragNDrop/index.tsx @@ -39,7 +39,7 @@ const nodeOrigin: NodeOrigin = [0.5, 0.5]; const DnDFlow = () => { const [reactFlowInstance, setReactFlowInstance] = useState(); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnect = (params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)); const onInit = (rfi: ReactFlowInstance) => setReactFlowInstance(rfi); @@ -49,7 +49,7 @@ const DnDFlow = () => { if (reactFlowInstance) { const type = event.dataTransfer.getData('application/reactflow'); - const position = reactFlowInstance.screenToFlowCoordinate({ + const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY, }); @@ -66,7 +66,7 @@ const DnDFlow = () => { return (
- +
{ onInit={onInit} onDrop={onDrop} onDragOver={onDragOver} + nodeOrigin={nodeOrigin} > diff --git a/examples/react/src/examples/EasyConnect/FloatingEdge.tsx b/examples/react/src/examples/EasyConnect/FloatingEdge.tsx index 7709cb42..48d621e7 100644 --- a/examples/react/src/examples/EasyConnect/FloatingEdge.tsx +++ b/examples/react/src/examples/EasyConnect/FloatingEdge.tsx @@ -1,11 +1,11 @@ import { useCallback } from 'react'; -import { useStore, getStraightPath, EdgeProps } from '@xyflow/react'; +import { useStore, getStraightPath, EdgeProps, useInternalNode } from '@xyflow/react'; import { getEdgeParams } from './utils.js'; function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) { - const sourceNode = useStore(useCallback((store) => store.nodes.find((n) => n.id === source), [source])); - const targetNode = useStore(useCallback((store) => store.nodes.find((n) => n.id === target), [target])); + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); if (!sourceNode || !targetNode) { return null; diff --git a/examples/react/src/examples/EasyConnect/index.tsx b/examples/react/src/examples/EasyConnect/index.tsx index 7e600848..11e347f1 100644 --- a/examples/react/src/examples/EasyConnect/index.tsx +++ b/examples/react/src/examples/EasyConnect/index.tsx @@ -5,7 +5,6 @@ import CustomNode from './CustomNode'; import FloatingEdge from './FloatingEdge'; import CustomConnectionLine from './CustomConnectionLine'; -import '@xyflow/react/dist/style.css'; import './style.css'; const initialNodes: Node[] = [ diff --git a/examples/react/src/examples/EasyConnect/utils.tsx b/examples/react/src/examples/EasyConnect/utils.tsx index cef818f5..f9667f23 100644 --- a/examples/react/src/examples/EasyConnect/utils.tsx +++ b/examples/react/src/examples/EasyConnect/utils.tsx @@ -1,21 +1,19 @@ -import { Node, Position, MarkerType, XYPosition } from '@xyflow/react'; +import { Node, Position, MarkerType, XYPosition, InternalNode } from '@xyflow/react'; // this helper function returns the intersection point // of the line between the center of the intersectionNode and the target node -function getNodeIntersection(intersectionNode: Node, targetNode: Node) { +function getNodeIntersection(intersectionNode: InternalNode, targetNode: InternalNode) { // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a - const { - width: intersectionNodeWidth, - height: intersectionNodeHeight, - positionAbsolute: intersectionNodePosition, - } = intersectionNode; - const targetPosition = targetNode.positionAbsolute!; + + const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode.measured; + const intersectionNodePosition = intersectionNode.internals.positionAbsolute; + const targetPosition = targetNode.internals.positionAbsolute!; const w = intersectionNodeWidth! / 2; const h = intersectionNodeHeight! / 2; - const x2 = intersectionNodePosition!.x + w; - const y2 = intersectionNodePosition!.y + h; + const x2 = intersectionNodePosition.x + w; + const y2 = intersectionNodePosition.y + h; const x1 = targetPosition.x + w; const y1 = targetPosition.y + h; @@ -31,8 +29,8 @@ function getNodeIntersection(intersectionNode: Node, targetNode: Node) { } // returns the position (top,right,bottom or right) passed node compared to the intersection point -function getEdgePosition(node: Node, intersectionPoint: XYPosition) { - const n = { ...node.positionAbsolute, ...node }; +function getEdgePosition(node: InternalNode, intersectionPoint: XYPosition) { + const n = { ...node.internals.positionAbsolute, ...node }; const nx = Math.round(n.x!); const ny = Math.round(n.y!); const px = Math.round(intersectionPoint.x); @@ -41,13 +39,13 @@ function getEdgePosition(node: Node, intersectionPoint: XYPosition) { if (px <= nx + 1) { return Position.Left; } - if (px >= nx + n.width! - 1) { + if (px >= nx + n.measured?.width! - 1) { return Position.Right; } if (py <= ny + 1) { return Position.Top; } - if (py >= n.y! + n.height! - 1) { + if (py >= n.y! + n.measured?.height! - 1) { return Position.Bottom; } @@ -55,7 +53,7 @@ function getEdgePosition(node: Node, intersectionPoint: XYPosition) { } // returns the parameters (sx, sy, tx, ty, sourcePos, targetPos) you need to create an edge -export function getEdgeParams(source: Node, target: Node) { +export function getEdgeParams(source: InternalNode, target: InternalNode) { const sourceIntersectionPoint = getNodeIntersection(source, target); const targetIntersectionPoint = getNodeIntersection(target, source); diff --git a/examples/react/src/examples/Edges/index.tsx b/examples/react/src/examples/Edges/index.tsx index b5b84fc6..bbba30fb 100644 --- a/examples/react/src/examples/Edges/index.tsx +++ b/examples/react/src/examples/Edges/index.tsx @@ -175,6 +175,15 @@ const edgeTypes: EdgeTypes = { custom2: CustomEdge2, }; +const defaultEdgeOptions = { + markerEnd: { + type: MarkerType.ArrowClosed, + color: 'red', + width: 20, + height: 20, + }, +}; + const EdgesFlow = () => { const [nodes, , onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -196,6 +205,8 @@ const EdgesFlow = () => { onEdgeMouseEnter={onEdgeMouseEnter} onEdgeMouseMove={onEdgeMouseMove} onEdgeMouseLeave={onEdgeMouseLeave} + onDelete={console.log} + defaultEdgeOptions={defaultEdgeOptions} > diff --git a/examples/react/src/examples/Empty/index.tsx b/examples/react/src/examples/Empty/index.tsx index e162daad..4dcd996e 100644 --- a/examples/react/src/examples/Empty/index.tsx +++ b/examples/react/src/examples/Empty/index.tsx @@ -26,8 +26,8 @@ const buttonStyle: CSSProperties = { }; const EmptyFlow = () => { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnect = useCallback((params: Connection | Edge) => setEdges((els) => addEdge(params, els)), [setEdges]); const addRandomNode = () => { diff --git a/examples/react/src/examples/FloatingEdges/FloatingEdge.tsx b/examples/react/src/examples/FloatingEdges/FloatingEdge.tsx index 6ee9e1e2..0856d012 100644 --- a/examples/react/src/examples/FloatingEdges/FloatingEdge.tsx +++ b/examples/react/src/examples/FloatingEdges/FloatingEdge.tsx @@ -5,8 +5,8 @@ import { getEdgeParams } from './utils'; const FloatingEdge: FC = ({ id, source, target, style }) => { const { sourceNode, targetNode } = useStore((s) => { - const sourceNode = s.nodes.find((n) => n.id === source); - const targetNode = s.nodes.find((n) => n.id === target); + const sourceNode = s.nodeLookup.get(source); + const targetNode = s.nodeLookup.get(target); return { sourceNode, targetNode }; }); diff --git a/examples/react/src/examples/FloatingEdges/utils.ts b/examples/react/src/examples/FloatingEdges/utils.ts index 543b0945..08593c5c 100644 --- a/examples/react/src/examples/FloatingEdges/utils.ts +++ b/examples/react/src/examples/FloatingEdges/utils.ts @@ -1,15 +1,15 @@ -import { Position, XYPosition, Node, Edge } from '@xyflow/react'; +import { Position, XYPosition, Node, Edge, InternalNode } from '@xyflow/react'; // this helper function returns the intersection point // of the line between the center of the intersectionNode and the target node function getNodeIntersection(intersectionNode: Node, targetNode: Node): XYPosition { // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a - const { - width: intersectionNodeWidth, - height: intersectionNodeHeight, - position: intersectionNodePosition, - } = intersectionNode; + const { position: intersectionNodePosition } = intersectionNode; + const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode.measured ?? { + width: 0, + height: 0, + }; const targetPosition = targetNode.position; const w = (intersectionNodeWidth ?? 0) / 2; @@ -42,13 +42,13 @@ function getEdgePosition(node: Node, intersectionPoint: XYPosition) { if (px <= nx + 1) { return Position.Left; } - if (px >= nx + (n.width ?? 0) - 1) { + if (px >= nx + (n.measured?.width ?? 0) - 1) { return Position.Right; } if (py <= ny + 1) { return Position.Top; } - if (py >= n.y + (n.height ?? 0) - 1) { + if (py >= n.y + (n.measured?.height ?? 0) - 1) { return Position.Bottom; } @@ -56,7 +56,7 @@ function getEdgePosition(node: Node, intersectionPoint: XYPosition) { } // returns the parameters (sx, sy, tx, ty, sourcePos, targetPos) you need to create an edge -export function getEdgeParams(source: Node, target: Node) { +export function getEdgeParams(source: InternalNode, target: InternalNode) { const sourceIntersectionPoint = getNodeIntersection(source, target); const targetIntersectionPoint = getNodeIntersection(target, source); diff --git a/examples/react/src/examples/Hidden/index.tsx b/examples/react/src/examples/Hidden/index.tsx index 586f300f..c9d49716 100644 --- a/examples/react/src/examples/Hidden/index.tsx +++ b/examples/react/src/examples/Hidden/index.tsx @@ -11,6 +11,7 @@ import { MiniMap, Controls, } from '@xyflow/react'; +import ReactFlowDevTools from '../DevTools/DevTools'; const initialNodes: Node[] = [ { @@ -48,8 +49,10 @@ const initialEdges: Edge[] = [ const setHidden = (hidden: boolean) => (els: any[]) => els.map((e: any) => { - e.hidden = hidden; - return e; + return { + ...e, + hidden, + }; }); const HiddenFlow = () => { @@ -70,6 +73,8 @@ const HiddenFlow = () => { setEdges(setHidden(isHidden)); }, [isHidden, setEdges, setNodes]); + console.log(nodes); + return ( { > +
diff --git a/examples/react/src/examples/Interaction/index.tsx b/examples/react/src/examples/Interaction/index.tsx index 0dcee27b..ba3ecf03 100644 --- a/examples/react/src/examples/Interaction/index.tsx +++ b/examples/react/src/examples/Interaction/index.tsx @@ -36,7 +36,7 @@ const onNodeClick = (_: ReactMouseEvent, node: Node) => console.log('click', nod const onEdgeClick = (_: ReactMouseEvent, edge: Edge) => console.log('click', edge); const onPaneClick = (event: ReactMouseEvent) => console.log('onPaneClick', event); const onPaneScroll = (event?: WheelEvent) => console.log('onPaneScroll', event); -const onPaneContextMenu = (event: ReactMouseEvent) => console.log('onPaneContextMenu', event); +const onPaneContextMenu = (event: ReactMouseEvent | MouseEvent) => console.log('onPaneContextMenu', event); const onMoveEnd = (_: TouchEvent | MouseEvent | null, viewport: Viewport) => console.log('onMoveEnd', viewport); const InteractionFlow = () => { @@ -80,6 +80,7 @@ const InteractionFlow = () => { onPaneClick={captureZoomClick ? onPaneClick : undefined} onPaneScroll={captureZoomScroll ? onPaneScroll : undefined} onPaneContextMenu={captureZoomClick ? onPaneContextMenu : undefined} + nodeDragThreshold={0} onMoveEnd={onMoveEnd} > diff --git a/examples/react/src/examples/Intersection/index.tsx b/examples/react/src/examples/Intersection/index.tsx index 006ddc77..2deb471e 100644 --- a/examples/react/src/examples/Intersection/index.tsx +++ b/examples/react/src/examples/Intersection/index.tsx @@ -17,28 +17,34 @@ const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', n const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node); const initialNodes: Node[] = [ + { + id: '0', + data: { label: 'rectangle' }, + position: { x: 0, y: 0 }, + width: 100, + height: 100, + draggable: false, + style: { + opacity: 0.5, + }, + }, { id: '1', type: 'input', data: { label: 'Node 1' }, position: { x: 0, y: 0 }, - className: 'light', - style: { - width: 200, - height: 100, - }, + width: 200, + height: 100, }, { id: '2', data: { label: 'Node 2' }, position: { x: 0, y: 150 }, - className: 'light', }, { id: '3', data: { label: 'Node 3' }, position: { x: 250, y: 0 }, - className: 'light', }, { id: '4', @@ -48,7 +54,6 @@ const initialNodes: Node[] = [ width: 50, height: 50, }, - className: 'light', }, ]; diff --git a/examples/react/src/examples/Layouting/index.tsx b/examples/react/src/examples/Layouting/index.tsx index 4ec3e14c..2371b503 100644 --- a/examples/react/src/examples/Layouting/index.tsx +++ b/examples/react/src/examples/Layouting/index.tsx @@ -19,6 +19,7 @@ import { import initialItems from './initial-elements'; import styles from './layouting.module.css'; +import ReactFlowDevTools from '../DevTools/DevTools'; const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); @@ -56,16 +57,16 @@ const LayoutFlow = () => { const layoutedNodes = nodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id); - node.targetPosition = isHorizontal ? Position.Left : Position.Top; - node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom; - // we need to pass a slightly different position in order to notify react flow about the change - // @TODO how can we change the position handling so that we dont need this hack? - node.position = { - x: nodeWithPosition.x + Math.random() / 1000, - y: nodeWithPosition.y, - }; - return node; + return { + ...node, + targetPosition: isHorizontal ? Position.Left : Position.Top, + sourcePosition: isHorizontal ? Position.Right : Position.Bottom, + position: { + x: nodeWithPosition.x, + y: nodeWithPosition.y, + }, + }; }); setNodes(layoutedNodes); @@ -98,6 +99,7 @@ const LayoutFlow = () => { onEdgesChange={onEdgesChange} > + diff --git a/examples/react/src/examples/MultiSetNodes/index.tsx b/examples/react/src/examples/MultiSetNodes/index.tsx new file mode 100644 index 00000000..b85cb33c --- /dev/null +++ b/examples/react/src/examples/MultiSetNodes/index.tsx @@ -0,0 +1,92 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, + Node, + Edge, + Panel, + useReactFlow, + ReactFlowProvider, +} from '@xyflow/react'; + +import './style.css'; + +const initNodes: Node[] = []; + +for (let i = 0; i < 100; i++) { + initNodes.push({ + id: i.toString(), + data: { + label: `node ${i + 1}`, + }, + position: { x: (i % 10) * 60, y: Math.floor(i / 10) * 60 }, + }); +} + +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, updateEdge } = useReactFlow(); + const [nodes, , onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + const multiSetNodes = () => { + nodes.forEach((node) => + setNodes((nds) => + nds.map((n) => { + if (n.id === node.id) { + return { ...n, data: { label: 'node set' } }; + } + + return n; + }) + ) + ); + }; + + const multiUpdateNodes = () => { + nodes.forEach((node) => updateNodeData(node.id, { label: 'node update' })); + }; + + const multiUpdateEdges = () => { + edges.forEach((edge) => updateEdge(edge.id, { label: 'edge update' })); + }; + + return ( + + + + + + + + + + ); +}; + +export default () => ( + + + +); diff --git a/examples/react/src/examples/MultiSetNodes/style.css b/examples/react/src/examples/MultiSetNodes/style.css new file mode 100644 index 00000000..2f427666 --- /dev/null +++ b/examples/react/src/examples/MultiSetNodes/style.css @@ -0,0 +1,3 @@ +.multiset .react-flow__node { + width: 50px; +} diff --git a/examples/react/src/examples/NestedNodes/index.tsx b/examples/react/src/examples/NestedNodes/index.tsx deleted file mode 100644 index 56bc3ca8..00000000 --- a/examples/react/src/examples/NestedNodes/index.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useState, MouseEvent, useCallback } from 'react'; -import { - ReactFlow, - Controls, - MiniMap, - Background, - addEdge, - useNodesState, - useEdgesState, - Node, - Edge, - ReactFlowInstance, - Connection, -} from '@xyflow/react'; - -const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', node); -const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node); -const onEdgeClick = (_: MouseEvent, edge: Edge) => console.log('click', edge); - -const initialNodes: Node[] = [ - { - id: '1', - type: 'input', - data: { label: 'Node 1' }, - position: { x: 250, y: 5 }, - className: 'light', - }, - { - id: '2', - data: { label: 'Node 2' }, - position: { x: 100, y: 100 }, - className: 'light', - style: { backgroundColor: 'rgba(255, 0, 0, 0.8)', width: 200, height: 200 }, - }, - { - id: '2a', - data: { label: 'Node 2a' }, - position: { x: 10, y: 50 }, - parentNode: '2', - }, - { - id: '3', - data: { label: 'Node 3' }, - position: { x: 320, y: 100 }, - className: 'light', - }, - { - id: '4', - data: { label: 'Node 4' }, - position: { x: 320, y: 200 }, - className: 'light', - style: { backgroundColor: 'rgba(255, 0, 0, 0.7)', width: 300, height: 300 }, - }, - { - id: '4a', - data: { label: 'Node 4a' }, - position: { x: 15, y: 65 }, - className: 'light', - parentNode: '4', - extent: 'parent', - }, - { - id: '4b', - data: { label: 'Node 4b' }, - position: { x: 15, y: 120 }, - className: 'light', - style: { - backgroundColor: 'rgba(255, 0, 255, 0.7)', - height: 150, - width: 270, - }, - parentNode: '4', - }, - { - id: '4b1', - data: { label: 'Node 4b1' }, - position: { x: 20, y: 40 }, - className: 'light', - parentNode: '4b', - }, - { - id: '4b2', - data: { label: 'Node 4b2' }, - position: { x: 100, y: 100 }, - className: 'light', - parentNode: '4b', - }, -]; - -const initialEdges: Edge[] = [ - { id: 'e1-2', source: '1', target: '2', animated: true }, - { id: 'e1-3', source: '1', target: '3' }, - { id: 'e2a-4a', source: '2a', target: '4a' }, - { id: 'e3-4', source: '3', target: '4' }, - { id: 'e3-4b', source: '3', target: '4b' }, - { id: 'e4a-4b1', source: '4a', target: '4b1' }, - { id: 'e4a-4b2', source: '4a', target: '4b2' }, - { id: 'e4b1-4b2', source: '4b1', target: '4b2' }, -]; - -const NestedFlow = () => { - const [rfInstance, setRfInstance] = useState(null); - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - - const onConnect = useCallback( - (connection: Connection) => { - setEdges((eds) => addEdge(connection, eds)); - }, - [setEdges] - ); - const onInit = useCallback((reactFlowInstance: ReactFlowInstance) => setRfInstance(reactFlowInstance), []); - - const updatePos = () => { - setNodes((nds) => { - return nds.map((n) => { - n.position = { - x: Math.random() * 400, - y: Math.random() * 400, - }; - - return n; - }); - }); - }; - - const logToObject = () => console.log(rfInstance?.toObject()); - const resetTransform = () => rfInstance?.setViewport({ x: 0, y: 0, zoom: 1 }); - - const toggleClassnames = () => { - setNodes((nds) => { - return nds.map((n) => { - n.className = n.className === 'light' ? 'dark' : 'light'; - return n; - }); - }); - }; - - const toggleChildNodes = () => { - setNodes((nds) => { - return nds.map((n) => { - n.hidden = !!n.parentNode && !n.hidden; - return n; - }); - }); - }; - - return ( - - - - - -
- - - - - -
-
- ); -}; - -export default NestedFlow; diff --git a/examples/react/src/examples/NodeResizer/DefaultResizer.tsx b/examples/react/src/examples/NodeResizer/DefaultResizer.tsx index 702b5c0f..a3532a8f 100644 --- a/examples/react/src/examples/NodeResizer/DefaultResizer.tsx +++ b/examples/react/src/examples/NodeResizer/DefaultResizer.tsx @@ -9,7 +9,7 @@ const DefaultResizerNode: FC = ({ data, selected }) => { maxWidth={data.maxWidth ?? undefined} minHeight={data.minHeight ?? undefined} maxHeight={data.maxHeight ?? undefined} - isVisible={data.isVisible ?? selected} + isVisible={data.isVisible ?? !!selected} shouldResize={data.shouldResize ?? undefined} onResizeStart={data.onResizeStart ?? undefined} onResize={data.onResize ?? undefined} diff --git a/examples/react/src/examples/NodeResizer/index.tsx b/examples/react/src/examples/NodeResizer/index.tsx index e276996c..397c1f22 100644 --- a/examples/react/src/examples/NodeResizer/index.tsx +++ b/examples/react/src/examples/NodeResizer/index.tsx @@ -16,8 +16,6 @@ import CustomResizer from './CustomResizer'; import VerticalResizer from './VerticalResizer'; import HorizontalResizer from './HorizontalResizer'; -import '@xyflow/react/dist/style.css'; - const nodeTypes = { defaultResizer: DefaultResizer, customResizer: CustomResizer, @@ -39,6 +37,7 @@ const initialNodes: Node[] = [ type: 'defaultResizer', data: { label: 'default resizer' }, position: { x: 0, y: 0 }, + origin: [1, 1], style: { ...nodeStyle }, }, { @@ -52,7 +51,9 @@ const initialNodes: Node[] = [ maxHeight: 200, }, position: { x: 0, y: 60 }, - style: { ...nodeStyle, width: 100, height: 80 }, + width: 100, + height: 80, + style: { ...nodeStyle }, }, { id: '1b', @@ -66,9 +67,9 @@ const initialNodes: Node[] = [ maxHeight: 400, }, position: { x: 250, y: 0 }, + width: 174, + height: 123, style: { - width: 174, - height: 123, ...nodeStyle, }, }, @@ -77,7 +78,9 @@ const initialNodes: Node[] = [ type: 'customResizer', data: { label: 'custom resize icon' }, position: { x: 0, y: 200 }, - style: { width: 100, height: 60, ...nodeStyle }, + width: 100, + height: 60, + style: { ...nodeStyle }, }, { id: '3', @@ -96,7 +99,8 @@ const initialNodes: Node[] = [ keepAspectRatio: true, }, position: { x: 400, y: 200 }, - style: { ...nodeStyle, height: 50 }, + height: 50, + style: { ...nodeStyle }, }, { id: '4', @@ -118,6 +122,50 @@ const initialNodes: Node[] = [ position: { x: 250, y: 400 }, style: { ...nodeStyle }, }, + { + id: '5', + type: 'defaultResizer', + data: { label: 'Parent', keepAspectRatio: true }, + position: { x: 700, y: 0 }, + width: 300, + height: 300, + style: { ...nodeStyle }, + }, + { + id: '5a', + type: 'defaultResizer', + data: { + label: 'Child with extent: parent', + }, + position: { x: 50, y: 50 }, + parentId: '5', + extent: 'parent', + width: 50, + height: 100, + style: { ...nodeStyle }, + }, + { + id: '5b', + type: 'defaultResizer', + data: { label: 'Child with expandParent' }, + position: { x: 100, y: 100 }, + width: 100, + height: 100, + parentId: '5', + expandParent: true, + style: { ...nodeStyle }, + }, + { + id: '5c', + type: 'defaultResizer', + data: { label: 'Child with expandParent & keepAspectRatio' }, + position: { x: 250, y: 200 }, + height: 100, + width: 100, + parentId: '5', + expandParent: true, + style: { ...nodeStyle }, + }, ]; const CustomNodeFlow = () => { @@ -142,6 +190,7 @@ const CustomNodeFlow = () => { maxZoom={5} snapToGrid={snapToGrid} fitView + onlyRenderVisibleElements > diff --git a/examples/react/src/examples/NodeTypesObjectChange/index.tsx b/examples/react/src/examples/NodeTypesObjectChange/index.tsx index 01a0d993..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 = { @@ -66,7 +74,7 @@ const nodeTypesObjects: NodeTypesObject = { const NodeTypeChangeFlow = () => { const [nodeTypesId, setNodeTypesId] = useState('a'); const [nodes, , onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]); const changeType = () => setNodeTypesId((nt) => (nt === 'a' ? 'b' : 'a')); @@ -78,6 +86,7 @@ const NodeTypeChangeFlow = () => { onEdgesChange={onEdgesChange} onConnect={onConnect} nodeTypes={nodeTypesObjects[nodeTypesId]} + fitView > + + + + ); +}; + +export default function App() { + return ( + + + + ); +} diff --git a/examples/react/src/examples/Stress/index.tsx b/examples/react/src/examples/Stress/index.tsx index 7e83fac7..9020ddd6 100644 --- a/examples/react/src/examples/Stress/index.tsx +++ b/examples/react/src/examples/Stress/index.tsx @@ -1,7 +1,6 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { ReactFlow, - ReactFlowInstance, Edge, Node, NodeChange, @@ -12,16 +11,11 @@ import { EdgeChange, Controls, Background, - MiniMap, Panel, } from '@xyflow/react'; import { getNodesAndEdges } from './utils'; - -const onInit = (reactFlowInstance: ReactFlowInstance) => { - reactFlowInstance.fitView(); - console.log(reactFlowInstance.getNodes()); -}; +import { FrameRecorder, generateMouseEventParamsTargetingNode, nextFrame } from './performanceUtils'; const { nodes: initialNodes, edges: initialEdges } = getNodesAndEdges(25, 25); @@ -31,6 +25,166 @@ const StressFlow = () => { const onConnect = useCallback((connection: Connection) => { setEdges((eds) => addEdge(connection, eds)); }, []); + + const dragInViewport = async () => { + // Note: selecting specifically node 18, as it’s normally located in the right part of the viewport – + // which means dragging it left is safe to do without scrolling the viewport. + const nodeElement = document.querySelector('.react-flow__node[data-id="18"]'); + if (!nodeElement) throw new Error('Node with id 18 not found'); + + const frameRecorder = new FrameRecorder(); + + // Hold down the mouse + frameRecorder.setStage('mousedown'); + const mouseDownEvent = generateMouseEventParamsTargetingNode(nodeElement); + nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseDownEvent)); + await nextFrame(); + + // Start at the node position and move the mouse 5px to the left on every frame + frameRecorder.setStage('mousemove'); + let currentXPosition = mouseDownEvent.clientX; + for (let iteration = 0; iteration < 20; ++iteration) { + const movementX = -5; + currentXPosition += movementX; + + nodeElement.dispatchEvent( + new MouseEvent('mousemove', { + ...mouseDownEvent, + clientX: currentXPosition, + screenX: currentXPosition, + movementX, + }) + ); + await nextFrame(); + } + + // Release the mouse + frameRecorder.setStage('mouseup'); + nodeElement.dispatchEvent( + new MouseEvent('mouseup', { + ...mouseDownEvent, + clientX: currentXPosition, + screenX: currentXPosition, + }) + ); + await nextFrame(); + + // Log the results + await frameRecorder.endRecordingAsync(); + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + }; + + const dragOutsideViewport = async () => { + const randomNodeIndex = Math.floor(Math.random() * nodes.length); + const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodes[randomNodeIndex].id}"]`); + if (!nodeElement) throw new Error('Node not found'); + + const frameRecorder = new FrameRecorder(); + + // Hold down the mouse + frameRecorder.setStage('mousedown'); + const mouseDownEvent = generateMouseEventParamsTargetingNode(nodeElement); + nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseDownEvent)); + await nextFrame(); + + // Move the mouse to the top of the viewport (so that the viewport starts + // scrolling up). Then, wiggle the mouse up and down to keep the viewport + // scrolling. + frameRecorder.setStage('mousemove'); + let currentYPosition = 50; + for (let iteration = 0; iteration < 20; ++iteration) { + const movementY = Math.random() > 0.5 ? +2 : -2; + currentYPosition += movementY; + + nodeElement.dispatchEvent( + new MouseEvent('mousemove', { + ...mouseDownEvent, + clientY: currentYPosition, + screenY: currentYPosition, + movementY, + }) + ); + await nextFrame(); + } + + // Release the mouse + frameRecorder.setStage('mouseup'); + nodeElement.dispatchEvent( + new MouseEvent('mouseup', { + ...mouseDownEvent, + clientY: currentYPosition, + screenY: currentYPosition, + }) + ); + await nextFrame(); + + // Log the results + await frameRecorder.endRecordingAsync(); + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + }; + + const selectNode = async () => { + const randomNodeIndex = Math.floor(Math.random() * nodes.length); + const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodes[randomNodeIndex].id}"]`); + if (!nodeElement) throw new Error('Node not found'); + + const frameRecorder = new FrameRecorder(); + + const mouseEvent = generateMouseEventParamsTargetingNode(nodeElement); + + // mousedown + frameRecorder.setStage('mousedown'); + nodeElement.dispatchEvent(new MouseEvent('mousedown', mouseEvent)); + await nextFrame(); + + // click + frameRecorder.setStage('click'); + nodeElement.dispatchEvent(new MouseEvent('click', mouseEvent)); + await nextFrame(); + + // mouseup + frameRecorder.setStage('mouseup'); + nodeElement.dispatchEvent(new MouseEvent('mouseup', mouseEvent)); + await nextFrame(); + + // Log the results + await frameRecorder.endRecordingAsync(); + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + }; + + const [key, setKey] = useState(0); + const frameRecorderRef = useRef(null); + function remount() { + frameRecorderRef.current = new FrameRecorder(); + setKey((k) => k + 1); + } + useEffect(() => { + const frameRecorder = frameRecorderRef.current; + if (!frameRecorder) return; + + frameRecorder.endRecordingAsync().then(() => { + console.log('Frame durations:', frameRecorder.getFrames()); + console.log( + 'Frame durations for Observable (copy and paste to https://observablehq.com/@iamakulov/long-frame-visualizer):', + frameRecorder.getFramesForObservable() + ); + + frameRecorderRef.current = null; + }); + }, [key]); + const updatePos = () => { setNodes((nds) => { return nds.map((n) => { @@ -62,19 +216,23 @@ const StressFlow = () => { return ( - + + + + diff --git a/examples/react/src/examples/Stress/performanceUtils.ts b/examples/react/src/examples/Stress/performanceUtils.ts new file mode 100644 index 00000000..f14f8711 --- /dev/null +++ b/examples/react/src/examples/Stress/performanceUtils.ts @@ -0,0 +1,150 @@ +type Frame = { + duration: number; + stage: string; +}; + +/** + * Measures and outputs the duration of every frame that happens between the + * instance is created and `endRecording()` is called. + * + * Usage: + * + * ```ts + * const recorder = new FrameRecorder(); + * + * // Do some performance-intensive stuff + * + * await recorder.endRecordingAsync(); + * + * console.log(recorder.getFrames()); + * console.log(recorder.getFramesForObservable()); // → paste into https://observablehq.com/@iamakulov/long-frame-visualizer + * ``` + */ +export class FrameRecorder { + private frames: Frame[] = []; + private animationFrameId: number; + private stage: string = ''; + + constructor() { + let lastFrameTimestamp = performance.now(); + + const measureFrame = () => { + const timestamp = performance.now(); + + // Visualize the frames in the Performance pane (see the collapsed + // “Timings” section) – so it’s easier to see what exactly each frame + // captured + performance.measure(`frame (${this.stage})`, { + start: lastFrameTimestamp, + end: timestamp, + }); + + this.frames.push({ + duration: timestamp - lastFrameTimestamp, + stage: this.stage, + }); + + lastFrameTimestamp = timestamp; + + this.animationFrameId = requestAnimationFrame(measureFrame); + }; + + this.animationFrameId = requestAnimationFrame(measureFrame); + } + + // The method is explicitly marked `async` in its name to make sure the caller + // doesn’t forget to `await` it. (Otherwise, some events might be lost.) + async endRecordingAsync() { + this.setStage('waiting for idle'); + await new Promise((resolve) => requestIdleCallback(resolve)); + requestAnimationFrame(() => { + cancelAnimationFrame(this.animationFrameId); + }); + } + + /** + * Adds an optional annotation to all subsequent frames. Useful to + * differentiate frames from different events – e.g. you can call + * `setState("mousedown")` before dispatching a mousedown event, and then + * `setState("mouseup")` before a mouseup one. + * + * When used, will affect both `getFramesForObservable()` and `getFrames()`. + */ + setStage(stage: string) { + this.stage = stage; + } + + getFramesForObservable() { + return this.frames.map((frame, index) => ({ ...frame, index })); + } + + getFrames() { + // Group frames by stage – so you could see which frames originated from `mousedown` vs `mousemove` vs `mouseup` events + const framesPerStage: Record = {}; + for (const frame of this.frames) { + const stage = frame.stage; + if (!framesPerStage[stage]) { + framesPerStage[stage] = []; + } + framesPerStage[stage].push(frame.duration); + } + + // If there’s only one stage, return the frames directly + return framesPerStage; + } +} + +/** + * Returns a promise that resolves when the next frame starts, and everything + * that was already scheduled in the event queue has been processed. + */ +export function nextFrame() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * Generates params for a new MouseEvent() that will target the given node. + */ +export function generateMouseEventParamsTargetingNode(node: Element) { + const nodePosition = node.getBoundingClientRect(); + + // Let’s make the event (eg click) happen 5px to the right and 5px to the + // bottom of the node’s top-left corner + const positionRelativeToNode = { + left: 5, + top: 5, + }; + + return { + clientX: Math.round(nodePosition.left + positionRelativeToNode.left), + clientY: Math.round(nodePosition.top + positionRelativeToNode.top), + movementX: 0, + movementY: 0, + offsetX: positionRelativeToNode.left, + offsetY: positionRelativeToNode.top, + screenX: Math.round(nodePosition.left + positionRelativeToNode.left), + screenY: Math.round(nodePosition.top + positionRelativeToNode.top), + + // Required boilerplate + altKey: false, + bubbles: true, + button: 0, + buttons: 1, + cancelBubble: false, + cancelable: true, + composed: true, + ctrlKey: false, + currentTarget: null, + defaultPrevented: false, + detail: 1, + eventPhase: 0, + fromElement: null, + isTrusted: true, + metaKey: false, + relatedTarget: null, + returnValue: true, + shiftKey: false, + view: window, + which: 1, + }; +} diff --git a/examples/react/src/examples/Stress/utils.ts b/examples/react/src/examples/Stress/utils.ts index 6278e006..9266bf9c 100644 --- a/examples/react/src/examples/Stress/utils.ts +++ b/examples/react/src/examples/Stress/utils.ts @@ -20,8 +20,6 @@ export function getNodesAndEdges(xElements = 10, yElements = 10): ElementsCollec style: { width: 50, height: 30, fontSize: 11 }, data, position, - width: 50, - height: 30, }; initialNodes.push(node); diff --git a/examples/react/src/examples/Subflow/DebugNode.tsx b/examples/react/src/examples/Subflow/DebugNode.tsx index 9170ef65..547da524 100644 --- a/examples/react/src/examples/Subflow/DebugNode.tsx +++ b/examples/react/src/examples/Subflow/DebugNode.tsx @@ -1,6 +1,6 @@ import React, { memo, FC, CSSProperties } from 'react'; -import { Handle, NodeProps, Position } from '@xyflow/react'; +import { Handle, NodeProps, Position, useInternalNode } from '@xyflow/react'; const infoStyle: CSSProperties = { fontSize: 11 }; const idStyle: CSSProperties = { @@ -11,13 +11,18 @@ const idStyle: CSSProperties = { left: 2, }; -const DebugNode: FC = ({ zIndex, xPos, yPos, id }) => { +const DebugNode: FC = ({ zIndex, positionAbsoluteX, positionAbsoluteY, id }) => { + const node = useInternalNode(id)!; + return ( <>
{id}
- x:{Math.round(xPos || 0)} y:{Math.round(yPos || 0)} z:{zIndex} + x:{Math.round(positionAbsoluteX)} y:{Math.round(positionAbsoluteY)} z:{zIndex} +
+
+ x:{Math.round(node.position.x)} y:{Math.round(node.position.y)}
diff --git a/examples/react/src/examples/Subflow/index.tsx b/examples/react/src/examples/Subflow/index.tsx index e2e9af89..74654870 100644 --- a/examples/react/src/examples/Subflow/index.tsx +++ b/examples/react/src/examples/Subflow/index.tsx @@ -13,6 +13,9 @@ import { MiniMap, Background, Panel, + NodeOrigin, + useUpdateNodeInternals, + ReactFlowProvider, } from '@xyflow/react'; import DebugNode from './DebugNode'; @@ -49,7 +52,7 @@ const initialNodes: Node[] = [ data: { label: 'Node 4a' }, position: { x: 15, y: 15 }, className: 'light', - parentNode: '4', + parentId: '4', origin: [0.5, 0.5], extent: [ @@ -67,21 +70,21 @@ const initialNodes: Node[] = [ height: 200, width: 300, }, - parentNode: '4', + parentId: '4', }, { id: '4b1', data: { label: 'Node 4b1' }, position: { x: 40, y: 20 }, className: 'light', - parentNode: '4b', + parentId: '4b', }, { id: '4b2', data: { label: 'Node 4b2' }, position: { x: 20, y: 100 }, className: 'light', - parentNode: '4b', + parentId: '4b', }, { id: '5', @@ -89,7 +92,7 @@ const initialNodes: Node[] = [ data: { label: 'Node 5' }, position: { x: 650, y: 250 }, className: 'light', - style: { width: 400, height: 150 }, + style: { width: 100, height: 100 }, zIndex: 1000, }, { @@ -97,15 +100,15 @@ const initialNodes: Node[] = [ data: { label: 'Node 5a' }, position: { x: 0, y: 0 }, className: 'light', - parentNode: '5', + parentId: '5', extent: 'parent', }, { id: '5b', data: { label: 'Node 5b' }, - position: { x: 225, y: 50 }, + position: { x: 200, y: 200 }, className: 'light', - parentNode: '5', + parentId: '5', expandParent: true, }, { @@ -119,6 +122,7 @@ const initialNodes: Node[] = [ data: { label: 'Node 3' }, position: { x: 400, y: 100 }, className: 'light', + extent: 'parent', }, ]; @@ -149,6 +153,7 @@ const nodeTypes = { const Subflow = () => { const [rfInstance, setRfInstance] = useState(null); + const updateNodeInternals = useUpdateNodeInternals(); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -158,10 +163,13 @@ const Subflow = () => { const updatePos = () => { setNodes((nds) => { return nds.map((n) => { - if (!n.parentNode) { - n.position = { - x: Math.random() * 400, - y: Math.random() * 400, + if (!n.parentId) { + return { + ...n, + position: { + x: Math.random() * 400, + y: Math.random() * 400, + }, }; } @@ -176,8 +184,10 @@ const Subflow = () => { const toggleClassnames = () => { setNodes((nds) => { return nds.map((n) => { - n.className = n.className === 'light' ? 'dark' : 'light'; - return n; + return { + ...n, + className: n.className === 'light' ? 'dark' : 'light', + }; }); }); }; @@ -185,8 +195,10 @@ const Subflow = () => { const toggleChildNodes = () => { setNodes((nds) => { return nds.map((n) => { - n.hidden = !!n.parentNode && !n.hidden; - return n; + return { + ...n, + hidden: !!n.parentId && !n.hidden, + }; }); }); }; @@ -207,28 +219,27 @@ const Subflow = () => { onlyRenderVisibleElements={false} nodeTypes={nodeTypes} fitView + nodeOrigin={[0, 0]} > - - - - + + + + + +
); }; -export default Subflow; +export default () => ( + + + +); diff --git a/examples/react/src/examples/TouchDevice/index.tsx b/examples/react/src/examples/TouchDevice/index.tsx index 636a36a4..e412bee5 100644 --- a/examples/react/src/examples/TouchDevice/index.tsx +++ b/examples/react/src/examples/TouchDevice/index.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { ReactFlow, Node, Edge, useNodesState, useEdgesState, Position, Connection, addEdge } from '@xyflow/react'; -import styles from './touch-device.module.css'; +import './touch-device.css'; const initialNodes: Node[] = [ { @@ -42,7 +42,7 @@ const TouchDeviceFlow = () => { onConnectEnd={onConnectEnd} onClickConnectStart={onClickConnectStart} onClickConnectEnd={onClickConnectEnd} - className={styles.flow} + className="touch-flow" /> ); }; diff --git a/examples/react/src/examples/TouchDevice/touch-device.css b/examples/react/src/examples/TouchDevice/touch-device.css new file mode 100644 index 00000000..92f85c27 --- /dev/null +++ b/examples/react/src/examples/TouchDevice/touch-device.css @@ -0,0 +1,27 @@ +.react-flow.touch-flow .react-flow__handle { + width: 20px; + height: 20px; + border-radius: 3px; + background-color: #9f7aea; +} + +.touch-flow .react-flow__handle-right { + --translate: translate(50%, -50%); +} + +.touch-flow .react-flow__handle-left { + --translate: translate(-50%, -50%); +} + +@keyframes bounce { + 0% { + transform: var(--translate) scale(1); + } + 50% { + transform: var(--translate) scale(1.1); + } +} + +.react-flow.touch-flow .react-flow__handle.clickconnecting { + animation: bounce 1600ms infinite ease-in; +} diff --git a/examples/react/src/examples/TouchDevice/touch-device.module.css b/examples/react/src/examples/TouchDevice/touch-device.module.css deleted file mode 100644 index b5f65209..00000000 --- a/examples/react/src/examples/TouchDevice/touch-device.module.css +++ /dev/null @@ -1,19 +0,0 @@ -.flow :global .react-flow__handle { - width: 20px; - height: 20px; - border-radius: 3px; - background-color: #9f7aea; -} - -.flow :global .react-flow__handle.connecting { - animation: bounce 1600ms infinite ease-out; -} - -@keyframes bounce { - 0% { - transform: translate(0, -50%) scale(1); - } - 50% { - transform: translate(0, -50%) scale(1.1); - } -} diff --git a/examples/react/src/examples/Undirectional/index.tsx b/examples/react/src/examples/Undirectional/index.tsx index d9391541..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: {}, }, ]; @@ -183,11 +183,11 @@ const getId = () => `${id++}`; const UpdateNodeInternalsFlow = () => { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - const { project } = useReactFlow(); + 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)), [] ); @@ -196,12 +196,12 @@ const UpdateNodeInternalsFlow = () => { setNodes((nds) => nds.concat({ id: getId(), - position: project({ x: evt.clientX, y: evt.clientY - 40 }), + position: screenToFlowPosition({ x: evt.clientX, y: evt.clientY }), type: 'custom', data: null, }) ), - [project, setNodes] + [screenToFlowPosition, setNodes] ); return ( @@ -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/UpdateNode/index.tsx b/examples/react/src/examples/UpdateNode/index.tsx index 7c320606..c32fca34 100644 --- a/examples/react/src/examples/UpdateNode/index.tsx +++ b/examples/react/src/examples/UpdateNode/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { ReactFlow, Node, Edge, useNodesState, useEdgesState } from '@xyflow/react'; +import { ReactFlow, Node, Edge, useNodesState, useEdgesState, ReactFlowProvider, useReactFlow } from '@xyflow/react'; import styles from './updatenode.module.css'; @@ -13,6 +13,7 @@ const initialEdges: Edge[] = [{ id: 'e1-2', source: '1', target: '2' }]; const UpdateNode = () => { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const { updateNode } = useReactFlow(); const [nodeName, setNodeName] = useState('Node 1'); const [nodeBg, setNodeBg] = useState('#eee'); @@ -23,9 +24,12 @@ const UpdateNode = () => { nds.map((n) => { if (n.id === '1') { // it's important that you create a new object here in order to notify react flow about the change - n.data = { - ...n.data, - label: nodeName, + return { + ...n, + data: { + ...n.data, + label: nodeName, + }, }; } @@ -39,7 +43,10 @@ const UpdateNode = () => { nds.map((n) => { if (n.id === '1') { // it's important that you create a new object here in order to notify react flow about the change - n.style = { ...n.style, backgroundColor: nodeBg }; + return { + ...n, + style: { ...n.style, backgroundColor: nodeBg }, + }; } return n; @@ -51,8 +58,10 @@ const UpdateNode = () => { setNodes((nds) => nds.map((n) => { if (n.id === '1' || n.id === 'e1-2') { - // when you update a simple type you can just update the value - n.hidden = nodeHidden; + return { + ...n, + hidden: nodeHidden, + }; } return n; @@ -80,9 +89,19 @@ const UpdateNode = () => { setNodeHidden(evt.target.checked)} />
+ +
); }; -export default UpdateNode; +export default () => ( + + + +); diff --git a/examples/react/src/examples/UseConnection/index.tsx b/examples/react/src/examples/UseConnection/index.tsx new file mode 100644 index 00000000..cc90f11f --- /dev/null +++ b/examples/react/src/examples/UseConnection/index.tsx @@ -0,0 +1,75 @@ +import { useCallback, useEffect } from 'react'; +import { + ReactFlow, + Background, + MiniMap, + Node, + addEdge, + ReactFlowProvider, + Edge, + useNodesState, + useEdgesState, + OnConnect, + useConnection, +} from '@xyflow/react'; + +const initialNodes: Node[] = [ + { + id: '1', + type: 'input', + data: { label: 'Node 1' }, + position: { x: 250, y: 5 }, + className: 'light', + }, + { + id: '2', + data: { label: 'Node 2' }, + position: { x: 100, y: 100 }, + className: 'light', + }, + { + id: '3', + data: { label: 'Node 3' }, + position: { x: 400, y: 100 }, + className: 'light', + }, +]; + +const initialEdges: Edge[] = [ + { id: 'e1-2', source: '1', target: '2' }, + { id: 'e1-3', source: '1', target: '3' }, +]; + +const UseZoomPanHelperFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const onConnect: OnConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []); + const connection = useConnection(); + + useEffect(() => { + console.log('connection', connection); + }, [connection]); + + return ( + + + + + ); +}; + +const WrappedFlow = () => ( + + + +); + +export default WrappedFlow; diff --git a/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx new file mode 100644 index 00000000..4b8fd2c1 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnections/MultiHandleNode.tsx @@ -0,0 +1,39 @@ +import { memo, FC, useEffect, useCallback } from 'react'; +import { Handle, Position, NodeProps, useHandleConnections, Connection, HandleProps } from '@xyflow/react'; + +function CustomHandle({ nodeId, ...handleProps }: HandleProps & { nodeId: string }) { + const onConnect = useCallback( + (connections: Connection[]) => console.log('onConnect handler, node id:', nodeId, connections), + [nodeId] + ); + + const onDisconnect = useCallback( + (connections: Connection[]) => console.log('onDisconnect handler, node id:', nodeId, connections), + [nodeId] + ); + const connections = useHandleConnections({ + type: handleProps.type, + id: handleProps.id, + onConnect, + onDisconnect, + }); + + useEffect(() => { + console.log('useEffect, node id:', nodeId, handleProps.type, connections); + }, [connections]); + + return ; +} + +const CustomNode: FC = ({ id }) => { + return ( +
+ +
node {id}
+ + +
+ ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx new file mode 100644 index 00000000..1c13dcb0 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnections/SingleHandleNode.tsx @@ -0,0 +1,41 @@ +import { memo, FC, useEffect, useCallback } from 'react'; +import { Handle, Position, NodeProps, useHandleConnections, Connection, HandleProps } from '@xyflow/react'; + +function CustomHandle({ nodeId, ...handleProps }: HandleProps & { nodeId: string }) { + const onConnect = useCallback( + (connections: Connection[]) => { + console.log('onConnect handler, node id:', nodeId, connections); + }, + [nodeId] + ); + const onDisconnect = useCallback( + (connections: Connection[]) => { + console.log('onDisconnect handler, node id:', nodeId, connections); + }, + [nodeId] + ); + const connections = useHandleConnections({ + type: handleProps.type, + id: handleProps.id, + onConnect, + onDisconnect, + }); + + useEffect(() => { + console.log('useEffect, node id:', nodeId, handleProps.type, connections); + }, [connections]); + + return ; +} + +const CustomNode: FC = ({ id }) => { + return ( +
+ +
node {id}
+ +
+ ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/examples/UseHandleConnections/index.tsx b/examples/react/src/examples/UseHandleConnections/index.tsx new file mode 100644 index 00000000..c6aa4a68 --- /dev/null +++ b/examples/react/src/examples/UseHandleConnections/index.tsx @@ -0,0 +1,118 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + MiniMap, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, +} from '@xyflow/react'; + +import MultiHandleNode from './MultiHandleNode'; +import SingleHandleNode from './SingleHandleNode'; + +const nodeTypes = { + multi: MultiHandleNode, + single: SingleHandleNode, +}; + +const initNodes = [ + { + id: '1', + type: 'single', + data: {}, + position: { x: 0, y: 0 }, + }, + { + id: '2', + type: 'single', + data: {}, + position: { x: 200, y: -100 }, + }, + { + id: '3', + type: 'single', + data: {}, + position: { x: 200, y: 100 }, + }, + + { + id: '4', + type: 'multi', + data: {}, + position: { x: 400, y: 0 }, + }, + { + id: '5', + type: 'multi', + data: {}, + position: { x: 600, y: -100 }, + }, + { + id: '6', + type: 'multi', + data: {}, + position: { x: 600, y: 100 }, + }, +]; + +const initEdges = [ + { + id: 'e1-2', + source: '1', + target: '2', + }, + { + id: 'e1-3', + source: '1', + target: '3', + }, + + { + id: 'e4a-5', + source: '4', + sourceHandle: 'a', + target: '5', + }, + { + id: 'e4b-5', + source: '4', + sourceHandle: 'b', + target: '6', + }, +]; + +const defaultEdgeOptions = { + animated: true, +}; + +const CustomNodeFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + return ( + + + + + + ); +}; + +export default CustomNodeFlow; 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/UseNodesData/ResultNode.tsx b/examples/react/src/examples/UseNodesData/ResultNode.tsx new file mode 100644 index 00000000..bf8f7a35 --- /dev/null +++ b/examples/react/src/examples/UseNodesData/ResultNode.tsx @@ -0,0 +1,20 @@ +import { memo } from 'react'; +import { Handle, Position, useHandleConnections, useNodesData } from '@xyflow/react'; +import { isTextNode, type MyNode } from '.'; + +function ResultNode() { + const connections = useHandleConnections({ + type: 'target', + }); + const nodesData = useNodesData(connections.map((connection) => connection.source)); + const textNodes = nodesData.filter(isTextNode); + + return ( +
+ +
incoming texts: {textNodes.map(({ data }, i) =>
{data.text}
) || 'none'}
+
+ ); +} + +export default memo(ResultNode); diff --git a/examples/react/src/examples/UseNodesData/TextNode.tsx b/examples/react/src/examples/UseNodesData/TextNode.tsx new file mode 100644 index 00000000..dd9f25d1 --- /dev/null +++ b/examples/react/src/examples/UseNodesData/TextNode.tsx @@ -0,0 +1,33 @@ +import { memo, useState } from 'react'; +import { Position, NodeProps, Handle, useReactFlow } from '@xyflow/react'; + +function TextNode({ id, data }: NodeProps) { + const { updateNodeData } = useReactFlow(); + const [text, setText] = useState(data.text); + const updateText = (text: string) => { + // avoid jumping caret with a synchronous update + setText(text); + // update actual node data + updateNodeData(id, { text }); + }; + + return ( +
+
node {id}
+
+ + updateText(evt.target.value)} value={text} style={{ display: 'block' }} /> + + + updateNodeData(id, { text: evt.target.value })} + value={data.text} + style={{ display: 'block' }} + /> +
+ +
+ ); +} + +export default memo(TextNode); diff --git a/examples/react/src/examples/UseNodesData/UppercaseNode.tsx b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx new file mode 100644 index 00000000..70c6079d --- /dev/null +++ b/examples/react/src/examples/UseNodesData/UppercaseNode.tsx @@ -0,0 +1,26 @@ +import { memo, useEffect } from 'react'; +import { Position, NodeProps, useReactFlow, Handle, useHandleConnections, useNodesData } from '@xyflow/react'; +import { isTextNode, type TextNode, type MyNode } from '.'; + +function UppercaseNode({ id }: NodeProps) { + const { updateNodeData } = useReactFlow(); + const connections = useHandleConnections({ + type: 'target', + }); + const nodesData = useNodesData(connections[0]?.source); + const textNode = isTextNode(nodesData) ? nodesData : null; + + useEffect(() => { + updateNodeData(id, { text: textNode?.data.text.toUpperCase() }); + }, [textNode]); + + return ( +
+ +
uppercase transform
+ +
+ ); +} + +export default memo(UppercaseNode); diff --git a/examples/react/src/examples/UseNodesData/index.tsx b/examples/react/src/examples/UseNodesData/index.tsx new file mode 100644 index 00000000..dabc1d4d --- /dev/null +++ b/examples/react/src/examples/UseNodesData/index.tsx @@ -0,0 +1,126 @@ +import { useCallback } from 'react'; +import { + ReactFlow, + Controls, + addEdge, + Connection, + useNodesState, + useEdgesState, + Background, + Node, + Edge, +} from '@xyflow/react'; + +import TextNode from './TextNode'; +import ResultNode from './ResultNode'; +import UppercaseNode from './UppercaseNode'; + +export type TextNode = Node<{ text: string }, 'text'>; +export type ResultNode = Node<{}, 'result'>; +export type UppercaseNode = Node<{ text: string }, 'uppercase'>; +export type MyNode = TextNode | ResultNode | UppercaseNode; + +export function isTextNode(node: any): node is TextNode | UppercaseNode { + return node.type === 'text' || node.type === 'uppercase'; +} + +const nodeTypes = { + text: TextNode, + result: ResultNode, + uppercase: UppercaseNode, +}; + +const initNodes: MyNode[] = [ + { + id: '1', + type: 'text', + data: { + text: 'hello', + }, + position: { x: -100, y: -50 }, + }, + { + id: '1a', + type: 'uppercase', + data: { text: '' }, + position: { x: 100, y: 0 }, + }, + { + id: '1b', + type: 'uppercase', + data: { text: '' }, + position: { x: 100, y: -100 }, + }, + { + id: '2', + type: 'text', + data: { + text: 'world', + }, + position: { x: 0, y: 100 }, + }, + { + id: '3a', + type: 'result', + data: {}, + position: { x: 300, y: -75 }, + }, + { + id: '3b', + type: 'result', + data: {}, + position: { x: 300, y: 50 }, + }, +]; + +const initEdges: Edge[] = [ + { + id: 'e1-1a', + source: '1', + target: '1a', + }, + { + id: 'e1a-3a', + source: '1b', + target: '3a', + }, + { + id: 'e1-1b', + source: '1', + target: '1b', + }, + { + id: 'e1a-3b', + source: '1a', + target: '3b', + }, + { + id: 'e2-3b', + source: '2', + target: '3b', + }, +]; + +const CustomNodeFlow = () => { + const [nodes, , onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + + const onConnect = useCallback((connection: Connection) => setEdges((eds) => addEdge(connection, eds)), [setEdges]); + + return ( + + + + + ); +}; + +export default CustomNodeFlow; diff --git a/examples/react/src/examples/UseNodesInit/index.tsx b/examples/react/src/examples/UseNodesInit/index.tsx index 549315ca..d4932ae1 100644 --- a/examples/react/src/examples/UseNodesInit/index.tsx +++ b/examples/react/src/examples/UseNodesInit/index.tsx @@ -11,6 +11,8 @@ import { useEdgesState, OnConnect, useNodesInitialized, + Panel, + useReactFlow, } from '@xyflow/react'; const initialNodes: Node[] = [ @@ -49,6 +51,7 @@ const initialEdges: Edge[] = [ ]; const UseZoomPanHelperFlow = () => { + const { addNodes } = useReactFlow(); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -59,6 +62,13 @@ const UseZoomPanHelperFlow = () => { console.log('initialized', initialized); }, [initialized]); + const addNode = () => + addNodes({ + id: `${Math.random()}`, + data: { label: 'new node' }, + position: { x: Math.random() * 400, y: Math.random() * 400 }, + }); + return ( { > + + + ); }; diff --git a/examples/react/src/examples/UseOnSelectionChange/index.tsx b/examples/react/src/examples/UseOnSelectionChange/index.tsx index a9f91adf..b9795951 100644 --- a/examples/react/src/examples/UseOnSelectionChange/index.tsx +++ b/examples/react/src/examples/UseOnSelectionChange/index.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { ReactFlow, addEdge, @@ -10,6 +10,7 @@ import { useEdgesState, useOnSelectionChange, OnSelectionChangeParams, + OnSelectionChangeFunc, } from '@xyflow/react'; const initialNodes: Node[] = [ @@ -35,9 +36,9 @@ const initialEdges: Edge[] = [ }, ]; -const SelectionLogger = () => { +const SelectionLogger = ({ id }: { id: string }) => { const onChange = useCallback(({ nodes, edges }: OnSelectionChangeParams) => { - console.log(nodes, edges); + console.log(id, nodes, edges); }, []); useOnSelectionChange({ @@ -63,14 +64,23 @@ const Flow = () => { ); }; -const WrappedFlow = () => ( - - - -
- -
-
-); +const WrappedFlow = () => { + const [secondLoggerActive, setSecondLoggerActive] = useState(true); + + const toggleSecondLogger = () => { + setSecondLoggerActive(!secondLoggerActive); + }; + + return ( + + + + {secondLoggerActive && } +
+ +
+
+ ); +}; export default WrappedFlow; diff --git a/examples/react/src/examples/UseReactFlow/index.tsx b/examples/react/src/examples/UseReactFlow/index.tsx index 584e6e7b..2177d0cc 100644 --- a/examples/react/src/examples/UseReactFlow/index.tsx +++ b/examples/react/src/examples/UseReactFlow/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, MouseEvent, useEffect } from 'react'; +import { useCallback, MouseEvent, useEffect, useRef } from 'react'; import { ReactFlow, Background, @@ -27,18 +27,21 @@ const initialNodes: Node[] = [ data: { label: 'Node 2' }, position: { x: 100, y: 100 }, className: 'light', + type: 'default', }, { id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 }, className: 'light', + type: 'default', }, { id: '4', data: { label: 'Node 4' }, position: { x: 400, y: 200 }, className: 'light', + type: 'default', }, ]; @@ -57,7 +60,7 @@ const UseZoomPanHelperFlow = () => { const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const onConnect = (params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)); const { - project, + screenToFlowPosition, setCenter, zoomIn, zoomOut, @@ -68,13 +71,14 @@ const UseZoomPanHelperFlow = () => { getNodes, getEdges, deleteElements, + updateNodeData, } = useReactFlow(); const onPaneClick = useCallback( (evt: MouseEvent) => { - const projectedPosition = project({ + const projectedPosition = screenToFlowPosition({ x: evt.clientX, - y: evt.clientY - 40, + y: evt.clientY, }); setNodes((nds) => @@ -84,10 +88,11 @@ const UseZoomPanHelperFlow = () => { data: { label: `${projectedPosition.x}-${projectedPosition.y}`, }, + type: 'default', }) ); }, - [project, setNodes] + [screenToFlowPosition, setNodes] ); const onNodeClick = useCallback( @@ -105,6 +110,7 @@ const UseZoomPanHelperFlow = () => { data: { label: 'New Node', }, + type: 'default', }; addNodes(newNode); @@ -125,12 +131,31 @@ const UseZoomPanHelperFlow = () => { deleteElements({ nodes: [{ id: '2' }], edges: [{ id: 'e1-3' }] }); }, []); + const edgeAdded = useRef(false); + useEffect(() => { - addEdges({ id: 'e3-4', source: '3', target: '4' }); + if (!edgeAdded.current) { + addEdges({ id: 'e3-4', source: '3', target: '4' }); + edgeAdded.current = true; + } }, [addEdges]); const onResetNodes = useCallback(() => setNodesHook(initialNodes), [setNodesHook]); + const onSetNodes = () => { + setNodes([ + { id: 'a', type: 'default', position: { x: 0, y: 0 }, data: { label: 'Node a' } }, + { id: 'b', type: 'default', position: { x: 0, y: 150 }, data: { label: 'Node b' } }, + ]); + + setEdges([{ id: 'a-b', source: 'a', target: 'b' }]); + }; + + const onUpdateNode = () => { + updateNodeData('1', { label: 'update' }); + updateNodeData('2', { label: 'update' }); + }; + return ( { + + diff --git a/examples/react/src/examples/UseUpdateNodeInternals/index.tsx b/examples/react/src/examples/UseUpdateNodeInternals/index.tsx index 7de6cb9b..2cd405b7 100644 --- a/examples/react/src/examples/UseUpdateNodeInternals/index.tsx +++ b/examples/react/src/examples/UseUpdateNodeInternals/index.tsx @@ -33,23 +33,23 @@ const getId = (): string => `${id++}`; const UpdateNodeInternalsFlow = () => { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnect = useCallback((params: Edge | Connection) => setEdges((els) => addEdge(params, els)), [setEdges]); - const { project } = useReactFlow(); + const { screenToFlowPosition } = useReactFlow(); const onPaneClick = useCallback( (evt: MouseEvent) => setNodes((nds) => nds.concat({ id: getId(), - position: project({ x: evt.clientX, y: evt.clientY - 40 }), + position: screenToFlowPosition({ x: evt.clientX, y: evt.clientY }), data: { label: 'new node' }, targetPosition: Position.Left, sourcePosition: Position.Right, }) ), - [project, setNodes] + [screenToFlowPosition, setNodes] ); return ( diff --git a/examples/react/src/examples/Validation/index.tsx b/examples/react/src/examples/Validation/index.tsx index 864c3c08..25ed4eff 100644 --- a/examples/react/src/examples/Validation/index.tsx +++ b/examples/react/src/examples/Validation/index.tsx @@ -13,9 +13,10 @@ import { OnConnectStart, OnConnectEnd, OnConnect, - updateEdge, + reconnectEdge, Edge, IsValidConnection, + OnBeforeDelete, } from '@xyflow/react'; import ConnectionStatus from './ConnectionStatus'; @@ -23,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'; @@ -40,7 +41,7 @@ const CustomInput: FC = () => ( const CustomNode: FC = ({ id }) => ( <> - +
{id}
@@ -54,7 +55,7 @@ const nodeTypes: NodeTypes = { const ValidationFlow = () => { const [value, setValue] = useState(0); const [nodes, , onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const onConnectStart: OnConnectStart = useCallback( (event, params) => { @@ -80,11 +81,15 @@ 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] ); + const onBeforeDelete: OnBeforeDelete = useCallback(async () => { + return true; + }, []); + return ( { nodeTypes={nodeTypes} onConnectStart={onConnectStart} onConnectEnd={onConnectEnd} - onEdgeUpdate={onEdgeUpdate} + onReconnect={onReconnect} isValidConnection={isValidConnection} + onBeforeDelete={onBeforeDelete} fitView > diff --git a/examples/react/src/examples/Validation/validation.module.css b/examples/react/src/examples/Validation/validation.module.css index c19a8723..3fbe2f94 100644 --- a/examples/react/src/examples/Validation/validation.module.css +++ b/examples/react/src/examples/Validation/validation.module.css @@ -24,10 +24,14 @@ background: #fff; } -.validationflow :global .connecting { +.validationflow :global .connectingto { background: #ff6060; } +.validationflow :global .react-flow__node-custominput .connectingfrom { + background: #55dd99; +} + .validationflow :global .valid { background: #55dd99; } diff --git a/examples/react/src/generic-tests/Flow.tsx b/examples/react/src/generic-tests/Flow.tsx new file mode 100644 index 00000000..a6db5f5d --- /dev/null +++ b/examples/react/src/generic-tests/Flow.tsx @@ -0,0 +1,39 @@ +import { useState, useCallback } from 'react'; +import { + ReactFlow, + applyNodeChanges, + applyEdgeChanges, + addEdge, + OnNodesChange, + OnEdgesChange, + OnConnect, + Controls, + Panel, + MiniMap, + Background, +} from '@xyflow/react'; + +type FlowProps = { + flowConfig: FlowConfig; +}; + +export default ({ flowConfig }: FlowProps) => { + const [nodes, setNodes] = useState(flowConfig.flowProps?.nodes); + const [edges, setEdges] = useState(flowConfig.flowProps?.edges); + const props = { ...flowConfig.flowProps, nodes, edges }; + + const onNodesChange: OnNodesChange = useCallback((changes) => setNodes((nds) => applyNodeChanges(changes, nds)), []); + const onEdgesChange: OnEdgesChange = useCallback((changes) => setEdges((eds) => applyEdgeChanges(changes, eds)), []); + const onConnect: OnConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []); + + return ( +
+ + {flowConfig.controlsProps && } + {flowConfig.panelProps && } + {flowConfig.minimapProps && } + {flowConfig.backgroundProps && } + +
+ ); +}; diff --git a/examples/react/src/generic-tests/edges/general.ts b/examples/react/src/generic-tests/edges/general.ts new file mode 100644 index 00000000..3daceec8 --- /dev/null +++ b/examples/react/src/generic-tests/edges/general.ts @@ -0,0 +1,164 @@ +import { MarkerType } from '@xyflow/react'; + +export default { + flowProps: { + fitView: true, + multiSelectionKeyCode: 's', + nodes: [ + { + id: '1', + data: { label: '1' }, + position: { x: 0, y: 0 }, + type: 'input', + }, + { + id: '2', + data: { label: '2' }, + position: { x: -100, y: 100 }, + }, + { + id: '3', + data: { label: '3' }, + position: { x: 100, y: 100 }, + }, + { + id: '4', + data: { label: '4' }, + position: { x: -100, y: 200 }, + }, + { + id: '5', + data: { label: '5' }, + position: { x: 100, y: 200 }, + }, + { + id: '6', + data: { label: '6' }, + position: { x: -100, y: 300 }, + }, + { + id: '7', + data: { label: '7' }, + position: { x: 100, y: 300 }, + }, + { + id: '8', + data: { label: '8' }, + position: { x: -100, y: 400 }, + }, + { + id: '9', + data: { label: '9' }, + position: { x: 100, y: 400 }, + }, + { + id: '10', + data: { label: '10' }, + position: { x: -100, y: 500 }, + }, + { + id: '11', + data: { label: '11' }, + position: { x: 100, y: 500 }, + }, + // { + // id: '12', + // data: { label: '12' }, + // position: { x: -100, y: 600 } + // }, + // { + // id: '13', + // data: { label: '13' }, + // position: { x: 100, y: 600 } + // } + ], + edges: [ + { + id: 'edge-with-class', + source: '1', + target: '2', + className: 'edge-class-test', + }, + { + id: 'edge-with-style', + source: '1', + target: '3', + style: { + stroke: 'red', + }, + }, + { + id: 'hidden-edge', + source: '2', + target: '4', + label: 'hidden', + hidden: true, + }, + { + id: 'animated-edge', + source: '3', + target: '5', + label: 'animated', + animated: true, + }, + { + id: 'not-selectable-edge', + source: '4', + target: '6', + label: 'not-selectable', + selectable: false, + }, + { + id: 'not-deletable', + source: '5', + target: '7', + label: 'not-deletable', + deletable: false, + }, + { + id: 'z-index', + source: '6', + target: '8', + label: 'z-index', + zIndex: 3141592, + }, + { + id: 'aria-label', + source: '7', + target: '9', + label: 'aria-label', + ariaLabel: 'aria-label-test', + }, + { + id: 'interaction-width', + source: '8', + target: '10', + label: 'interaction-width', + interactionWidth: 42, + }, + { + id: 'markers', + source: '9', + target: '11', + label: 'markers', + markerEnd: { type: MarkerType.Arrow }, + markerStart: { type: MarkerType.ArrowClosed }, + }, + // { + // id: 'updatable', + // source: '9', + // target: '11', + // label: 'focusable', + // reconnectable: true + // }, + // { + // id: 'not-focusable', + // + // source: '5', + // target: '7', + // label: 'not-focusable', + // focusable: false + // }, + ], + }, +} satisfies FlowConfig; diff --git a/examples/react/src/generic-tests/index.tsx b/examples/react/src/generic-tests/index.tsx new file mode 100644 index 00000000..1a595581 --- /dev/null +++ b/examples/react/src/generic-tests/index.tsx @@ -0,0 +1,17 @@ +import { useLocation } from 'react-router-dom'; + +import Flow from './Flow'; + +const flowConfigs = import.meta.glob('./**/*.ts', { eager: true, import: 'default' }); + +export default () => { + const location = useLocation(); + const path = `.${location.pathname.replace('/tests/generic', '')}.ts`; + const flowConfig = flowConfigs[path]; + + if (!flowConfig) { + return `404: This route doesn't exists.`; + } + + return ; +}; diff --git a/examples/react/src/generic-tests/node-toolbar/components/ToolbarNode.tsx b/examples/react/src/generic-tests/node-toolbar/components/ToolbarNode.tsx new file mode 100644 index 00000000..30384445 --- /dev/null +++ b/examples/react/src/generic-tests/node-toolbar/components/ToolbarNode.tsx @@ -0,0 +1,19 @@ +import { memo, FC } from 'react'; +import { Handle, Position, NodeProps, NodeToolbar } from '@xyflow/react'; + +const CustomNode: FC = ({ id, data }) => { + return ( + <> + + + + + +
{data.label}
+ + + + ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/generic-tests/node-toolbar/general.ts b/examples/react/src/generic-tests/node-toolbar/general.ts new file mode 100644 index 00000000..d7df3fa9 --- /dev/null +++ b/examples/react/src/generic-tests/node-toolbar/general.ts @@ -0,0 +1,50 @@ +import { Position, type Node } from '@xyflow/react'; +import ToolbarNode from './components/ToolbarNode'; + +const positions = ['top', 'right', 'bottom', 'left']; +const alignments = ['start', 'center', 'end']; + +const nodes: Node[] = [ + { + id: 'default-node', + type: 'ToolbarNode', + data: { label: 'toolbar top', toolbarPosition: Position.Top }, + position: { x: 0, y: -200 }, + className: 'react-flow__node-default', + }, +]; + +positions.forEach((position, posIndex) => { + alignments.forEach((align, alignIndex) => { + const id = `node-${align}-${position}`; + nodes.push({ + id, + type: 'ToolbarNode', + data: { + label: `toolbar ${position} ${align}`, + toolbarPosition: position as Position, + toolbarAlign: align, + toolbarVisible: true, + }, + className: 'react-flow__node-default', + position: { x: posIndex * 300, y: alignIndex * 100 }, + }); + }); +}); + +export default { + flowProps: { + fitView: true, + nodeTypes: { + ToolbarNode, + }, + nodes, + edges: [ + { + id: 'first-edge', + source: 'default-node', + target: 'node-start-top', + }, + ], + }, +} satisfies FlowConfig; diff --git a/examples/react/src/generic-tests/nodes/components/DragHandleNode.tsx b/examples/react/src/generic-tests/nodes/components/DragHandleNode.tsx new file mode 100644 index 00000000..48037f61 --- /dev/null +++ b/examples/react/src/generic-tests/nodes/components/DragHandleNode.tsx @@ -0,0 +1,25 @@ +export default () => { + return ( +
+
+
+ ); +}; diff --git a/examples/react/src/generic-tests/nodes/general.ts b/examples/react/src/generic-tests/nodes/general.ts new file mode 100644 index 00000000..d45091dd --- /dev/null +++ b/examples/react/src/generic-tests/nodes/general.ts @@ -0,0 +1,92 @@ +import DragHandleNode from './components/DragHandleNode'; + +export default { + flowProps: { + fitView: true, + nodeTypes: { + DragHandleNode, + }, + nodeDragThreshold: 0, + nodes: [ + { + id: 'Node-1', + data: { label: 'Node-1' }, + position: { x: 0, y: 0 }, + type: 'input', + className: 'playwright-test-class-123', + style: { backgroundColor: 'red' }, + }, + { + id: 'Node-2', + type: 'output', + data: { label: 'Node-2' }, + position: { x: -100, y: 100 }, + }, + { + id: 'Node-3', + data: { label: 'Node-3' }, + position: { x: 100, y: 100 }, + }, + { + id: 'Node-4', + data: { label: 'Node-4' }, + position: { x: 0, y: 200 }, + type: 'output', + }, + { + id: 'drag-handle', + data: { label: 'Drag Handle' }, + position: { x: 200, y: 0 }, + type: 'DragHandleNode', + dragHandle: '.custom-drag-handle', + }, + { + id: 'notConnectable', + type: 'output', + data: { label: 'notConnectable' }, + position: { x: 0, y: 300 }, + connectable: false, + }, + { + id: 'notDraggable', + data: { label: 'notDraggable' }, + position: { x: 0, y: 400 }, + draggable: false, + }, + { + id: 'notSelectable', + data: { label: 'notSelectable' }, + position: { x: 0, y: 500 }, + selectable: false, + }, + { + id: 'notDeletable', + data: { label: 'notDeletable' }, + position: { x: 0, y: 600 }, + deletable: false, + }, + { + id: 'hidden', + data: { label: 'hidden' }, + position: { x: 0, y: 700 }, + hidden: true, + }, + ], + edges: [ + { + id: '1-2', + type: 'default', + source: 'Node-1', + target: 'Node-2', + label: 'edge', + }, + { + id: '1-3', + type: 'default', + source: 'Node-1', + target: 'Node-3', + label: 'edge', + }, + ], + }, +} satisfies FlowConfig; diff --git a/examples/react/src/generic-tests/pane/general.ts b/examples/react/src/generic-tests/pane/general.ts new file mode 100644 index 00000000..54c1d193 --- /dev/null +++ b/examples/react/src/generic-tests/pane/general.ts @@ -0,0 +1,37 @@ +export default { + flowProps: { + minZoom: 0.25, + maxZoom: 4, + fitView: true, + nodes: [ + { + id: '1', + data: { label: '1' }, + position: { x: 0, y: 0 }, + type: 'input', + }, + { + id: '2', + data: { label: '2' }, + position: { x: -100, y: 100 }, + }, + { + id: '3', + data: { label: '3' }, + position: { x: 100, y: 100 }, + }, + ], + edges: [ + { + id: 'first-edge', + source: '1', + target: '2', + }, + { + id: 'second-edge', + source: '1', + target: '3', + }, + ], + }, +} satisfies FlowConfig; diff --git a/examples/react/src/generic-tests/pane/non-defaults.ts b/examples/react/src/generic-tests/pane/non-defaults.ts new file mode 100644 index 00000000..1da85cdd --- /dev/null +++ b/examples/react/src/generic-tests/pane/non-defaults.ts @@ -0,0 +1,38 @@ +export default { + flowProps: { + panOnScroll: true, + defaultViewport: { x: 1.23, y: 9.87, zoom: 1.234 }, + autoPanOnConnect: false, + autoPanOnNodeDrag: false, + nodes: [ + { + id: '1', + data: { label: '1' }, + position: { x: 0, y: 0 }, + type: 'input', + }, + { + id: '2', + data: { label: '2' }, + position: { x: -100, y: 100 }, + }, + { + id: '3', + data: { label: '3' }, + position: { x: 100, y: 100 }, + }, + ], + edges: [ + { + id: 'first-edge', + source: '1', + target: '2', + }, + { + id: 'second-edge', + source: '1', + target: '3', + }, + ], + }, +} satisfies FlowConfig; diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index 34599efc..4fdcd68a 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -1,9 +1,10 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import App from './App'; -import './index.css'; +import App from './App/index'; + import '@xyflow/react/dist/style.css'; +import './index.css'; createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/examples/react/tsconfig.json b/examples/react/tsconfig.json index 7156a499..0519e0a8 100644 --- a/examples/react/tsconfig.json +++ b/examples/react/tsconfig.json @@ -15,7 +15,7 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "types": ["cypress", "cypress-real-events"] + "types": ["cypress", "node", "cypress-real-events"] }, "include": ["**/*.ts", "**/*.tsx"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/examples/svelte/README.md b/examples/svelte/README.md index 72cd559c..b596832d 100644 --- a/examples/svelte/README.md +++ b/examples/svelte/README.md @@ -1,4 +1,4 @@ -# Svelte Flow examples +# Svelte Flow examples This Vite app is used internally to test the library. @@ -8,3 +8,13 @@ This Vite app is used internally to test the library. pnpm dev ``` +## Adding new example + +Development of the library is done against `src/routes/examples`. Feel free to add new implementations for features that you develop. + +1. Create a new folder & flow at `src/routes/examples/` +2. Register the new route in `src/components/Header/Header.svelte` + +## Adding new E2E implementation + +E2E testing is done against the flows implemented in `src/generic-tests`. Adding a new configuration file automatically adds a new route under [`http://localhost:5173/tests/generic/$foldername/$filename`](http://localhost:5173/tests/generic/nodes/general). For further documentation visit [`xyflow/tests/playwright`](/tests/playwright). diff --git a/examples/svelte/package.json b/examples/svelte/package.json index 8cd6a14e..e8c221ae 100644 --- a/examples/svelte/package.json +++ b/examples/svelte/package.json @@ -1,5 +1,5 @@ { - "name": "svelte", + "name": "svelte-examples", "version": "0.0.1", "private": true, "scripts": { @@ -12,24 +12,25 @@ "format": "prettier --plugin-search-dir . --write ." }, "devDependencies": { - "@sveltejs/adapter-auto": "^2.0.0", - "@sveltejs/kit": "^1.20.4", - "@typescript-eslint/eslint-plugin": "^5.45.0", - "@typescript-eslint/parser": "^5.45.0", - "eslint": "^8.28.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-svelte": "^2.30.0", - "prettier": "^2.8.0", - "prettier-plugin-svelte": "^2.10.1", - "svelte": "^4.0.5", - "svelte-check": "^3.4.3", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^4.4.2" + "@sveltejs/adapter-auto": "^3.2.1", + "@sveltejs/kit": "^2.5.10", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "eslint": "^8.53.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-svelte": "^2.35.0", + "prettier": "^3.0.3", + "prettier-plugin-svelte": "^3.0.3", + "svelte": "^4.2.12", + "svelte-check": "^3.6.6", + "tslib": "^2.6.2", + "typescript": "^5.2.2", + "vite": "^5.2.12" }, "type": "module", "dependencies": { "@dagrejs/dagre": "^1.0.4", + "@sveltejs/vite-plugin-svelte": "^3.1.1", "@xyflow/svelte": "workspace:^" } } diff --git a/examples/svelte/src/app.d.ts b/examples/svelte/src/app.d.ts index f59b884c..31fd2d84 100644 --- a/examples/svelte/src/app.d.ts +++ b/examples/svelte/src/app.d.ts @@ -1,4 +1,15 @@ // See https://kit.svelte.dev/docs/types#app + +import type { + BackgroundProps, + Edge, + MiniMap, + MiniMapProps, + Node, + PanelProps, + SvelteFlowProps +} from '@xyflow/svelte'; + // for information about these interfaces declare global { namespace App { @@ -7,6 +18,14 @@ declare global { // interface PageData {} // interface Platform {} } + + interface FlowConfig { + flowProps?: Omit & { nodes: Node[]; edges: Edge[] }; + panelProps?: PanelProps; + backgroundProps?: BackgroundProps; + controlsProps?: ControlsProps; + minimapProps?: MiniMapProps; + } } export {}; diff --git a/examples/svelte/src/app.html b/examples/svelte/src/app.html index 68f1308a..a9d5b2f0 100644 --- a/examples/svelte/src/app.html +++ b/examples/svelte/src/app.html @@ -1,8 +1,9 @@ - + + diff --git a/examples/svelte/src/generic-tests/node-toolbar/general.ts b/examples/svelte/src/generic-tests/node-toolbar/general.ts new file mode 100644 index 00000000..6fce800f --- /dev/null +++ b/examples/svelte/src/generic-tests/node-toolbar/general.ts @@ -0,0 +1,50 @@ +import { Position, type Node } from '@xyflow/svelte'; +import ToolbarNode from './components/ToolbarNode.svelte'; + +const positions = ['top', 'right', 'bottom', 'left']; +const alignments = ['start', 'center', 'end']; + +const nodes: Node[] = [ + { + id: 'default-node', + type: 'ToolbarNode', + data: { label: 'toolbar top', toolbarPosition: Position.Top }, + position: { x: 0, y: -200 }, + class: 'react-flow__node-default' + } +]; + +positions.forEach((position, posIndex) => { + alignments.forEach((align, alignIndex) => { + const id = `node-${align}-${position}`; + nodes.push({ + id, + type: 'ToolbarNode', + data: { + label: `toolbar ${position} ${align}`, + toolbarPosition: position as Position, + toolbarAlign: align, + toolbarVisible: true + }, + class: 'react-flow__node-default', + position: { x: posIndex * 300, y: alignIndex * 100 } + }); + }); +}); + +export default { + flowProps: { + fitView: true, + nodeTypes: { + ToolbarNode + }, + nodes, + edges: [ + { + id: 'first-edge', + source: 'default-node', + target: 'node-start-top' + } + ] + } +} satisfies FlowConfig; diff --git a/examples/svelte/src/generic-tests/nodes/components/DragHandleNode.svelte b/examples/svelte/src/generic-tests/nodes/components/DragHandleNode.svelte new file mode 100644 index 00000000..da5fa4c4 --- /dev/null +++ b/examples/svelte/src/generic-tests/nodes/components/DragHandleNode.svelte @@ -0,0 +1,26 @@ + + +
+
+
+ + diff --git a/examples/svelte/src/generic-tests/nodes/general.ts b/examples/svelte/src/generic-tests/nodes/general.ts new file mode 100644 index 00000000..60757953 --- /dev/null +++ b/examples/svelte/src/generic-tests/nodes/general.ts @@ -0,0 +1,92 @@ +import DragHandleNode from './components/DragHandleNode.svelte'; + +export default { + flowProps: { + fitView: true, + nodeDragThreshold: 0, + nodeTypes: { + DragHandleNode + }, + nodes: [ + { + id: 'Node-1', + data: { label: 'Node-1' }, + position: { x: 0, y: 0 }, + type: 'input', + class: 'playwright-test-class-123', + style: 'background-color: red;' + }, + { + id: 'Node-2', + type: 'output', + data: { label: 'Node-2' }, + position: { x: -100, y: 100 } + }, + { + id: 'Node-3', + data: { label: 'Node-3' }, + position: { x: 100, y: 100 } + }, + { + id: 'Node-4', + data: { label: 'Node-4' }, + position: { x: 0, y: 200 }, + type: 'output' + }, + { + id: 'drag-handle', + data: { label: 'Drag Handle' }, + position: { x: 200, y: 0 }, + type: 'DragHandleNode', + dragHandle: '.custom-drag-handle' + }, + { + id: 'notConnectable', + type: 'output', + data: { label: 'notConnectable' }, + position: { x: 0, y: 300 }, + connectable: false + }, + { + id: 'notDraggable', + data: { label: 'notDraggable' }, + position: { x: 0, y: 400 }, + draggable: false + }, + { + id: 'notSelectable', + data: { label: 'notSelectable' }, + position: { x: 0, y: 500 }, + selectable: false + }, + { + id: 'notDeletable', + data: { label: 'notDeletable' }, + position: { x: 0, y: 600 }, + deletable: false + }, + { + id: 'hidden', + data: { label: 'hidden' }, + position: { x: 0, y: 700 }, + hidden: true + } + ], + edges: [ + { + id: '1-2', + type: 'default', + source: 'Node-1', + target: 'Node-2', + label: 'edge' + }, + { + id: '1-3', + type: 'default', + source: 'Node-1', + target: 'Node-3', + label: 'edge' + } + ] + } +} satisfies FlowConfig; diff --git a/examples/svelte/src/generic-tests/pane/activation-keys.ts b/examples/svelte/src/generic-tests/pane/activation-keys.ts new file mode 100644 index 00000000..ca16c47b --- /dev/null +++ b/examples/svelte/src/generic-tests/pane/activation-keys.ts @@ -0,0 +1,40 @@ +export default { + flowProps: { + zoomOnScroll: false, + // zoomActivationKey: 'Space', + // panOnDrag: false, + // panOnScroll: false, + panActivationKey: 'Space', + fitView: true, + nodes: [ + { + id: '1', + data: { label: '1' }, + position: { x: 0, y: 0 }, + type: 'input' + }, + { + id: '2', + data: { label: '2' }, + position: { x: -100, y: 100 } + }, + { + id: '3', + data: { label: '3' }, + position: { x: 100, y: 100 } + } + ], + edges: [ + { + id: 'first-edge', + source: '1', + target: '2' + }, + { + id: 'second-edge', + source: '1', + target: '3' + } + ] + } +} satisfies FlowConfig; diff --git a/examples/svelte/src/generic-tests/pane/general.ts b/examples/svelte/src/generic-tests/pane/general.ts new file mode 100644 index 00000000..9b9c9a5c --- /dev/null +++ b/examples/svelte/src/generic-tests/pane/general.ts @@ -0,0 +1,37 @@ +export default { + flowProps: { + minZoom: 0.25, + maxZoom: 4, + fitView: true, + nodes: [ + { + id: '1', + data: { label: '1' }, + position: { x: 0, y: 0 }, + type: 'input' + }, + { + id: '2', + data: { label: '2' }, + position: { x: -100, y: 100 } + }, + { + id: '3', + data: { label: '3' }, + position: { x: 100, y: 100 } + } + ], + edges: [ + { + id: 'first-edge', + source: '1', + target: '2' + }, + { + id: 'second-edge', + source: '1', + target: '3' + } + ] + } +} satisfies FlowConfig; diff --git a/examples/svelte/src/generic-tests/pane/non-defaults.ts b/examples/svelte/src/generic-tests/pane/non-defaults.ts new file mode 100644 index 00000000..d3aa8e27 --- /dev/null +++ b/examples/svelte/src/generic-tests/pane/non-defaults.ts @@ -0,0 +1,38 @@ +export default { + flowProps: { + panOnScroll: true, + initialViewport: { x: 1.23, y: 9.87, zoom: 1.234 }, + autoPanOnConnect: false, + autoPanOnNodeDrag: false, + nodes: [ + { + id: '1', + data: { label: '1' }, + position: { x: 0, y: 0 }, + type: 'input' + }, + { + id: '2', + data: { label: '2' }, + position: { x: -100, y: 100 } + }, + { + id: '3', + data: { label: '3' }, + position: { x: 100, y: 100 } + } + ], + edges: [ + { + id: 'first-edge', + source: '1', + target: '2' + }, + { + id: 'second-edge', + source: '1', + target: '3' + } + ] + } +} satisfies FlowConfig; diff --git a/examples/svelte/src/routes/+layout.server.ts b/examples/svelte/src/routes/+layout.server.ts index b64f1170..c968a610 100644 --- a/examples/svelte/src/routes/+layout.server.ts +++ b/examples/svelte/src/routes/+layout.server.ts @@ -2,7 +2,7 @@ import { redirect } from '@sveltejs/kit'; /** @type {import('./$types').LayoutServerLoad} */ export function load({ route }) { - if (route.id === '/') { - throw redirect(307, '/overview'); - } + if (route.id === '/') { + throw redirect(307, '/examples/overview'); + } } diff --git a/examples/svelte/src/routes/custom-connection-line/ConnectionLine.svelte b/examples/svelte/src/routes/custom-connection-line/ConnectionLine.svelte deleted file mode 100644 index 0a7f87b5..00000000 --- a/examples/svelte/src/routes/custom-connection-line/ConnectionLine.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - -{#if $connection.path} - -{/if} diff --git a/examples/svelte/src/routes/custom-connection-line/CustomNode.svelte b/examples/svelte/src/routes/custom-connection-line/CustomNode.svelte deleted file mode 100644 index 0f4cc6bd..00000000 --- a/examples/svelte/src/routes/custom-connection-line/CustomNode.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - -
-
Node
- - - -
diff --git a/examples/svelte/src/routes/+layout.svelte b/examples/svelte/src/routes/examples/+layout.svelte similarity index 81% rename from examples/svelte/src/routes/+layout.svelte rename to examples/svelte/src/routes/examples/+layout.svelte index 84598fec..cf9f655f 100644 --- a/examples/svelte/src/routes/+layout.svelte +++ b/examples/svelte/src/routes/examples/+layout.svelte @@ -1,5 +1,5 @@
diff --git a/examples/svelte/src/routes/examples/+page.svelte b/examples/svelte/src/routes/examples/+page.svelte new file mode 100644 index 00000000..45db2228 --- /dev/null +++ b/examples/svelte/src/routes/examples/+page.svelte @@ -0,0 +1 @@ +
this redirects to /overview
diff --git a/examples/svelte/src/routes/add-node-on-drop/+page.svelte b/examples/svelte/src/routes/examples/add-node-on-drop/+page.svelte similarity index 100% rename from examples/svelte/src/routes/add-node-on-drop/+page.svelte rename to examples/svelte/src/routes/examples/add-node-on-drop/+page.svelte diff --git a/examples/svelte/src/routes/add-node-on-drop/Flow.svelte b/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte similarity index 72% rename from examples/svelte/src/routes/add-node-on-drop/Flow.svelte rename to examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte index cd12c1a4..e767e4ac 100644 --- a/examples/svelte/src/routes/add-node-on-drop/Flow.svelte +++ b/examples/svelte/src/routes/examples/add-node-on-drop/Flow.svelte @@ -1,7 +1,12 @@ @@ -70,11 +77,11 @@ {edges} fitView fitViewOptions={{ padding: 2 }} - on:connectstart={({ detail: { nodeId } }) => { + onconnectstart={(_, { nodeId }) => { // Memorize the nodeId you start draggin a connection line from a node connectingNodeId = nodeId; }} - on:connectend={handleConnectEnd} + onconnectend={handleConnectEnd} />
diff --git a/examples/svelte/src/routes/examples/color-mode/+page.svelte b/examples/svelte/src/routes/examples/color-mode/+page.svelte new file mode 100644 index 00000000..59ddfdfd --- /dev/null +++ b/examples/svelte/src/routes/examples/color-mode/+page.svelte @@ -0,0 +1,54 @@ + + + + + + + + + + + diff --git a/examples/svelte/src/routes/custom-connection-line/+page.svelte b/examples/svelte/src/routes/examples/custom-connection-line/+page.svelte similarity index 100% rename from examples/svelte/src/routes/custom-connection-line/+page.svelte rename to examples/svelte/src/routes/examples/custom-connection-line/+page.svelte diff --git a/examples/svelte/src/routes/examples/custom-connection-line/ConnectionLine.svelte b/examples/svelte/src/routes/examples/custom-connection-line/ConnectionLine.svelte new file mode 100644 index 00000000..d315f572 --- /dev/null +++ b/examples/svelte/src/routes/examples/custom-connection-line/ConnectionLine.svelte @@ -0,0 +1,24 @@ + + +{#if $connection.inProgress} + +{/if} diff --git a/examples/svelte/src/routes/examples/custom-connection-line/CustomNode.svelte b/examples/svelte/src/routes/examples/custom-connection-line/CustomNode.svelte new file mode 100644 index 00000000..6507ce51 --- /dev/null +++ b/examples/svelte/src/routes/examples/custom-connection-line/CustomNode.svelte @@ -0,0 +1,35 @@ + + +
+
Node
+ + + +
diff --git a/examples/svelte/src/routes/customnode/+page.svelte b/examples/svelte/src/routes/examples/customnode/+page.svelte similarity index 92% rename from examples/svelte/src/routes/customnode/+page.svelte rename to examples/svelte/src/routes/examples/customnode/+page.svelte index 14130a47..f89210c8 100644 --- a/examples/svelte/src/routes/customnode/+page.svelte +++ b/examples/svelte/src/routes/examples/customnode/+page.svelte @@ -1,5 +1,4 @@
- +
Custom Color Picker Node: {$colorStore}
@@ -20,14 +22,8 @@ on:input={(evt) => colorStore.set(evt.currentTarget.value)} value={$colorStore} /> - - + +
diff --git a/examples/svelte/src/routes/examples/edges/CustomBezierEdge.svelte b/examples/svelte/src/routes/examples/edges/CustomBezierEdge.svelte new file mode 100644 index 00000000..960d3e6e --- /dev/null +++ b/examples/svelte/src/routes/examples/edges/CustomBezierEdge.svelte @@ -0,0 +1,38 @@ + + + diff --git a/examples/svelte/src/routes/figma/+page.svelte b/examples/svelte/src/routes/examples/figma/+page.svelte similarity index 98% rename from examples/svelte/src/routes/figma/+page.svelte rename to examples/svelte/src/routes/examples/figma/+page.svelte index 7ebdd500..4673eeec 100644 --- a/examples/svelte/src/routes/figma/+page.svelte +++ b/examples/svelte/src/routes/examples/figma/+page.svelte @@ -45,6 +45,7 @@ {edges} fitView selectionMode={SelectionMode.Partial} + selectionOnDrag panOnScroll {panOnDrag} {onMoveStart} diff --git a/examples/svelte/src/routes/examples/handle-connect/+page.svelte b/examples/svelte/src/routes/examples/handle-connect/+page.svelte new file mode 100644 index 00000000..4f121c83 --- /dev/null +++ b/examples/svelte/src/routes/examples/handle-connect/+page.svelte @@ -0,0 +1,94 @@ + + + + + + + diff --git a/examples/svelte/src/routes/examples/handle-connect/MultiHandleNode.svelte b/examples/svelte/src/routes/examples/handle-connect/MultiHandleNode.svelte new file mode 100644 index 00000000..51243944 --- /dev/null +++ b/examples/svelte/src/routes/examples/handle-connect/MultiHandleNode.svelte @@ -0,0 +1,83 @@ + + +
+ +
node {id}
+ onConnectSource('a', connections)} + ondisconnect={(connections) => onDisconnectSource('a', connections)} + class="source-a" + /> + onConnectSource('b', connections)} + ondisconnect={(connections) => onDisconnectSource('b', connections)} + class="source-b" + /> +
+ + diff --git a/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte b/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte new file mode 100644 index 00000000..d2958969 --- /dev/null +++ b/examples/svelte/src/routes/examples/handle-connect/SingleHandleNode.svelte @@ -0,0 +1,50 @@ + + +
+ +
node {id}
+ +
+ + diff --git a/examples/svelte/src/routes/interaction/+page.svelte b/examples/svelte/src/routes/examples/interaction/+page.svelte similarity index 100% rename from examples/svelte/src/routes/interaction/+page.svelte rename to examples/svelte/src/routes/examples/interaction/+page.svelte diff --git a/examples/svelte/src/routes/intersections/+page.svelte b/examples/svelte/src/routes/examples/intersections/+page.svelte similarity index 100% rename from examples/svelte/src/routes/intersections/+page.svelte rename to examples/svelte/src/routes/examples/intersections/+page.svelte diff --git a/examples/svelte/src/routes/intersections/+server.ts b/examples/svelte/src/routes/examples/intersections/+server.ts similarity index 100% rename from examples/svelte/src/routes/intersections/+server.ts rename to examples/svelte/src/routes/examples/intersections/+server.ts diff --git a/examples/svelte/src/routes/intersections/Flow.svelte b/examples/svelte/src/routes/examples/intersections/Flow.svelte similarity index 91% rename from examples/svelte/src/routes/intersections/Flow.svelte rename to examples/svelte/src/routes/examples/intersections/Flow.svelte index f3f80335..12c98e00 100644 --- a/examples/svelte/src/routes/intersections/Flow.svelte +++ b/examples/svelte/src/routes/examples/intersections/Flow.svelte @@ -12,8 +12,8 @@ const { getIntersectingNodes } = useSvelteFlow(); - function onNodeDrag({ detail: { node } }) { - const intersections = getIntersectingNodes(node).map((n) => n.id); + function onNodeDrag({ detail: { targetNode } }) { + const intersections = getIntersectingNodes(targetNode).map((n) => n.id); $nodes.forEach((n) => { n.class = intersections.includes(n.id) ? 'highlight' : ''; diff --git a/examples/svelte/src/routes/examples/intersections/nodes-and-edges.ts b/examples/svelte/src/routes/examples/intersections/nodes-and-edges.ts new file mode 100644 index 00000000..7b094ce2 --- /dev/null +++ b/examples/svelte/src/routes/examples/intersections/nodes-and-edges.ts @@ -0,0 +1,28 @@ +import type { Node, Edge } from '@xyflow/svelte'; + +export const initialNodes: Node[] = [ + { + id: '1', + data: { label: 'Node 1' }, + position: { x: 0, y: 0 }, + style: 'width: 200px; height: 100px;' + }, + { + id: '2', + data: { label: 'Node 2' }, + position: { x: 0, y: 150 } + }, + { + id: '3', + data: { label: 'Node 3' }, + position: { x: 250, y: 0 } + }, + { + id: '4', + data: { label: 'Node' }, + position: { x: 350, y: 150 }, + style: 'width: 50px; height: 50px;' + } +]; + +export const initialEdges: Edge[] = []; diff --git a/examples/svelte/src/routes/examples/node-resizer/+page.svelte b/examples/svelte/src/routes/examples/node-resizer/+page.svelte new file mode 100644 index 00000000..e03acf34 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-resizer/+page.svelte @@ -0,0 +1,154 @@ + + + + + + + + diff --git a/examples/svelte/src/routes/examples/node-resizer/CustomResizer.svelte b/examples/svelte/src/routes/examples/node-resizer/CustomResizer.svelte new file mode 100644 index 00000000..e765c7c8 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-resizer/CustomResizer.svelte @@ -0,0 +1,43 @@ + + + + + + + + + + + + +
{data.label}
+ diff --git a/examples/svelte/src/routes/examples/node-resizer/DefaultResizer.svelte b/examples/svelte/src/routes/examples/node-resizer/DefaultResizer.svelte new file mode 100644 index 00000000..81964784 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-resizer/DefaultResizer.svelte @@ -0,0 +1,25 @@ + + + + +
{data.label}
+ diff --git a/examples/svelte/src/routes/examples/node-resizer/HorizontalResizer.svelte b/examples/svelte/src/routes/examples/node-resizer/HorizontalResizer.svelte new file mode 100644 index 00000000..5caea508 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-resizer/HorizontalResizer.svelte @@ -0,0 +1,38 @@ + + + + + +
{data.label}
+ diff --git a/examples/svelte/src/routes/examples/node-resizer/VerticalResizer.svelte b/examples/svelte/src/routes/examples/node-resizer/VerticalResizer.svelte new file mode 100644 index 00000000..5e173508 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-resizer/VerticalResizer.svelte @@ -0,0 +1,38 @@ + + + + + +
{data.label}
+ diff --git a/examples/svelte/src/routes/examples/node-resizer/types.ts b/examples/svelte/src/routes/examples/node-resizer/types.ts new file mode 100644 index 00000000..53562cef --- /dev/null +++ b/examples/svelte/src/routes/examples/node-resizer/types.ts @@ -0,0 +1,21 @@ +import { + type Node, + type ShouldResize, + type OnResizeStart, + type OnResize, + type OnResizeEnd +} from '@xyflow/svelte'; + +export type ResizeNode = Node<{ + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + shouldResize?: ShouldResize; + onResizeStart?: OnResizeStart; + onResize?: OnResize; + onResizeEnd?: OnResizeEnd; + keepAspectRatio?: boolean; + label?: string; + isVisible?: boolean; +}>; diff --git a/examples/svelte/src/routes/examples/node-toolbar/+page.svelte b/examples/svelte/src/routes/examples/node-toolbar/+page.svelte new file mode 100644 index 00000000..1a4360e5 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-toolbar/+page.svelte @@ -0,0 +1,63 @@ + + +
+ + + + +
diff --git a/examples/svelte/src/routes/examples/node-toolbar/CustomNode.svelte b/examples/svelte/src/routes/examples/node-toolbar/CustomNode.svelte new file mode 100644 index 00000000..99b00f7c --- /dev/null +++ b/examples/svelte/src/routes/examples/node-toolbar/CustomNode.svelte @@ -0,0 +1,32 @@ + + + + + + + +
+
{data.label}
+ + +
+ + diff --git a/examples/svelte/src/routes/examples/node-toolbar/SelectedNodesToolbar.svelte b/examples/svelte/src/routes/examples/node-toolbar/SelectedNodesToolbar.svelte new file mode 100644 index 00000000..df62b856 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-toolbar/SelectedNodesToolbar.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/examples/svelte/src/routes/overview/+page.svelte b/examples/svelte/src/routes/examples/overview/+page.svelte similarity index 73% rename from examples/svelte/src/routes/overview/+page.svelte rename to examples/svelte/src/routes/examples/overview/+page.svelte index 32bef5d0..2dd57734 100644 --- a/examples/svelte/src/routes/overview/+page.svelte +++ b/examples/svelte/src/routes/examples/overview/+page.svelte @@ -12,7 +12,9 @@ type EdgeTypes, type Node, type Edge, - ConnectionMode + ConnectionMode, + ControlButton, + type FitViewOptions } from '@xyflow/svelte'; import CustomNode from './CustomNode.svelte'; @@ -20,6 +22,7 @@ import CustomEdge from './CustomEdge.svelte'; import '@xyflow/svelte/dist/style.css'; + import InitTracker from './InitTracker.svelte'; const nodeTypes: NodeTypes = { custom: CustomNode, @@ -30,6 +33,11 @@ custom: CustomEdge }; + const fitViewOptions: FitViewOptions = { + padding: 0.2, + nodes: [{ id: '1' }, { id: '2' }] + }; + const nodes = writable([ { id: '1', @@ -83,6 +91,12 @@ type: 'custom', data: { label: 'Custom Node' }, position: { x: 150, y: 300 } + }, + { + id: 'hideunhide', + data: { label: 'HIDE ME' }, + position: { x: 300, y: 75 }, + hidden: true } ]); @@ -140,13 +154,16 @@ selectionMode={SelectionMode.Full} initialViewport={{ x: 100, y: 100, zoom: 2 }} snapGrid={[25, 25]} + oninit={() => console.log('on init')} on:nodeclick={(event) => console.log('on node click', event)} on:nodemouseenter={(event) => console.log('on node enter', event)} on:nodemouseleave={(event) => console.log('on node leave', event)} on:edgeclick={(event) => console.log('edge click', event)} - on:connectstart={(event) => console.log('on connect start', event)} - on:connect={(event) => console.log('on connect', event)} - on:connectend={(event) => console.log('on connect end', event)} + on:edgemouseenter={(event) => console.log('edge enter', event)} + on:edgemouseleave={(event) => console.log('edge leave', event)} + onconnectstart={(event) => console.log('on connect start', event)} + onconnect={(event) => console.log('on connect', event)} + onconnectend={(event) => console.log('on connect end', event)} on:paneclick={(event) => console.log('on pane click', event)} on:panecontextmenu={(event) => { console.log('on pane contextmenu', event); @@ -168,18 +185,40 @@ event.preventDefault(); console.log('on edge contextmenu', edge); }} + on:selectionclick={(event) => console.log('on selection click', event)} + on:selectioncontextmenu={(event) => console.log('on selection contextmenu', event)} + onbeforedelete={async ({ nodes, edges }) => { + console.log('on before delete', nodes, edges); + const deleteElements = confirm('Are you sure you want to delete the selected elements?'); + return deleteElements; + }} autoPanOnConnect autoPanOnNodeDrag connectionMode={ConnectionMode.Strict} attributionPosition={'top-center'} + deleteKey={['Backspace', 'd']} > - + + xy + console.log('control button')} + >log + + + + diff --git a/examples/svelte/src/routes/two-way-viewport/+page.svelte b/examples/svelte/src/routes/examples/two-way-viewport/+page.svelte similarity index 100% rename from examples/svelte/src/routes/two-way-viewport/+page.svelte rename to examples/svelte/src/routes/examples/two-way-viewport/+page.svelte diff --git a/examples/svelte/src/routes/two-way-viewport/Flow.svelte b/examples/svelte/src/routes/examples/two-way-viewport/Flow.svelte similarity index 88% rename from examples/svelte/src/routes/two-way-viewport/Flow.svelte rename to examples/svelte/src/routes/examples/two-way-viewport/Flow.svelte index 7aa5a9b4..73d3a073 100644 --- a/examples/svelte/src/routes/two-way-viewport/Flow.svelte +++ b/examples/svelte/src/routes/examples/two-way-viewport/Flow.svelte @@ -21,7 +21,7 @@ { id: 'B', position: { x: 0, y: 100 }, data: { label: 'B' } } ]); const edges = writable([{ id: 'ab', source: 'A', target: 'B' }]); - const viewport = writable({ x: 0, y: 10, zoom: 1.25 }); + const viewport = writable({ x: 100, y: 100, zoom: 1.25 }); const { fitView } = useSvelteFlow(); @@ -35,7 +35,7 @@ }); - + diff --git a/examples/svelte/src/routes/examples/usenodesdata/+page.svelte b/examples/svelte/src/routes/examples/usenodesdata/+page.svelte new file mode 100644 index 00000000..91fd8432 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/+page.svelte @@ -0,0 +1,95 @@ + + + + + + + + + diff --git a/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte new file mode 100644 index 00000000..436d2109 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/ResultNode.svelte @@ -0,0 +1,40 @@ + + +
+ +
incoming texts:
+ + {#each textNodes as textNode} +
{textNode.data.text}
+ {/each} +
+ + diff --git a/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte new file mode 100644 index 00000000..1fe5ff70 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/TextNode.svelte @@ -0,0 +1,30 @@ + + +
+
node {id}
+
+ updateNodeData(id, { text: evt.currentTarget.value })} + /> +
+ +
+ + diff --git a/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte new file mode 100644 index 00000000..55960cb5 --- /dev/null +++ b/examples/svelte/src/routes/examples/usenodesdata/UppercaseNode.svelte @@ -0,0 +1,48 @@ + + +
+ +
uppercase transform
+ +
+ + diff --git a/examples/svelte/src/routes/usesvelteflow/+page.svelte b/examples/svelte/src/routes/examples/usesvelteflow/+page.svelte similarity index 100% rename from examples/svelte/src/routes/usesvelteflow/+page.svelte rename to examples/svelte/src/routes/examples/usesvelteflow/+page.svelte diff --git a/examples/svelte/src/routes/usesvelteflow/Flow.svelte b/examples/svelte/src/routes/examples/usesvelteflow/Flow.svelte similarity index 100% rename from examples/svelte/src/routes/usesvelteflow/Flow.svelte rename to examples/svelte/src/routes/examples/usesvelteflow/Flow.svelte diff --git a/examples/svelte/src/routes/usesvelteflow/Sidebar.svelte b/examples/svelte/src/routes/examples/usesvelteflow/Sidebar.svelte similarity index 70% rename from examples/svelte/src/routes/usesvelteflow/Sidebar.svelte rename to examples/svelte/src/routes/examples/usesvelteflow/Sidebar.svelte index a195cf1c..0a27afd9 100644 --- a/examples/svelte/src/routes/usesvelteflow/Sidebar.svelte +++ b/examples/svelte/src/routes/examples/usesvelteflow/Sidebar.svelte @@ -1,5 +1,5 @@