├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── stale.yml └── workflows │ ├── build.yml │ └── npm-publish.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierrc ├── .storybook ├── main.ts ├── manager-head.html ├── manager.ts ├── preview-head.html ├── preview.tsx └── theme.ts ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── Advanced │ ├── CustomNodes.mdx │ ├── Refs.mdx │ ├── SSRSupport.mdx │ ├── StateMgmt.mdx │ └── Styling.mdx ├── Community.mdx ├── Contributing.mdx ├── GettingStarted │ ├── Basics.mdx │ ├── Components.mdx │ ├── DataShapes.mdx │ ├── Installing.mdx │ └── LinkingNodes.mdx ├── Helpers │ ├── Proximity.mdx │ ├── Selection.mdx │ └── Undo.mdx ├── Introduction.mdx ├── Support.mdx ├── Utils │ ├── Crud.mdx │ ├── Extending.mdx │ ├── Graph.mdx │ └── Utils.mdx ├── assets │ ├── logo-light.png │ ├── logo.png │ ├── logo.svg │ └── teaser.png └── tools │ ├── styles.css │ └── templates │ └── reaflow-codesandbox-template │ ├── package.json │ ├── public │ └── index.html │ └── src │ ├── App.js │ └── index.js ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── src ├── Canvas.module.css ├── Canvas.tsx ├── helpers │ ├── crudHelpers.test.ts │ ├── crudHelpers.ts │ ├── graphHelpers.ts │ ├── index.ts │ ├── useProximity.ts │ ├── useSelection.ts │ └── useUndo.ts ├── index.ts ├── layout │ ├── elkLayout.ts │ ├── index.ts │ ├── useLayout.ts │ ├── utils.test.ts │ └── utils.ts ├── symbols │ ├── Add │ │ ├── Add.module.css │ │ ├── Add.tsx │ │ └── index.ts │ ├── Arrow │ │ ├── Arrow.module.css │ │ ├── Arrow.tsx │ │ ├── MarkerArrow.tsx │ │ └── index.ts │ ├── Edge │ │ ├── Edge.module.css │ │ ├── Edge.tsx │ │ ├── index.ts │ │ └── utils.ts │ ├── Icon │ │ ├── Icon.module.css │ │ ├── Icon.tsx │ │ └── index.ts │ ├── Label │ │ ├── Label.module.css │ │ ├── Label.tsx │ │ └── index.ts │ ├── Node │ │ ├── Node.module.css │ │ ├── Node.tsx │ │ └── index.ts │ ├── Port │ │ ├── Port.module.css │ │ ├── Port.tsx │ │ └── index.tsx │ ├── Remove │ │ ├── Remove.module.css │ │ ├── Remove.tsx │ │ └── index.ts │ └── index.ts ├── types.ts ├── typings.d.ts └── utils │ ├── CanvasProvider.tsx │ ├── helpers.test.ts │ ├── helpers.ts │ ├── index.ts │ ├── useEdgeDrag.ts │ ├── useNodeDrag.ts │ └── useZoom.ts ├── stories ├── Basic.stories.tsx ├── Controls.stories.tsx ├── Drag.stories.tsx ├── Edge.stories.tsx ├── Editor.stories.tsx ├── Layouts.stories.tsx ├── Nested.stories.tsx ├── Node.stories.tsx ├── Port.stories.tsx ├── Proximity.stories.tsx ├── Selection.stories.tsx └── Undo.stories.tsx ├── test └── mockEnv.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: http://EditorConfig.org 2 | # EditorConfig Properties: https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties 3 | 4 | # top-most EditorConfig file 5 | root = true 6 | 7 | ### defaults 8 | [*] 9 | charset = utf-8 10 | 11 | # Unix-style newlines with 12 | end_of_line = lf 13 | 14 | # 2 space indentation 15 | indent_size = 2 16 | indent_style = space 17 | 18 | # remove any whitespace characters preceding newline characters 19 | trim_trailing_whitespace = true 20 | 21 | # newline ending every file 22 | insert_final_newline = true 23 | 24 | # Forces hard line wrapping after the amount of characters specified 25 | max_line_length = off 26 | 27 | ### custom for markdown 28 | [*.md] 29 | # do not remove any whitespace characters preceding newline characters 30 | trim_trailing_whitespace = false 31 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | types/ 3 | docs/ 4 | demo/ 5 | .storybook/ 6 | coverage/ 7 | src/**/*.story.tsx 8 | src/**/*.test.ts 9 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'es2021': true 5 | }, 6 | 'extends': [ 7 | 'eslint:recommended', 8 | 'plugin:react/recommended', 9 | 'prettier', 10 | 'plugin:react-hooks/recommended', 11 | 'plugin:storybook/recommended' 12 | ], 13 | 'parser': '@typescript-eslint/parser', 14 | 'parserOptions': { 15 | 'ecmaFeatures': { 16 | 'jsx': true 17 | }, 18 | 'ecmaVersion': 12, 19 | 'sourceType': 'module' 20 | }, 21 | 'plugins': ['react', '@typescript-eslint'], 22 | 'rules': { 23 | 'no-unused-vars': [0], 24 | 'indent': ['error', 2], 25 | 'react/display-name': [0], 26 | 'react/prop-types': [0], 27 | 'react/no-children-prop': [0], 28 | 'linebreak-style': ['error', 'unix'], 29 | 'quotes': ['error', 'single'], 30 | 'semi': ['error', 'always'] 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | open_collective: reaviz 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## I'm submitting a... 8 | 9 |

10 | [ ] Regression (a behavior that used to work and stopped working in a new release)
11 | [ ] Bug report  
12 | [ ] Performance issue
13 | [ ] Feature request
14 | [ ] Documentation issue or request
15 | [ ] Other... Please describe:
16 | 
17 | 18 | ## Current behavior 19 | 20 | 21 | 22 | ## Expected behavior 23 | 24 | 25 | 26 | ## Minimal reproduction of the problem with instructions 27 | 28 | 36 | 37 | ## What is the motivation / use case for changing the behavior? 38 | 39 | 40 | 41 | ## Environment 42 | 43 |

44 | Libs:
45 | - react version: X.Y.Z
46 | - realayers version: X.Y.Z
47 | 
48 | 
49 | Browser:
50 | - [ ] Chrome (desktop) version XX
51 | - [ ] Chrome (Android) version XX
52 | - [ ] Chrome (iOS) version XX
53 | - [ ] Firefox version XX
54 | - [ ] Safari (desktop) version XX
55 | - [ ] Safari (iOS) version XX
56 | - [ ] IE version XX
57 | - [ ] Edge version XX
58 |  
59 | For Tooling issues:
60 | - Node version: XX  
61 | - Platform:  
62 | 
63 | Others:
64 | 
65 | 
66 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | Please check if your PR fulfills the following requirements: 3 | 4 | - [ ] Tests for the changes have been added (for bug fixes / features) 5 | - [ ] Docs have been added / updated (for bug fixes / features) 6 | 7 | ## PR Type 8 | What kind of change does this PR introduce? 9 | 10 | 11 | ``` 12 | [ ] Bugfix 13 | [ ] Feature 14 | [ ] Code style update (formatting, local variables) 15 | [ ] Refactoring (no functional changes, no api changes) 16 | [ ] Build related changes 17 | [ ] CI related changes 18 | [ ] Documentation content changes 19 | [ ] Other... Please describe: 20 | ``` 21 | 22 | ## What is the current behavior? 23 | 24 | 25 | Issue Number: N/A 26 | 27 | 28 | ## What is the new behavior? 29 | 30 | 31 | ## Does this PR introduce a breaking change? 32 | ``` 33 | [ ] Yes 34 | [ ] No 35 | ``` 36 | 37 | 38 | 39 | 40 | ## Other information 41 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Use Node.js 18.x 21 | uses: actions/setup-node@v1 22 | with: 23 | version: 18.x 24 | 25 | - name: Install deps and build (with cache) 26 | uses: bahmutov/npm-install@v1 27 | 28 | - name: Lint 29 | run: npm run lint 30 | 31 | - name: Build Prod 32 | run: npm run build 33 | 34 | - name: Build Storybook 35 | run: npm run build-storybook 36 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: publish npm package 2 | 3 | on: 4 | workflow_dispatch 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: "18" 14 | - run: npm ci 15 | - run: npm run build 16 | - uses: JS-DevTools/npm-publish@v2 17 | with: 18 | token: ${{ secrets.NPM_TOKEN }} 19 | - if: ${{ steps.publish.outputs.type }} 20 | run: echo "Version changed!" 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pkg/ 8 | .rpt2_cache/ 9 | dist/ 10 | storybook-static/ 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # next.js build output 65 | .next 66 | .DS_Store 67 | src/**/*.scss.d.ts 68 | src/**/*.css.d.ts 69 | 70 | # IDE 71 | .idea 72 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | # https://github.com/prettier/prettier#configuration-file 2 | semi: true 3 | singleQuote: true 4 | trailingComma: none 5 | overrides: 6 | - files: ".prettierrc" 7 | options: 8 | parser: json 9 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | 3 | const config: StorybookConfig = { 4 | stories: [ 5 | '../docs/**/*.mdx', 6 | '../src/**/*.stories.tsx', 7 | '../stories/**/*.stories.tsx' 8 | ], 9 | addons: [ 10 | '@storybook/addon-storysource', 11 | '@storybook/addon-essentials' 12 | ], 13 | framework: { 14 | name: '@storybook/react-vite', 15 | options: {} 16 | } 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | REAFLOW 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 20 | 21 | 22 | 23 | 29 | 33 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons'; 2 | import theme from './theme'; 3 | 4 | addons.setConfig({ 5 | theme 6 | }); 7 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | REAFLOW 2 | 27 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import theme from './theme'; 2 | 3 | export const parameters = { 4 | layout: 'centered', 5 | docs: { 6 | theme 7 | }, 8 | controls: { hideNoControlsWarning: true }, 9 | actions: { argTypesRegex: '^on.*' }, 10 | options: { 11 | storySort: { 12 | order: [ 13 | 'Docs', 14 | [ 15 | 'Introduction', 16 | 'Getting Started', 17 | [ 18 | 'Installing', 19 | 'Basics', 20 | 'Components', 21 | 'Data Shapes', 22 | 'Linking Nodes' 23 | ], 24 | 'Utils', 25 | [ 26 | 'Getting Started', 27 | 'Extending', 28 | 'Graph', 29 | 'CRUD' 30 | ], 31 | 'Advanced', 32 | [ 33 | 'Styling' 34 | ], 35 | 'Helpers', 36 | [ 37 | 'Selection', 38 | 'Undo Redo', 39 | 'Proximity' 40 | ], 41 | 'Support' 42 | ], 43 | ], 44 | }, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /.storybook/theme.ts: -------------------------------------------------------------------------------- 1 | import { create } from '@storybook/theming/create'; 2 | import Logo from '../docs/assets/logo.svg'; 3 | 4 | export default create({ 5 | base: 'dark', 6 | brandTitle: 'REAFLOW', 7 | brandUrl: 'https://github.com/reaviz/reaflow', 8 | brandImage: Logo 9 | }); 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 5.4.1 - 4/8/25 2 | - [fix] fix: import.meta.resolve error #276 3 | 4 | # 5.4.0 - 4/07/25 5 | - [feature] elkjs use webworker when available 6 | - [fix] update motion and broken icons 7 | 8 | # 5.3.4 - 2/3/25 9 | - [chore] upgrade reakeys & reablocks #273 10 | 11 | # 5.3.3 - 1/28/25 12 | - [chore] upgrade reakeys 13 | - [chore] upgrade reablocks 14 | 15 | # 5.3.2 - 1/17/25 16 | - [chore] upgrade framer-motion to motion 17 | - [chore] upgrade reablocks 18 | 19 | # 5.3.1 - 7/11/24 20 | - [improvement] Update pan drag cursor #260 21 | 22 | # 5.3.0 - 7/11/24 23 | - [feature] Add fit to node canvas util and related improvements (#258) 24 | - [improvement] Improve scroll consistency on different devices (#259) 25 | - [chore] Upgrade deps (#257) 26 | - [feature] Add drag pan type (#256) 27 | - [docs] Update README.md 28 | 29 | # 5.2.12 - 4/30/24 30 | - [fix] Update reakeys + reablocks 31 | 32 | # 5.2.11 - 4/22/24 33 | - [fix] Update reablocks 34 | 35 | # 5.2.10 - 4/14/24 36 | - [chore] migrate from rdk to reablocks 37 | 38 | # 5.2.9 - 2/28/24 39 | - [chore] upgrade deps 40 | 41 | # 5.2.8 - 12/15/23 42 | - [chore] upgrade rdk 43 | 44 | # 5.2.7 - 11/13/23 45 | - [improvement] center horizontal edge labels 46 | - [fix] fix elk.bundled webpack error 47 | 48 | # 5.2.6 - 8/25/22 49 | - [fix] fix node title showing when no content to show 50 | 51 | # 5.2.5 - 8/23/22 52 | - [fix] calculateSize fn #231 53 | 54 | # 5.2.4 - 8/22/23 55 | - [fix] Attempt to fix webpack error issue 56 | 57 | # 5.2.3 - 8/21/23 58 | - [fix] Fix webpack error #229 59 | 60 | # 5.2.2 - 7/27/23 61 | - [fix] fix node ordering bug 62 | - [chore] upgrade deps 63 | 64 | # 5.2.1 - 6/14/23 65 | - [chore] improve exports 66 | 67 | # 5.2.0 - 5/2/23 68 | - [chore] update build 69 | 70 | # 5.1.2 - 1/9/23 71 | - [fix] update useProximity getCoords call #199 72 | 73 | # 5.1.1 - 12/2/22 74 | - [fix] Fix getCoords when non-center position is used #179 75 | 76 | # 5.1.0 - 11/21/22 77 | - [chore] upgrade deps 78 | 79 | # 5.0.7 - 9/19/22 80 | - [improvement] Feature/expose useScrollXY function #180 81 | - [chore] bump elkjs #181 82 | 83 | # 5.0.6 - 6/10/22 84 | - [improvement] Added interpolation props to Edge #166 85 | 86 | # 5.0.5 - 5/10/22 87 | - [chore] bump rdk 88 | 89 | # 5.0.4 90 | - [fix] Change center and centerCanvas to defaultPosition and positionCanvas for more flexible placement options #154 91 | 92 | # 5.0.3 - 4/6/22 93 | - [chore] bump rdk 94 | 95 | # 5.0.2 - 4/5/22 96 | - [chore] fix framer-motion 97 | 98 | # 5.0.1 - 4/4/22 99 | - [fix] Edge labels extra data #151 100 | 101 | # 5.0.0 - 4/4/22 102 | - [chore] upgrade react 103 | - [chore] upgrade framer-motion 104 | 105 | # 4.2.17 - 3/31/22 106 | - [fix] Fixes missing data in nested edges #150 107 | 108 | # 4.2.16 - 3/30/22 109 | - [fix] Fix data attribute in edge #149 110 | 111 | # 4.2.16 - 2/3/22 112 | - [fix] improve edge custom styling #143 113 | 114 | # 4.2.15 - 12/28/21 115 | - [fix] Pass children to edge cloned element #136 116 | 117 | # 4.2.14 - 12/6/21 118 | - [fix] Improve performance and add memorization #130 119 | 120 | # 4.2.13 - 12/2/21 121 | - [fix] fix unnecessary re-renders #127 122 | 123 | # 4.2.12 - 11/23/21 124 | - [fix] Pass Node ref to event at srcElement prop #125 125 | 126 | # 4.2.11 - 11/1/21 127 | - [fix] fix useUndo functions(count, history, clear) #117 128 | 129 | # 4.2.10 - 10/27/21 130 | - [fix] Fix crash on undefined Edge properties #116 131 | 132 | # 4.2.9 - 10/25/21 133 | - [enhancement] add more control for nodes and edges #113 134 | 135 | # 4.2.8 - 10/20/21 136 | - [fix] prevent parent events on disabled entities #112 137 | 138 | # 4.2.7 - 10/20/21 139 | - [fix] #109 css not rule applied to multiple classes 140 | 141 | # 4.2.6 - 9/27/21 142 | - [fix] #105 handle nested nested 143 | 144 | # 4.2.5 - 9/22/21 145 | - [chore] upgrade rdk for ghost dom issues 146 | 147 | # 4.2.4 - 9/21/21 148 | - [chore] upgrade rdk for ghost dom issues 149 | 150 | # 4.2.3 - 9/21/21 151 | - [chore] upgrade deps 152 | 153 | # 4.2.2 - 9/8/21 154 | - [chore] upgrade deps 155 | 156 | # 4.2.1 - 8/23/21 157 | - [chore] upgrade reakeys 158 | 159 | # 4.2.0 - 8/23/21 160 | - [feature] add ability to disable hotkeys 161 | 162 | # 4.1.2 - 7/27/21 163 | - [fix] remove unneeded dependency 164 | 165 | # 4.1.1 - 7/27/21 166 | - [fix] fix drag node edge callback 167 | 168 | # 4.1.0 - 7/26/21 169 | - [feature] Update drag re-arrange to support nested items 170 | 171 | # 4.0.0 - 7/23/21 172 | - [BREAKING] `onNodeLink` and `onNodeLinkCheck` now pass `event` as the first argument! 173 | - [feature] Improve drag DX 174 | - [fix] fix port not getting drag cursor 175 | 176 | # 3.3.4 - 7/23/21 177 | - [fix] improve safety check for node linking 178 | 179 | # 3.3.3 - 7/23/21 180 | - [fix] fix height null errors 181 | 182 | # 3.3.2 - 7/22/21 183 | - [fix] reorder port position for drag node to be on top 184 | - [fix] fix height / width in drag node being lost 185 | 186 | # 3.3.1 - 7/22/21 187 | - [feature] remove hardcoded height/width for dynamic cloning 188 | 189 | # 3.3.0 - 7/22/21 190 | - [feature] Node now has `dragType` which indicates if u can drag a node from the port, node, or port when multi-node only. 191 | - [feature] new helper function: `getEdgesByNode` 192 | - [feature] added demo for node + port dragging 193 | - [feature] added `dragCursor` for custom cursors on node dragging 194 | 195 | # 3.2.0 - 7/21/21 196 | - [feature] ability to drag nodes to different positions 197 | - [feature] new helper functions: `removeEdgesFromNode` and `createEdgeFromNodes` 198 | 199 | # 3.1.4 - 7/9/21 200 | - [chore] upgrade rdk 201 | 202 | # 3.1.3 - 6/9/21 203 | - [chore] upgrade rdk 204 | - [chore] fix elk bundling issue 205 | 206 | # 3.1.2 - 5/14/21 207 | - [bug] fix positioning of ports relative to edges 208 | 209 | # 3.1.1 - 4/19/21 210 | - [feature] add `animated` property to disable animations 211 | 212 | # 3.1.0 - 4/16/21 213 | - [chore] upgrade rdk/realayers/deps 214 | 215 | # 3.0.14 - 3/7/21 216 | - [feature] Add `children` property to Edges/Port components #76 217 | 218 | # 3.0.13 - 2/11/21 219 | - [fix] fix disabled css overrides not correct in node 220 | 221 | # 3.0.12 - 2/11/21 222 | - [fix] fix disabled css overrides not correct in edge 223 | 224 | # 3.0.11 - 2/11/21 225 | - [fix] fix disabled css overrides not correct 226 | 227 | # 3.0.10 - 2/10/21 228 | - [feature] add ability to disable port events 229 | - [fix] make port cursor a crosshair rather than pointer 230 | 231 | # 3.0.9 - 2/10/21 232 | - [feature] add `selectionDisabled` to `NodeData` and `EdgeData` 233 | - [fix] fix `disabled` not passing through to nodes and edges 234 | 235 | # 3.0.8 - 2/10/21 236 | - [feature] add `layoutOptions` to `NodeData` model 237 | - [chore] improve docs 238 | 239 | # 3.0.7 - 2/9/21 240 | - [fix] fix edge overrender issues 241 | 242 | # 3.0.6 - 2/9/21 243 | - [fix] fix edge overrender issues 244 | 245 | # 3.0.5 - 2/4/21 246 | - [fix] fix typo w/ null coll 247 | 248 | # 3.0.4 - 2/4/21 249 | - [feature] add disabled to select helper 250 | - [perf] improve node render perf 251 | - [perf] improve proximity perf 252 | - [perf] improve undo perf 253 | 254 | # 3.0.3 - 2/4/21 255 | - [fix] reverting react-use-gesture update 256 | 257 | # 3.0.2 - 2/4/21 258 | - [feature] add ability to disable proximity 259 | 260 | # 3.0.1 - 2/4/21 261 | - [feature] add ability to disable undo/redo 262 | 263 | # 3.0.0 - 2/4/21 264 | - [breaking] `useProximity` no longer returns `distance`, instead use `onDistanceChange` 265 | - [feature] add `onIntersection` to `useProximity` 266 | - [feature] add `intersectNodeId` to `useProximity` 267 | - [feature] performance improvements for `useProximity` 268 | 269 | # 2.7.0 - 2/3/21 270 | - [fix] fix proximity drop with nesting 271 | - [fix] fix proximity not handling all edges 272 | - [fix] fix addNodeAndEdge not accounting for parent 273 | - [chore] upgrade deps 274 | - [chore] tweak peer dep for framer-motion 275 | 276 | # 2.6.4 - 1/25/21 277 | - [Fix] Fix upsert node function #38 278 | - [Fix] Fix wrong import #39 279 | 280 | # 2.6.3 - 12/29/20 281 | - [Fix] Fix children interface 282 | 283 | # 2.6.2 - 12/29/20 284 | - [Fix] Pass down nodes/edges to children callback 285 | 286 | # 2.6.1 - 12/29/20 287 | - [Fix] Remove foreign objects being default children 288 | 289 | # 2.6.0 - 12/29/20 290 | - [Feature] Add proximity hook 291 | - [Feature] Expand canvas ref options 292 | - [Fix] Can't link nodes who aren't parents 293 | - [Fix] Layout null ref exception 294 | - [Chore] Folder reorg for utils 295 | - [Chore] Folder reorg for docs 296 | - [Chore] Replace `transformation-matrix` with `kld-affine` 297 | - [Chore] Various type improvements 298 | 299 | # 2.5.5 - 12/28/20 300 | - [Fix] Improve `useUndo` types 301 | 302 | # 2.5.5 - 12/28/20 303 | - [Feature] Add `clear` to `useUndo` hook 304 | 305 | # 2.5.3/4 - 12/18/20 306 | - [Chore] Update deps 307 | - [Chore] cleanup deps 308 | 309 | # 2.5.2 - 12/17/20 310 | - [Chore] Remove scss 311 | - [Chore] Update rdk 312 | 313 | # 2.5.1 - 12/17/20 314 | - [Feature] Improve Can Redo Hook Event Args 315 | 316 | # 2.5.0 - 12/17/20 317 | - [Feature] Undo Redo Hook 318 | 319 | # 2.4.5 - 12/16/20 320 | - [Feature] Add adjustable padding for nested nodes and add storybook story for custom nested nodes #30 321 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | Node-based Visualizations for React 5 |

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Open Collective backers and sponsors 23 | 24 |
25 | 26 | --- 27 | 28 | REAFLOW is a modular diagram engine for building static or interactive editors. The library is feature-rich and modular allowing for displaying complex visualizations with total customizability. 29 | 30 | ## 🚀 Quick Links 31 | 32 | - Checkout the [**docs and demos**](https://reaflow.dev) 33 | - Learn about updates from the [Changelog](CHANGELOG.md) 34 | 35 | ## 💎 Other Projects 36 | 37 | - [Reagraph](https://reagraph.dev?utm=reaflow) - Open-source library for large webgl based network graphs. 38 | - [Reablocks](https://reablocks.dev?utm=reaflow) - Open-source component library for React based on Tailwind. 39 | - [Reaviz](https://reaviz.dev?utm=reaflow) - Open-source library for data visulizations for React. 40 | - [Reachat](https://reachat.dev?utm=reaflow) - Open-source library for building LLM/Chat UIs for React. 41 | - 42 | ## ✨ Features 43 | 44 | - Complex automatic layout leveraging ELKJS 45 | - Easy Node/Edge/Port customizations 46 | - Zooming / Panning / Centering controls 47 | - Drag and drop Node/Port connecting and rearranging 48 | - Nesting of Nodes/Edges 49 | - Proximity based Node linking helper 50 | - Node/Edge selection helper 51 | - Undo/Redo helper 52 | 53 | ## 📦 Usage 54 | 55 | Install the package via **NPM**: 56 | 57 | ``` 58 | npm i reaflow --save 59 | ``` 60 | 61 | Install the package via **Yarn**: 62 | 63 | ``` 64 | yarn add reaflow 65 | ``` 66 | 67 | Import the component into your app and add some nodes and edges: 68 | 69 | ```jsx 70 | import React from 'react'; 71 | import { Canvas } from 'reaflow'; 72 | 73 | export default () => ( 74 | 95 | ); 96 | ``` 97 | 98 | ## 🔭 Development 99 | 100 | If you want to run reaflow locally, its super easy! 101 | 102 | - Clone the repo 103 | - `npm i` 104 | - `npm start` 105 | - Browser opens to Storybook page 106 | 107 | ## ❤️ Contributors 108 | 109 | Thanks to all our contributors! 110 | 111 | 112 | -------------------------------------------------------------------------------- /docs/Advanced/CustomNodes.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Custom nodes 6 | 7 | Using HTML within a `Node` component relies on the SVG [`foreignObject`](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject). 8 | 9 | > You do not need to use `xmlns` (XML NameSpace) in the first `div` within `foreignObject`, it's only required when the SVG is a whole document. 10 | 11 | ## Rendering different kinds/types of nodes 12 | 13 | Most apps will need to render different kinds of nodes. The way to go is to use a Node "[Router](https://github.com/Vadorequest/poc-nextjs-reaflow/blob/734018e8135523fccc2c01077294bca0a32ddfbe/src/components/nodes/NodeRouter.tsx#L43)" component, which checks what the node's type is, and renders the related React component. 14 | 15 | ## How does `foreignObject` render in HTML? 16 | 17 | While using `foreignObject` allows building components using usual HTML/CSS, there are a few quirks to consider. 18 | 19 | ```html 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | Node content 30 |
31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 | ``` 45 | 46 | ## Known issues and workarounds 47 | 48 | ### Use `position: fixed` in the first div 49 | 50 | You must apply `position: fixed` to the first `div` element contained by the `foreignObject`, otherwise any child element using `position` [will not be displayed](https://github.com/reaviz/reaflow/issues/44#issuecomment-776883460). 51 | - This issue was the reason why `react-select` and `ChakraUI Select` components wouldn't display properly. 52 | 53 | ### Z-index doesn't have any effect on SVG elements 54 | 55 | The `foreignObject` is still a SVG element, and it is displayed on top of the `rect` (which represents the Node component created by reaflow). 56 | 57 | It is not possible to re-order SVG elements using `z-index`. 58 | The rule of display on the Z index being "the last element is displayed on top of the other element". 59 | 60 | ### The `foreignObject` will steal events (onClick, onEnter, onLeave, etc.) that are bound to the `rect` (Node) 61 | 62 | Because the `foreignObject` displays on top of the `rect` element, it will "steal" events such as onClick, onEnter/onLeave (mouse). 63 | 64 | Those events are provided by default by Reaflow `Canvas` to its `Node` components. 65 | Thus, by using `foreignObject`, **none of the built-in Node events will work anymore**, [unless you set `pointer-events: none` to the `foreignObject` element](https://github.com/reaviz/reaflow/discussions/34). 66 | 67 | Although, even if you disable `pointer-events`, depending on your Node component UI, it might only work for part of the component. 68 | 69 | Many built-in behaviors will be affected because of this, such as: 70 | - Dragging an edge from a node 71 | - Dragging won't work if the click doesn't happen on the `rect` 72 | - Selecting nodes 73 | - The click won't work it doesn't happen on the `rect` 74 | - Using shortcuts for multiple selection [won't work because keyboard events won't be captured](https://github.com/reaviz/reaflow/issues/50) 75 | 76 | That's why, in addition to disabling `pointer-events`, **you might also want [to forward the native events](https://github.com/Vadorequest/poc-nextjs-reaflow/blob/272a23604e0a11ef0726e19091be58ffd5861d62/src/components/nodes/BaseNode.tsx#L357-L360)** (onClick, onEnter, onLeave, onKeyPress, etc.) to the main div (`.node` above). 77 | 78 | By forwarding those events to the first `div`, you'll work around most of the above-mentioned issues. 79 | 80 | ### Entering/leaving a node 81 | 82 | Depending on how complicated your HTML is within the nodes themselves, it might be tough to detect whether you're in a node or not. 83 | 84 | When not using `foreignObject`, it is really straightforward, but when the `foreignObject` contains complex HTML structure, the `onEnter/onLeave` events applied to main `div` will trigger when hovering other elements within that node, leading to a tons of false-positive events. 85 | 86 | At this time, there was no viable solution being reported to work around this issue. 87 | 88 | ## Community examples 89 | 90 | - [Vadorequest/poc-nextjs-reaflow](https://github.com/Vadorequest/poc-nextjs-reaflow) uses custom nodes UI, and [all nodes relies on `foreignObject`](https://github.com/Vadorequest/poc-nextjs-reaflow/blob/287141b94145eec18fb02aab8f00676ae92f1310/src/components/nodes/BaseNode.tsx#L279-L418) 91 | -------------------------------------------------------------------------------- /docs/Advanced/Refs.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Refs 6 | You can get a reference to the `Canvas` instance to perform various 7 | functions externally. 8 | 9 | ```jsx 10 | export const MyCanvas: FC = () => { 11 | const ref = useRef(null); 12 | 13 | useEffect(() => { 14 | // Refs give you ability to do things like: 15 | // ref.current?.centerCanvas() 16 | }, [ref]); 17 | 18 | return ; 19 | }; 20 | ``` 21 | 22 | The `ref` will expose the following interface: 23 | 24 | ```ts 25 | export interface CanvasRef { 26 | /** 27 | * Canvas SVG ref. 28 | */ 29 | svgRef: RefObject; 30 | 31 | /** 32 | * X/Y offset. 33 | */ 34 | xy: [number, number]; 35 | 36 | /** 37 | * Scroll offset. 38 | */ 39 | scrollXY: [number, number]; 40 | 41 | /** 42 | * ELK Layout object. 43 | */ 44 | layout: ElkRoot; 45 | 46 | /** 47 | * Ref to container div. 48 | */ 49 | containerRef: RefObject; 50 | 51 | /** 52 | * Height of the svg. 53 | */ 54 | canvasHeight?: number; 55 | 56 | /** 57 | * Width of the svg. 58 | */ 59 | canvasWidth?: number; 60 | 61 | /** 62 | * Width of the container div. 63 | */ 64 | containerWidth?: number; 65 | 66 | /** 67 | * Height of the container div. 68 | */ 69 | containerHeight?: number; 70 | 71 | /** 72 | * Positions the canvas to the viewport. 73 | */ 74 | positionCanvas?: (position: CanvasPosition, animated?: boolean) => void; 75 | 76 | /** 77 | * Fit the canvas to the viewport. 78 | */ 79 | fitCanvas?: (animated?: boolean) => void; 80 | 81 | /** 82 | * Fit a group of nodes to the viewport. 83 | */ 84 | fitNodes?: (nodeIds: string | string[], animated?: boolean) => void; 85 | 86 | /** 87 | * Scroll to X/Y 88 | */ 89 | setScrollXY?: (xy: [number, number], animated?: boolean) => void; 90 | 91 | /** 92 | * Factor of zoom. 93 | */ 94 | zoom: number; 95 | 96 | /** 97 | * Set a zoom factor of the canvas. 98 | */ 99 | setZoom?: (factor: number) => void; 100 | 101 | /** 102 | * Zoom in on the canvas. 103 | */ 104 | zoomIn?: (zoomFactor?: number) => void; 105 | 106 | /** 107 | * Zoom out on the canvas. 108 | */ 109 | zoomOut?: (zoomFactor?: number) => void; 110 | } 111 | ``` 112 | -------------------------------------------------------------------------------- /docs/Advanced/SSRSupport.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # SSR Support 6 | 7 | The Next.js framework supports SSR, but the `Canvas` component shouldn't be rendered on the server. 8 | _It won't crash the app, but it'd print a lot of noisy warnings (`useEffect`, etc.) on the server, though._ 9 | 10 | `pages/index.tsx`: 11 | ```tsx 12 | import React from 'react'; 13 | import { Canvas } from 'reaflow'; 14 | 15 | const Page = () => ( 16 |
17 |
18 | { 19 | // Don't render the Canvas on the server 20 | typeof window !== 'undefined' && ( 21 | 42 | ) 43 | } 44 |
45 |
46 | ); 47 | 48 | export default Page; 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/Advanced/StateMgmt.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # State Management 6 | 7 | This Storybook demo showcases `React.useState` as state manager, because it's very simple to comprehend and simple apps could actually use it for real. 8 | 9 | Although, as your app growths, you might need to have some **shared state** and use a shared State manager (Redux, Recoil, xState, MobX, etc.). 10 | 11 | ## What state manager should be used with reaflow? 12 | 13 | Reaflow is store-agnostic and doesn't recommend any store manager in particular. The choice is yours. 14 | 15 | If you're unfamiliar with React State managers, we recommend watching [What State Management Library Should I Use with React?](https://www.youtube.com/watch?v=u_o09PD_qAs&feature=emb_logo&ab_channel=LeeRobinson). 16 | 17 | ## Immutability when using `React.useState` 18 | 19 | > If you're using `React.useState`, be careful about immutability. 20 | 21 | While React `useState` will not throw when mutate the state directly, it won't actually work. 22 | 23 | ```tsx 24 | import React, { useState } from 'react'; 25 | import { NodeData } from 'reaflow'; 26 | 27 | const [nodes, setNodes] = useState([]); 28 | 29 | ... 30 | 31 | const newNodes = nodes; // DO NOT DO THAT 32 | newNodes[0] = { id: '1' }; 33 | console.log('updateCurrentNode new nodes', newNodes); // Will print the expected object 34 | 35 | setNodes(newNodes); // Will not crash, but won't actually mutate the state for real 36 | 37 | ``` 38 | 39 | You must not mutate the state directly `nodes`, but clone it first. 40 | 41 | ```tsx 42 | import cloneDeep from 'lodash.clonedeep'; 43 | 44 | const newNodes = nodes; // DO NOT DO THAT 45 | const newNodes = cloneDeep(nodes); // Do that instead 46 | ``` 47 | 48 | You can use the library of your choice to clone the state, `lodash.clonedeep` is a good choice. 49 | 50 | > [This is not a bug, it's expected](https://github.com/reaviz/reaflow/issues/43#issuecomment-774012401). Although, it is very confusing because `newNodes` shows the expected value in the console. 51 | It would be a better developer experience for React to throw an exception when mutating the state directly. _(it's what `recoil` does)_ 52 | 53 | ## Community examples 54 | 55 | - [Vadorequest/poc-nextjs-reaflow](https://github.com/Vadorequest/poc-nextjs-reaflow) uses **[Recoil](https://recoiljs.org/)** as shared State Manager. 56 | -------------------------------------------------------------------------------- /docs/Advanced/Styling.mdx: -------------------------------------------------------------------------------- 1 | import { Canvas, Meta } from '@storybook/addon-docs'; 2 | import * as BasicStories from '../../stories/Basic.stories'; 3 | 4 | 5 | 6 | # Custom Styling 7 | 8 | --- 9 | 10 | The Nodes, Edges, Arrows and Ports can all be overriden to pass custom attributes 11 | to them to allow for custom styling. Below is an example of some of the possibilities 12 | you can achieve. 13 | 14 |
15 | 16 |
17 | 18 | In the above example, we override the `node`, `edge` and `arrow` properties 19 | in the `Canvas` component as shown below (abbreviated for demonstration purposes): 20 | 21 | ```jsx 22 | import { Canvas, Node, Edge, Port, MarkerArrow } from 'reaflow'; 23 | 24 | export const CustomCanvas: FC = () => ( 25 | } 32 | port={} 33 | /> 34 | } 35 | arrow={} 36 | edge={} 37 | /> 38 | ); 39 | ``` 40 | 41 | This allows us to set custom properties on these components or even 42 | override them all together with our own component. This allows for 43 | total control over the child components without loosing all the "magic" 44 | under the hood that controls them. 45 | -------------------------------------------------------------------------------- /docs/Community.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Community 6 | 7 | ## Opensource 8 | Examples of open-source projects using reaflow: 9 | 10 | - [POC Next.js + Reaflow](https://github.com/Vadorequest/poc-nextjs-reaflow): Uses 11 | Reaflow to build a "**decision tree**". Advanced use-case with different kinds 12 | of nodes with heavy usage of `foreignObject` to display actual HTML within the nodes themselves. 13 | Hosted on Vercel and built with Next.js 10. 14 | 15 | - [JSON Visio](https://github.com/AykutSarac/jsonvisio.com): Uses 16 | Reaflow to mirror JSON onto "**graphs**". JSON Visio is data visualization tool for your json data which seamlessly illustrates your data on graphs without having to restructure anything, paste directly or import file. 17 | -------------------------------------------------------------------------------- /docs/Contributing.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Contributing 6 | 7 | ## Getting started 8 | 9 | - `npm i` 10 | - `npm start` opens Storybook documentation on [localhost:9009](http://localhost:9009) 11 | 12 | ## Using the `reaflow` package locally as a dependency 13 | 14 | If you're working on some app **that uses `reaflow` as a dependency**, and if you want to quickly update the code of `reaflow` locally (without publishing changes to NPM), you basically have two choices: 15 | - [Use Git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) 16 | - Use [NPM](https://docs.npmjs.com/cli/v6/commands/npm-link)/[Yarn](https://classic.yarnpkg.com/en/docs/cli/link/) `link` feature 17 | 18 | At this time, we don't have an official recommandation about which one to use, it's up to you. 19 | 20 | ### Using `link` 21 | 22 | Using NPM/Yarn link will link your dependencies on your main project to your local `reaflow` folder. 23 | 24 | > :warning: Beware: The "link" might break quite often when installing new dependencies on your main project, and you'll need to unlink and link back again to fix it. 25 | 26 | #### Configuration example (with Yarn): 27 | 28 | 1. From your local `reaflow` folder, run `yarn install` and then `yarn link:reaflow`, which will create the links in yarn 29 | 1. From your main project folder, run `yarn link reaflow && yarn link react && yarn link react-dom` 30 | - _We suggest adding it as a command in your package.json: `"link:reaflow": "yarn link reaflow && yarn link react && yarn link react-dom"`_ - [See example](https://github.com/Vadorequest/poc-nextjs-reaflow/blob/cf8499008c4b70946d82803741401fac48264a5b/package.json#L8) 31 | 1. **If you edit the `reaflow` files** and need to recompile the project then run `yarn build:watch` from your local `reaflow` folder (changes will be applied immediately, you'll benefit from hot-reloading, etc.) 32 | 33 | Once everything is linked, your main project will use the files in your local `reaflow` folder. 34 | While `yarn build:watch` is running, your changes from `reaflow` will automatically apply to your main project. 35 | 36 | #### :warning When the link breaks 37 | 38 | When you install new dependencies in your main project, it might break the links with `reaflow`. 39 | 40 | To fix it, you'll need to remove your `node_modules` folder and reinstall all packages **on both** your main project and `reaflow`, starting with `reaflow`. 41 | 1. From `reaflow` folder: `rm -rf node_modules && yarn && yarn link:reaflow` 42 | 1. From your main project folder: `rm -rf node_modules && yarn && yarn link:reaflow` 43 | -------------------------------------------------------------------------------- /docs/GettingStarted/Basics.mdx: -------------------------------------------------------------------------------- 1 | import { Canvas, Meta } from '@storybook/addon-docs'; 2 | import * as BasicStories from '../../stories/Basic.stories'; 3 | 4 | 5 | 6 | # Basics 7 | 8 | ## Your First Diagram 9 | Let's build our first diagram by defining some `nodes` and `edges`. 10 | Nodes are the blocks and edges are the relationships between the blocks. 11 | 12 | The data shapes require one property of `id` but you can pass `text` 13 | or `icon` to them to show some sort of indication what it represents. 14 | The `id` property can be any `string` but for demonstration purposes 15 | we are going to use some basic strings. 16 | 17 | ```js 18 | const nodes = [ 19 | { 20 | id: '1', 21 | text: '1' 22 | }, 23 | { 24 | id: '2', 25 | text: '2' 26 | } 27 | ]; 28 | 29 | const edges = [ 30 | { 31 | id: '1-2', 32 | from: '1', 33 | to: '2' 34 | } 35 | ]; 36 | ``` 37 | 38 | These shapes above will create two elements `1` and `2` and create 39 | a relationship between them. Once we have this defined, we can simply 40 | pass these properties to the `Canvas` and it will do the rest! 41 | 42 | ```jsx 43 | import React from 'react'; 44 | import { Canvas } from 'reaflow'; 45 | 46 | export const MyDiagram = () => ( 47 | 51 | ); 52 | ``` 53 | 54 | This will render a graph like this: 55 | 56 | -------------------------------------------------------------------------------- /docs/GettingStarted/Components.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, ArgTypes } from '@storybook/addon-docs'; 2 | import { Canvas, Node, Edge, Port, MarkerArrow, Add, Remove, Label, Icon } from '../../src'; 3 | 4 | 5 | 6 | # Components 7 | The library uses a variety of components internally that you can 8 | customize. 9 | 10 | - Canvas - The root component 11 | - Node - The node element component 12 | - Edge - The connector between nodes 13 | - Port - The exit points of a node 14 | - Marker Arrow - The shape used to connect show direction on the edges 15 | - Add - The shape used on edges to show dropping between edges 16 | - Remove - The shape used on nodes and edges to remove each 17 | - Label - The component used by nodes and edges to show text 18 | - Icon - The component used by nodes to show an icon 19 | 20 | Below are the props from each component. 21 | 22 | ## `Canvas` 23 | 24 | 25 | 26 | ## `Node` 27 | 28 | 29 | ## `Edge` 30 | 31 | 32 | ## `Port` 33 | 34 | 35 | ## `MarkerArrow` 36 | 37 | 38 | ## `Add` 39 | 40 | 41 | ## `Remove` 42 | 43 | 44 | ## `Label` 45 | 46 | 47 | ## `Icon` 48 | 49 | -------------------------------------------------------------------------------- /docs/GettingStarted/DataShapes.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Data Shapes 6 | The graph is made up of 3 basic data shape objects you 7 | can pass to the graph. 8 | 9 | - `NodeData` - The element block which renders 10 | - `EdgeData` - The link between Nodes 11 | - `PortData` - A specific enter/exit block to link between Nodes 12 | 13 | ## Node 14 | ```ts 15 | export interface NodeData { 16 | /** 17 | * Unique ID for the node. 18 | */ 19 | id: string; 20 | 21 | /** 22 | * Whether the node is disabled or not. 23 | */ 24 | disabled?: boolean; 25 | 26 | /** 27 | * Text label for the node. 28 | */ 29 | text?: any; 30 | 31 | /** 32 | * Optional height attribute. If not passed with calculate 33 | * default sizes using text. 34 | */ 35 | height?: number; 36 | 37 | /** 38 | * Optional width attribute. If not passed with calculate 39 | * default sizes using text. 40 | */ 41 | width?: number; 42 | 43 | /** 44 | * Parent node id for nesting. 45 | */ 46 | parent?: string; 47 | 48 | /** 49 | * List of ports. 50 | */ 51 | ports?: PortData[]; 52 | 53 | /** 54 | * Icon for the node. 55 | */ 56 | icon?: IconData; 57 | 58 | /** 59 | * Padding for the node. 60 | */ 61 | nodePadding?: number | [number, number] | [number, number, number, number]; 62 | 63 | /** 64 | * Data for the node. 65 | */ 66 | data?: T; 67 | 68 | /** 69 | * CSS classname for the node. 70 | */ 71 | className?: string; 72 | 73 | /** 74 | * ELK layout options. 75 | */ 76 | layoutOptions?: ElkNodeLayoutOptions; 77 | 78 | /** 79 | * Whether the node can be clicked. 80 | */ 81 | selectionDisabled?: boolean; 82 | } 83 | ``` 84 | 85 | The node also has a `IconData` shape: 86 | 87 | ```ts 88 | export interface IconData { 89 | /** 90 | * URL for the icon. 91 | */ 92 | url: string; 93 | 94 | /** 95 | * Height of the icon. 96 | */ 97 | height: number; 98 | 99 | /** 100 | * Width of the icon. 101 | */ 102 | width: number; 103 | } 104 | ``` 105 | 106 | ## Edge 107 | ```ts 108 | export interface EdgeData { 109 | /** 110 | * Unique ID of the edge. 111 | */ 112 | id: string; 113 | 114 | /** 115 | * Whether the edge is disabled or not. 116 | */ 117 | disabled?: boolean; 118 | 119 | /** 120 | * Text label for the edge. 121 | */ 122 | text?: any; 123 | 124 | /** 125 | * ID of the from node. 126 | */ 127 | from?: string; 128 | 129 | /** 130 | * ID of the to node. 131 | */ 132 | to?: string; 133 | 134 | /** 135 | * Optional ID of the from port. 136 | */ 137 | fromPort?: string; 138 | 139 | /** 140 | * Optional ID of the to port. 141 | */ 142 | toPort?: string; 143 | 144 | /** 145 | * Data about the edge. 146 | */ 147 | data?: T; 148 | 149 | /** 150 | * CSS Classname for the edge. 151 | */ 152 | className?: string; 153 | 154 | /** 155 | * Optional arrow head type. 156 | */ 157 | arrowHeadType?: any; 158 | 159 | /** 160 | * Parent of the edge for nesting. 161 | */ 162 | parent?: string; 163 | 164 | /** 165 | * Whether the edge can be clicked. 166 | */ 167 | selectionDisabled?: boolean; 168 | } 169 | ``` 170 | 171 | ## Port 172 | ```ts 173 | export interface PortData { 174 | /** 175 | * Unique ID of the port. 176 | */ 177 | id: string; 178 | 179 | /** 180 | * Height of the port. 181 | */ 182 | height: number; 183 | 184 | /** 185 | * Width of the port. 186 | */ 187 | width: number; 188 | 189 | /** 190 | * Whether the port is visually hidden or not. 191 | */ 192 | hidden?: boolean; 193 | 194 | /** 195 | * Classname for the port. 196 | */ 197 | className?: string; 198 | 199 | /** 200 | * Alignment of the port. 201 | */ 202 | alignment?: 'CENTER'; 203 | 204 | /** 205 | * Side the port is located. 206 | */ 207 | side: 'NORTH' | 'SOUTH' | 'EAST' | 'WEST'; 208 | 209 | /** 210 | * Port is disabled. 211 | */ 212 | disabled?: boolean; 213 | } 214 | ``` 215 | -------------------------------------------------------------------------------- /docs/GettingStarted/Installing.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Getting Started 6 | 7 | ## Installing 8 | 9 | You can install REAFLOW with [NPM](https://www.npmjs.com/package/reaflow) or Yarn. 10 | 11 | - NPM: `npm install reaflow --save` 12 | - YARN: `yarn add reaflow` 13 | 14 | ## Compatibility 15 | 16 | REAFLOW is compatible with React v16+ and works with ReactDOM. React Native is not supported at this time. 17 | 18 | ## Developing 19 | 20 | If you want to run the project locally, its really easy! 21 | 22 | The project uses Storybook for its demos and development 23 | environment. To run it locally: 24 | 25 | - Clone repo 26 | - `npm install` 27 | - `npm start` 28 | 29 | Once started the browser will open to the storybook url. 30 | From here you can tweak the charts and see them build 31 | and reload in real time. 32 | 33 | We use Rollup to build and package for distribution. 34 | You can run this by doing `npm run build` and it will 35 | create a `dist` folder with the type definitions, bundled 36 | javascript and css files. 37 | -------------------------------------------------------------------------------- /docs/GettingStarted/LinkingNodes.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Linking Nodes 6 | reaflow allows users to edit graphs by adding/removing and 7 | linking nodes together dynamically. There are 2 event handlers 8 | you can subscribe to for checking if a node can be linked 9 | and performing the actual link. 10 | 11 | - `onNodeLinkCheck` - Function you can implement to return true/false if node can link 12 | - `onNodeLink` - Function you implement to perform the actual link 13 | 14 | Below is a example showing how one might use these two functions together: 15 | 16 | ```tsx 17 | import React, { useState } from 'react'; 18 | import { Canvas, hasLink, NodeData, EdgeData } from 'reaflow'; 19 | 20 | export default () => { 21 | const [nodes, setNodes] = useState([ 22 | { 23 | id: '1', 24 | text: '1' 25 | }, 26 | { 27 | id: '2', 28 | text: '2' 29 | } 30 | ]); 31 | const [edges, setEdges] = useState([]); 32 | 33 | return ( 34 | { 38 | return !hasLink(edges, from, to); 39 | }} 40 | onNodeLink={(event, from, to) => { 41 | const id = `${from.id}-${to.id}`; 42 | 43 | setEdges([ 44 | ...edges, 45 | { 46 | id, 47 | from: from.id, 48 | to: to.id 49 | } 50 | ]); 51 | }} 52 | /> 53 | ) 54 | }; 55 | ``` 56 | 57 | In the example, we take advantage of one of the 58 | helpers in the library called `hasLink` which will 59 | return if the node is already linked. 60 | 61 | In order to actually add the link, you can 62 | implement the `onNodeLink` which is a function 63 | that should set a new edge with pointers to the 64 | source and target node. 65 | -------------------------------------------------------------------------------- /docs/Helpers/Proximity.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Story } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Proximity 6 | 7 | --- 8 | 9 | The `useProximity` hook allows you to use the distance 10 | from a node to link them rather than when the mouse 11 | enter/leaves the node. This is most useful for editor 12 | style interfaces when a user is dragging a element 13 | onto the canvas and wants to drop the node intelligently. 14 | 15 | ## Getting Started 16 | To get started, let's import the `useProximity` hook 17 | and setup a drag and drop interface using `framer-motion`. 18 | Below we will create a simple example ( referrer to demo for 19 | functional demo ) and walk through each step. The example 20 | might seem large but it's meant to be overly verbose for 21 | educational purposes. 22 | 23 | ```tsx 24 | import React, { useState, useRef } from 'react'; 25 | import { useProximity, CanvasRef, addNodeAndEdge, Canvas, EdgeData, NodeData } from 'reaflow'; 26 | import { useDragControls } from 'framer-motion'; 27 | 28 | const App = () => { 29 | // This is the controls from framer-motion for dragging 30 | const dragControls = useDragControls(); 31 | 32 | // We need to create a reference to the canvas so we can pass 33 | // it to the hook so it has knowledge about the canvas 34 | const canvasRef = useRef(null); 35 | 36 | // We need to determine if we can drop the element onto the canvas 37 | const [droppable, setDroppable] = useState(false); 38 | 39 | // Let's save the node that we have "entered" so that when the user 40 | // ends the drag we can link it 41 | const [enteredNode, setEnteredNode] = useState(null); 42 | 43 | // Just some empty arrays for demo purposes, this would normally 44 | // be whatever your graph contains 45 | const [edges, setEdges] = useState([]); 46 | const [nodes, setNodes] = useState([]); 47 | 48 | const { 49 | // Drag event handlers we need to hook into our drag 50 | onDragStart: onProximityDragStart, 51 | onDrag: onProximityDrag, 52 | onDragEnd: onProximityDragEnd 53 | } = useProximity({ 54 | // The ref we defined above 55 | canvasRef, 56 | onMatchChange: (match: string | null) => { 57 | // If there is a match, let's find the node in 58 | // our models here 59 | let matchNode: NodeData | null = null; 60 | if (match) { 61 | matchNode = nodes.find(n => n.id === match); 62 | } 63 | 64 | // Now let's set the matched node 65 | setEnteredNode(matchNode); 66 | 67 | // We set this seperately from the enteredNode because 68 | // you might want to do some validation on whether you can drop or not 69 | setDroppable(matchNode !== null); 70 | } 71 | }); 72 | 73 | const onDragStart = (event) => { 74 | // Call the hook's drag start 75 | onProximityDragStart(event); 76 | 77 | // Have the drag snap to our cursor 78 | dragControls.start(event, { snapToCursor: true }); 79 | }; 80 | 81 | const onDragEnd = (event) => { 82 | // Call our proximity to let it know we are done dragging 83 | onProximityDragEnd(event); 84 | 85 | // If its droppable let's add it to the canvas 86 | if (droppable) { 87 | // Let's use our addNodeAndEdge helper function 88 | const result = addNodeAndEdge( 89 | nodes, 90 | edges, 91 | // Make this whatever you want to drop 92 | { 93 | id: 'random', 94 | text: 'random' 95 | }, 96 | // Let's add it using the closest node 97 | enteredNode 98 | ); 99 | 100 | // Update our edges and nodes 101 | setNodes(result.nodes); 102 | setEdges(result.edges); 103 | } 104 | 105 | // Reset the drop state 106 | setDroppable(false); 107 | setEnteredNode(null); 108 | }; 109 | 110 | return ( 111 |
112 | 113 | Drag Me! 114 | 115 | 121 | {activeDrag && ( 122 |
123 | Dragger! 124 |
125 | )} 126 |
127 | 132 |
133 | ) 134 | } 135 | ``` 136 | 137 | Note: You don't have to use `framer-motion` but since `reaflow` 138 | uses it internally its probably best to stick with that. 139 | 140 | ## Interfaces 141 | The `useProximity` hook accepts the following properties: 142 | 143 | ```ts 144 | export interface ProximityProps { 145 | /** 146 | * Disable proximity or not. 147 | */ 148 | disabled?: boolean; 149 | 150 | /** 151 | * Min distance required before match is made. Default is 40. 152 | */ 153 | minDistance?: number; 154 | 155 | /** 156 | * Ref pointer to the canvas. 157 | */ 158 | canvasRef?: RefObject; 159 | 160 | /** 161 | * Distance from the match. 162 | */ 163 | onDistanceChange?: (distance: number | null) => void; 164 | 165 | /** 166 | * When a match state has changed. 167 | */ 168 | onMatchChange?: (matche: string | null, distance: number | null) => void; 169 | 170 | /** 171 | * When the pointer intersects a node. 172 | */ 173 | onIntersects?: (matche: string | null) => void; 174 | } 175 | ``` 176 | 177 | and it returns the following interface: 178 | 179 | ```ts 180 | export interface ProximityResult { 181 | /** 182 | * The matched id of the node. 183 | */ 184 | match: string | null; 185 | 186 | /** 187 | * Event for drag started. 188 | */ 189 | onDragStart: (event: PointerEvent) => void; 190 | 191 | /** 192 | * Event for active dragging. 193 | */ 194 | onDrag: (event: PointerEvent) => void; 195 | 196 | /** 197 | * Event for drag ended. 198 | */ 199 | onDragEnd: (event: PointerEvent) => void; 200 | } 201 | ``` 202 | 203 | ## How it works 204 | Under the hood it will coorelate the mouse pointer position 205 | to the canvas ( including offset and zoom ) using [kld-affine](https://github.com/thelonious/kld-affine) 206 | which is a geometric matrices library. 207 | 208 | Next as the user drags it will measure the distance of all the nodes 209 | relative to the mouse pointer position and if find the node with 210 | the closest distance and determine if it falls in the threshold 211 | which by default is `40`. 212 | -------------------------------------------------------------------------------- /docs/Helpers/Selection.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Story } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Selection 6 | 7 | --- 8 | 9 | Out of the box, reaflow supports selection handled either manually or 10 | semi-automatic with hotkeys using a hook and [reakeys](https://github.com/reaviz/reakeys). 11 | 12 | ## Selection Hook 13 | The `useSelection` hooks will automatically manage selection state and hotkeys 14 | for you. To set it up, simply import the `useSelection` hook and pass the hook 15 | the `nodes`, `edges` and any default selections you like. 16 | 17 | The hook accepts the following: 18 | 19 | ```ts 20 | export interface SelectionProps { 21 | /** 22 | * Current selections. 23 | */ 24 | selections?: string[]; 25 | 26 | /** 27 | * Node datas. 28 | */ 29 | nodes?: NodeData[]; 30 | 31 | /** 32 | * Edge datas. 33 | */ 34 | edges?: EdgeData[]; 35 | 36 | /** 37 | * Hotkey types. 38 | */ 39 | hotkeys?: HotkeyTypes[]; 40 | 41 | /** 42 | * Disabled or not. 43 | */ 44 | disabled?: boolean; 45 | 46 | /** 47 | * On selection change. 48 | */ 49 | onSelection?: (value: string[]) => void; 50 | 51 | /** 52 | * On data change. 53 | */ 54 | onDataChange?: (nodes: NodeData[], edges: EdgeData[]) => void; 55 | } 56 | ``` 57 | 58 | and returns the following: 59 | 60 | ```ts 61 | export interface SelectionResult { 62 | /** 63 | * Selections id array. 64 | */ 65 | selections: string[]; 66 | 67 | /** 68 | * Clear selections method. 69 | */ 70 | clearSelections: (value?: string[]) => void; 71 | 72 | /** 73 | * A selection method. 74 | */ 75 | addSelection: (value: string) => void; 76 | 77 | /** 78 | * Remove selection method. 79 | */ 80 | removeSelection: (value: string) => void; 81 | 82 | /** 83 | * Toggle existing selection on/off method. 84 | */ 85 | toggleSelection: (value: string) => void; 86 | 87 | /** 88 | * Set internal selections. 89 | */ 90 | setSelections: (value: string[]) => void; 91 | 92 | /** 93 | * On click event pass through. 94 | */ 95 | onClick?: ( 96 | event: React.MouseEvent, 97 | data: any 98 | ) => void; 99 | 100 | /** 101 | * On canvas click event pass through. 102 | */ 103 | onCanvasClick?: (event?: React.MouseEvent) => void; 104 | 105 | /** 106 | * On keydown event pass through. 107 | */ 108 | onKeyDown?: (event: React.KeyboardEvent) => void; 109 | } 110 | ``` 111 | 112 | The hotkeys that are bound via this hook are: 113 | 114 | - `ctrl/meta + a`: Select all nodes 115 | - `escape`: Defoucs selections 116 | - `ctrl/meta + click`: Toggle node selection 117 | - `backspace`: Remove selected nodes 118 | 119 | Below is a typical setup of where you define the selection hook. 120 | 121 | ```ts 122 | import { NodeData, EdgeData, useSelection } from 'reaflow'; 123 | 124 | const [nodes, setNodes] = useState([ 125 | { 126 | id: '1', 127 | text: 'Node 1' 128 | }, 129 | { 130 | id: '2', 131 | text: 'Node 2' 132 | } 133 | ]); 134 | 135 | const [edges, setEdges] = useState([ 136 | { 137 | id: '1-2', 138 | from: '1', 139 | to: '2' 140 | } 141 | ]); 142 | 143 | const { selections, onCanvasClick, onClick, onKeyDown, clearSelections } = useSelection({ 144 | nodes, 145 | edges, 146 | onDataChange: (n, e) => { 147 | console.info('Data changed', n, e); 148 | setNodes(n); 149 | setEdges(e); 150 | }, 151 | onSelection: (s) => { 152 | console.info('Selection', s); 153 | } 154 | }); 155 | ``` 156 | 157 | Once defined you can pass these onto the canvas like: 158 | 159 | ```jsx 160 | { 169 | const result = removeAndUpsertNodes(nodes, edges, node); 170 | setEdges(result.edges); 171 | setNodes(result.nodes); 172 | clearSelections(); 173 | }} 174 | /> 175 | } 176 | edge={ 177 | 180 | } 181 | onCanvasClick={onCanvasClick} 182 | /> 183 | ``` 184 | 185 | and the hook will handle setting the rest up for you. In the `onSelection` 186 | block you can define custom rules for selection as well. 187 | 188 | ## Manual Selection Management 189 | If you don't wish to use the `useSelection` hook you can handle the selections 190 | yourself manually. This is as simple as defining a state for the selections 191 | and just passing it on. 192 | 193 | ```jsx 194 | import { NodeData, EdgeData } from 'reaflow'; 195 | 196 | const [selections, setSelections] = useState([]); 197 | 198 | const [nodes] = useState([ 199 | { 200 | id: '1', 201 | text: 'Node 1' 202 | }, 203 | { 204 | id: '2', 205 | text: 'Node 2' 206 | } 207 | ]); 208 | 209 | const [edges] = useState([ 210 | { 211 | id: '1-2', 212 | from: '1', 213 | to: '2' 214 | } 215 | ]); 216 | ``` 217 | 218 | then similar to how we passed the selections with the hook, we do the same 219 | thing with the manual selection state. 220 | 221 | ```jsx 222 | { 229 | console.log('Selecting Node', event, node); 230 | setSelections([node.id]); 231 | }} 232 | /> 233 | } 234 | edge={ 235 | { 237 | console.log('Selecting Edge', event, edge); 238 | setSelections([edge.id]); 239 | }} 240 | /> 241 | } 242 | onCanvasClick={(event) => { 243 | console.log('Canvas Clicked', event); 244 | setSelections([]); 245 | }} 246 | onLayoutChange={layout => console.log('Layout', layout)} 247 | /> 248 | ``` 249 | -------------------------------------------------------------------------------- /docs/Helpers/Undo.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Undo Redo 6 | 7 | --- 8 | 9 | Out of the box, reaflow supports undo and redo functionality. This can be 10 | done optionally using the `useUndo` hook. It also supports hotkeys using 11 | [reakeys](https://github.com/reaviz/reakeys). 12 | 13 | ## Getting Started 14 | The `useUndo` hooks will automatically manage the history of the 15 | nodes and edges, all you need to do is manage the current state. 16 | The hook accepts the following arguments: 17 | 18 | ```ts 19 | export interface UndoProps { 20 | /** 21 | * Current node datas. 22 | */ 23 | nodes: NodeData[]; 24 | 25 | /** 26 | * Current edge datas. 27 | */ 28 | edges: EdgeData[]; 29 | 30 | /** 31 | * Max history count. 32 | */ 33 | maxHistory?: number; 34 | 35 | /** 36 | * Disabled or not. 37 | */ 38 | disabled?: boolean; 39 | 40 | /** 41 | * On undo/redo event handler. 42 | */ 43 | onUndoRedo: (state: UndoRedoEvent) => void; 44 | } 45 | ``` 46 | 47 | To implement the hook all you need to do is import it like: 48 | 49 | ```tsx 50 | import { useUndo, EdgeData, Canvas, NodeData } from 'reaflow'; 51 | 52 | const MyApp = () => { 53 | const [nodes, setNodes] = useState([]); 54 | const [edges, setEdges] = useState([]); 55 | 56 | const { undo, redo, canUndo, canRedo } = useUndo({ 57 | nodes, 58 | edges, 59 | onUndoRedo: (state: UndoRedoEvent) => { 60 | console.log('Undo / Redo', state); 61 | // Note: This is where YOUR state comes into play 62 | setEdges(state.edges); 63 | setNodes(state.nodes); 64 | } 65 | }); 66 | 67 | return ; 68 | } 69 | ``` 70 | 71 | The `UndoRedoEvent` interface looks like: 72 | 73 | ```ts 74 | export interface UndoRedoEvent { 75 | /** 76 | * Updated node datas. 77 | */ 78 | nodes?: NodeData[]; 79 | 80 | /** 81 | * Updated edge datas. 82 | */ 83 | edges?: EdgeData[]; 84 | 85 | /** 86 | * Type of change. 87 | */ 88 | type: 'undo' | 'redo' | 'clear'; 89 | 90 | /** 91 | * Whether you can undo now. 92 | */ 93 | canUndo: boolean; 94 | 95 | /** 96 | * Whether you can redo now. 97 | */ 98 | canRedo: boolean; 99 | } 100 | ``` 101 | 102 | Now anytime you make a change to the `nodes` or `edges` the 103 | hook will update the internal history and then you can 104 | call `undo` or `redo` functions to retrieve those states. 105 | 106 | The hook returns other properties such as: 107 | 108 | ```ts 109 | export interface UndoResult { 110 | /** 111 | * Can undo or not. 112 | */ 113 | canUndo: boolean; 114 | 115 | /** 116 | * Can redo or not. 117 | */ 118 | canRedo: boolean; 119 | 120 | /** 121 | * Count of existing changes. 122 | */ 123 | count: () => number; 124 | 125 | /** 126 | * Clear state and save first element of new state. 127 | */ 128 | clear: (nodes: NodeData[]; edges: EdgeData[]) => void; 129 | 130 | /** 131 | * Get history of state. 132 | */ 133 | history: () => { nodes: NodeData[]; edges: EdgeData[] }[]; 134 | 135 | /** 136 | * Perform an redo. 137 | */ 138 | redo: () => void; 139 | 140 | /** 141 | * Perform a undo. 142 | */ 143 | undo: () => void; 144 | } 145 | ``` 146 | -------------------------------------------------------------------------------- /docs/Introduction.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | import img from './assets/teaser.png'; 3 | 4 | 5 | 6 |
7 | 11 |
12 | Node-based Visualizations for React 13 |
14 | 15 | 16 | 17 |    18 | 19 | 20 | 21 |    22 | 23 | 24 | 25 |    26 | 27 | 28 | 29 |    30 | 31 | 32 | 33 |    34 | 35 | GitHub stars 36 | 37 | 38 | --- 39 | 40 | 41 | Unify design system 42 | 43 | 44 | REAFLOW is a modular diagram engine for build static or interactive editors. 45 | The library is feature rich and modular allowing for displaying complex 46 | visualizations with total customizability. 47 | 48 |
49 | -------------------------------------------------------------------------------- /docs/Support.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Support 6 | 7 | We encourage users to make PRs and [log tickets](https://github.com/reaviz/reaflow/issues) for issues. We will try to respond to them as quickly as possible 8 | but if you are in need of extra support, our team at [Good Code](https://goodcode.us?utm=reaflow) is here to help. Reach out 9 | to us today to discuss our packages and support plans. 10 | -------------------------------------------------------------------------------- /docs/Utils/Crud.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Getting Started with Utils 6 | 7 | There a variety of common interactions you want to make 8 | with the canvas and graph. reaflow has some helpers out of 9 | the box to help manage those typical operations. Those 10 | helpers are broken up into two categories: 11 | 12 | - CRUD - Dealing with manipulating nodes/edges 13 | - Graph - Dealing with traversing the graph 14 | - Extended Utils - More use-case focused helpers 15 | 16 | #### Notes 17 | > These helpers are very generic and meant to help people getting started, but they're not meant to cover specific use-cases. 18 | > 19 | > You should copy them and adapt them to your own needs if they don't cover your needs. [See discussion](https://github.com/reaviz/reaflow/issues/47#issuecomment-775919579). 20 | > 21 | > There are advanced examples in [Extending Utils](/?path=/story/docs-utils-extending--page) page. 22 | -------------------------------------------------------------------------------- /docs/Utils/Extending.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Extended utils 6 | 7 | The basic utils will not cover all use-cases. 8 | 9 | Below are a few examples of advanced use-cases **with first-class support for connecting nodes through their `ports`**, built on top of the default built-in utils. 10 | 11 | > _Courtesy of [Vadorequest/poc-nextjs-reaflow](https://github.com/Vadorequest/poc-nextjs-reaflow/blob/91275644e9c3d0ed8cf7c2c80d49ef526b5e5069/src/utils/nodes.ts)._ 12 | 13 | ```tsx 14 | import BaseEdgeData from '../types/BaseEdgeData'; 15 | import BaseNodeData from '../types/BaseNodeData'; 16 | import BasePortData from '../types/BasePortData'; 17 | import { CanvasDataset } from '../types/CanvasDataset'; 18 | import { createEdge } from './edges'; 19 | import { 20 | getDefaultFromPort, 21 | getDefaultToPort, 22 | } from './ports'; 23 | 24 | /** 25 | * Add a node and optional edge, and automatically link their ports. 26 | * 27 | * Automatically connects the fromNode (left node) using its EAST port (right side) to the newNode (right node) using it's WEST port (left side). 28 | * 29 | * Similar to reaflow.addNodeAndEdge utility. 30 | */ 31 | export function addNodeAndEdgeThroughPorts( 32 | nodes: BaseNodeData[], 33 | edges: BaseEdgeData[], 34 | newNode: BaseNodeData, 35 | fromNode?: BaseNodeData, 36 | toNode?: BaseNodeData, 37 | fromPort?: BasePortData, 38 | toPort?: BasePortData, 39 | ): CanvasDataset { 40 | // The default destination node is the newly created node 41 | toNode = toNode || newNode; 42 | 43 | const newEdge: BaseEdgeData = createEdge( 44 | fromNode, 45 | toNode, 46 | getDefaultFromPort(fromNode, fromPort), 47 | getDefaultToPort(toNode, toPort), 48 | ); 49 | 50 | return { 51 | nodes: [...nodes, newNode], 52 | edges: [ 53 | ...edges, 54 | ...(fromNode ? 55 | [ 56 | newEdge, 57 | ] 58 | : []), 59 | ], 60 | }; 61 | } 62 | 63 | /** 64 | * Helper function for upserting a node in a edge (split the edge in 2 and put the node in between), and automatically link their ports. 65 | * 66 | * Automatically connects the left edge to the newNode using it's WEST port (left side). 67 | * Automatically connects the right edge to the newNode using it's EAST port (right side). 68 | * 69 | * Similar to reaflow.upsertNode utility. 70 | */ 71 | export function upsertNodeThroughPorts( 72 | nodes: BaseNodeData[], 73 | edges: BaseEdgeData[], 74 | edge: BaseEdgeData, 75 | newNode: BaseNodeData, 76 | ): CanvasDataset { 77 | const oldEdgeIndex = edges.findIndex(e => e.id === edge.id); 78 | const edgeBeforeNewNode = { 79 | ...edge, 80 | id: `${edge.from}-${newNode.id}`, 81 | to: newNode.id, 82 | }; 83 | const edgeAfterNewNode = { 84 | ...edge, 85 | id: `${newNode.id}-${edge.to}`, 86 | from: newNode.id, 87 | }; 88 | 89 | if (edge.fromPort && edge.toPort) { 90 | const fromLeftNodeToWestPort: BasePortData | undefined = newNode?.ports?.find((port: BasePortData) => port?.side === 'WEST'); 91 | const fromRightNodeToEastPort: BasePortData | undefined = newNode?.ports?.find((port: BasePortData) => port?.side === 'EAST'); 92 | 93 | edgeBeforeNewNode.fromPort = edge.fromPort; 94 | edgeBeforeNewNode.toPort = fromLeftNodeToWestPort?.id || `${newNode.id}-to`; 95 | 96 | edgeAfterNewNode.fromPort = fromRightNodeToEastPort?.id || `${newNode.id}-from`; 97 | edgeAfterNewNode.toPort = edge.toPort; 98 | } 99 | 100 | edges.splice(oldEdgeIndex, 1, edgeBeforeNewNode, edgeAfterNewNode); 101 | 102 | return { 103 | nodes: [...nodes, newNode], 104 | edges: [...edges], 105 | }; 106 | } 107 | 108 | /** 109 | * Removes a node between two edges and merges the two edges into one, and automatically link their ports. 110 | * 111 | * Similar to reaflow.removeAndUpsertNodes utility. 112 | */ 113 | export function removeAndUpsertNodesThroughPorts( 114 | nodes: BaseNodeData[], 115 | edges: BaseEdgeData[], 116 | removeNodes: BaseNodeData | BaseNodeData[], 117 | onNodeLinkCheck?: ( 118 | newNodes: BaseNodeData[], 119 | newEdges: BaseEdgeData[], 120 | from: BaseNodeData, 121 | to: BaseNodeData, 122 | port?: BasePortData, 123 | ) => undefined | boolean, 124 | ): CanvasDataset { 125 | if (!Array.isArray(removeNodes)) { 126 | removeNodes = [removeNodes]; 127 | } 128 | 129 | const nodeIds = removeNodes.map((n) => n.id); 130 | const newNodes = nodes.filter((n) => !nodeIds.includes(n.id)); 131 | const newEdges = edges.filter( 132 | (e: BaseEdgeData) => !nodeIds.includes(e?.from as string) && !nodeIds.includes(e?.to as string), 133 | ); 134 | 135 | for (const nodeId of nodeIds) { 136 | const sourceEdges = edges.filter((e) => e.to === nodeId); 137 | const targetEdges = edges.filter((e) => e.from === nodeId); 138 | 139 | for (const sourceEdge of sourceEdges) { 140 | for (const targetEdge of targetEdges) { 141 | const sourceNode = nodes.find((n) => n.id === sourceEdge.from); 142 | const targetNode = nodes.find((n) => n.id === targetEdge.to); 143 | 144 | if (sourceNode && targetNode) { 145 | const canLink = onNodeLinkCheck?.( 146 | newNodes, 147 | newEdges, 148 | sourceNode, 149 | targetNode, 150 | ); 151 | 152 | if (canLink === undefined || canLink) { 153 | const fromPort: BasePortData | undefined = sourceNode?.ports?.find((port: BasePortData) => port?.side === 'EAST'); 154 | const toPort: BasePortData | undefined = targetNode?.ports?.find((port: BasePortData) => port?.side === 'WEST'); 155 | 156 | newEdges.push({ 157 | id: `${sourceNode.id}-${targetNode.id}`, 158 | from: sourceNode.id, 159 | to: targetNode.id, 160 | parent: sourceNode?.parent, 161 | fromPort: fromPort?.id, 162 | toPort: toPort?.id, 163 | }); 164 | } 165 | } 166 | } 167 | } 168 | } 169 | 170 | return { 171 | edges: newEdges, 172 | nodes: newNodes, 173 | }; 174 | } 175 | 176 | ``` 177 | -------------------------------------------------------------------------------- /docs/Utils/Graph.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Graph Utils 6 | 7 | ## Detecting Circulars 8 | The `detectCircular` function helps you determine if 9 | the source node will create a circular link if connected 10 | to the target node. 11 | 12 | The signature for this looks like: 13 | 14 | ```js 15 | detectCircular( 16 | nodes: NodeData[], 17 | edges: EdgeData[], 18 | fromNode: NodeData, 19 | toNode: NodeData 20 | ) => boolean; 21 | ``` 22 | 23 | Below is an example usage: 24 | 25 | ```js 26 | import { detectCircular } from 'reaflow'; 27 | 28 | const has = detectCircular(nodes, edges, fromNode, toNode); 29 | if (!has) { 30 | // Do something 31 | } 32 | ``` 33 | 34 | ## Parent Node Traversal 35 | The `getParentsForNodeId` function helps you find all the 36 | parent nodes for a given node id. 37 | 38 | The signature for this looks like: 39 | 40 | ```js 41 | getParentsForNodeId( 42 | nodes: NodeData[], 43 | edges: EdgeData[], 44 | nodeId: string 45 | ) => NodeData[]; 46 | ``` 47 | 48 | Below is an example usage: 49 | 50 | ```js 51 | import { getParentsForNodeId } from 'reaflow'; 52 | 53 | const nodes = getParentsForNodeId(nodes, edges, node.id); 54 | ``` 55 | 56 | ## Has Link 57 | The `hasLink` function helps you determine if 58 | the source node already has a link to the target node. 59 | 60 | The signature for this looks like: 61 | 62 | ```js 63 | hasLink( 64 | edges: EdgeData[], 65 | fromNode: NodeData, 66 | toNode: NodeData 67 | ) => boolean; 68 | ``` 69 | 70 | Below is an example usage: 71 | 72 | ```js 73 | import { hasLink } from 'reaflow'; 74 | 75 | const has = hasLink(edges, fromNode, toNode); 76 | if (!has) { 77 | // Do something 78 | } 79 | ``` 80 | 81 | ## Get Edges Given a Node 82 | Similar to `hasLink` the `getEdgesByNode` function will 83 | return all the edges given a node. 84 | 85 | The signature looks like this: 86 | 87 | ```js 88 | getEdgesByNode( 89 | edges: EdgeData[], 90 | node: NodeData, 91 | ) => { all: EdgeData[], to: EdgeData[], from: EdgeData[] } 92 | ``` 93 | 94 | Below is an example usage: 95 | 96 | ```js 97 | import { getEdgesByNode } from 'reaflow'; 98 | 99 | const { all, to, from } = getEdgesByNode(edges, node); 100 | if (!all.length) { 101 | // Do something 102 | } 103 | ``` 104 | -------------------------------------------------------------------------------- /docs/Utils/Utils.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # CRUD Utils 6 | 7 | ## Upsert Node 8 | The `upsertNode` function helps you insert a new 9 | node between two other nodes. 10 | 11 | The signature for this looks like: 12 | 13 | ```js 14 | upsertNode( 15 | nodes: NodeData[], 16 | edges: EdgeData[], 17 | edge: EdgeData, 18 | newNode: NodeData 19 | ) => { nodes: NodeData[]; edges: EdgeData[]; } 20 | ``` 21 | 22 | Below is an example usage: 23 | 24 | ```js 25 | import { upsertNode} from 'reaflow'; 26 | 27 | const id = `node-${Math.random()}`; 28 | const newNode = { 29 | id, 30 | text: id 31 | }; 32 | 33 | const results = upsertNode(nodes, edges, edge, newNode); 34 | setNodes(results.nodes); 35 | setEdges(results.edges); 36 | ``` 37 | 38 | ## Remove Node 39 | The `removeNode` function helps you remove a node 40 | and all related edges. 41 | 42 | The signature for this looks like: 43 | 44 | ```js 45 | removeNode( 46 | nodes: NodeData[], 47 | edges: EdgeData[], 48 | removeNodes: string | string[] 49 | ) => { nodes: NodeData[]; edges: EdgeData[]; } 50 | ``` 51 | 52 | Below is an example usage: 53 | 54 | ```js 55 | import { removeNode } from 'reaflow'; 56 | 57 | const results = removeNode(nodes, edges, nodeIds); 58 | setNodes(results.nodes); 59 | setEdges(results.edges); 60 | ``` 61 | 62 | ## Node Removal and Upsert 63 | The `removeAndUpsertNodes` helper allows you to remove a node 64 | that has existing `to` and `from` edges and link the child 65 | edges from the node remove to the parent of the node removed. 66 | 67 | The signature for this looks like: 68 | 69 | ```js 70 | removeAndUpsertNodes( 71 | nodes: NodeData[], 72 | edges: EdgeData[], 73 | removeNodes: NodeData | NodeData[], 74 | onNodeLinkCheck?: ( 75 | newNodes: NodeData[], 76 | newEdges: EdgeData[], 77 | from: NodeData, 78 | to: NodeData, 79 | port?: PortData 80 | ) => undefined | boolean 81 | ) => { nodes: NodeData[]; edges: EdgeData[]; } 82 | ``` 83 | 84 | Below is an example usage: 85 | 86 | ```js 87 | import { removeAndUpsertNodes } from 'reaflow'; 88 | 89 | const result = removeAndUpsertNodes(nodes, edges, node); 90 | setNodes(results.nodes); 91 | setEdges(results.edges); 92 | ``` 93 | 94 | ## Add Node and Optional Edge 95 | The `addNodeAndEdge` helper is a shortcut function to add a 96 | node to a nodes array and a optional edge. 97 | 98 | The signature for this looks like: 99 | 100 | ```js 101 | addNodeAndEdge( 102 | nodes: NodeData[], 103 | edges: EdgeData[], 104 | node: NodeData, 105 | toNode?: NodeData 106 | ) => { nodes: NodeData[]; edges: EdgeData[]; } 107 | ``` 108 | 109 | Below is an example usage: 110 | 111 | ```js 112 | import { addNodeAndEdge } from 'reaflow'; 113 | 114 | const result = addNodeAndEdge( 115 | nodes, 116 | edges, 117 | { 118 | id, 119 | text: id 120 | }, 121 | enteredNode 122 | ); 123 | 124 | setNodes(results.nodes); 125 | setEdges(results.edges); 126 | ``` 127 | 128 | ## Remove Edge 129 | The `removeEdge` function simplifies removing a single 130 | or array of edges. 131 | 132 | The signature for this looks like: 133 | 134 | ```js 135 | removeEdge( 136 | edges: EdgeData[], 137 | edge: EdgeData | EdgeData[] 138 | ) => EdgeData[] 139 | ``` 140 | 141 | Below is an example usage: 142 | 143 | ```js 144 | import { removeEdge } from 'reaflow'; 145 | 146 | const newEdges = removeEdge( 147 | edges, 148 | edgesToRemove 149 | ); 150 | 151 | setEdges(newEdges); 152 | ``` 153 | 154 | ## Remove Edges from Node 155 | The `removeEdgesFromNode` function simplifies removing all 156 | edges from a node. 157 | 158 | The signature for this looks like: 159 | 160 | ```js 161 | removeEdgesFromNode( 162 | nodeId: string, 163 | edges: EdgeData[] 164 | ) => EdgeData[] 165 | ``` 166 | 167 | Below is an example usage: 168 | 169 | ```js 170 | import { removeEdgesFromNode } from 'reaflow'; 171 | 172 | const newEdges = removeEdgesFromNode( 173 | node.id, 174 | edges 175 | ); 176 | 177 | setEdges(newEdges); 178 | ``` 179 | 180 | ## Create Edge from Nodes 181 | The `createEdgeFromNodes` function simplifies creating an 182 | edge between two nodes. 183 | 184 | The signature for this looks like: 185 | 186 | ```js 187 | createEdgeFromNodes( 188 | fromNode: NodeData, 189 | toNode: NodeData 190 | ) => EdgeData 191 | ``` 192 | 193 | Below is an example usage: 194 | 195 | ```js 196 | import { createEdgeFromNodes } from 'reaflow'; 197 | 198 | const newEdge = createEdgeFromNodes( 199 | fromNode, 200 | toNode 201 | ); 202 | 203 | setEdges([...edges, newEdge]); 204 | ``` 205 | -------------------------------------------------------------------------------- /docs/assets/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reaviz/reaflow/ccfb3374d35c2c8585a327bbab3c6f7088e8bc42/docs/assets/logo-light.png -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reaviz/reaflow/ccfb3374d35c2c8585a327bbab3c6f7088e8bc42/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /docs/assets/teaser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reaviz/reaflow/ccfb3374d35c2c8585a327bbab3c6f7088e8bc42/docs/assets/teaser.png -------------------------------------------------------------------------------- /docs/tools/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Lato", "Helvetica Neue", "Helvetica", "Arial", sans-serif; 3 | font-weight: 400; 4 | background: #22272b; 5 | color: #fff; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | margin: 25px; 9 | text-align: center; 10 | } 11 | 12 | .container { 13 | margin: 55px; 14 | } 15 | -------------------------------------------------------------------------------- /docs/tools/templates/reaflow-codesandbox-template/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reaviz-codesandbox-template", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "dependencies": { 7 | "motion": "^11.18.0", 8 | "react": "latest", 9 | "react-dom": "latest", 10 | "react-scripts": "3.0.1", 11 | "reaflow": "latest" 12 | }, 13 | "devDependencies": { 14 | "typescript": "3.3.3" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test --env=jsdom", 20 | "eject": "react-scripts eject" 21 | }, 22 | "browserslist": [">0.2%", "not dead", "not ie <= 11", "not op_mini all"] 23 | } 24 | -------------------------------------------------------------------------------- /docs/tools/templates/reaflow-codesandbox-template/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | reaflow example 10 | 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /docs/tools/templates/reaflow-codesandbox-template/src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function App() { 4 | return

Hello reaviz!

; 5 | } 6 | -------------------------------------------------------------------------------- /docs/tools/templates/reaflow-codesandbox-template/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | ReactDOM.render(, rootElement); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reaflow", 3 | "version": "5.4.1", 4 | "description": "Node-based Visualizations for React", 5 | "scripts": { 6 | "build": "vite build --mode library", 7 | "build:watch": "vite build --watch --mode library", 8 | "build-storybook": "storybook build", 9 | "prettier": "prettier --loglevel warn --write 'src/**/*.{ts,tsx,js,jsx}'", 10 | "lint": "eslint --ext js,ts,tsx", 11 | "lint:fix": "eslint --ext js,ts,tsx --fix src", 12 | "start": "storybook dev -p 9009", 13 | "test": "vitest --passWithNoTests", 14 | "prepare": "husky install" 15 | }, 16 | "type": "module", 17 | "types": "dist/index.d.ts", 18 | "main": "./dist/index.umd.cjs", 19 | "module": "./dist/index.js", 20 | "source": "src/index.ts", 21 | "exports": { 22 | ".": { 23 | "import": "./dist/index.js", 24 | "require": "./dist/index.umd.cjs", 25 | "types": "./dist/index.d.ts" 26 | } 27 | }, 28 | "browser": "dist/index.js", 29 | "typings": "dist/index.d.ts", 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/reaviz/reaflow.git" 33 | }, 34 | "files": [ 35 | "dist" 36 | ], 37 | "keywords": [ 38 | "react", 39 | "reactjs", 40 | "workflow", 41 | "node-editor", 42 | "diagrams", 43 | "elkjs" 44 | ], 45 | "license": "Apache-2.0", 46 | "bugs": { 47 | "url": "https://github.com/reaviz/reaflow/issues" 48 | }, 49 | "homepage": "https://github.com/reaviz/reaflow#readme", 50 | "dependencies": { 51 | "@juggle/resize-observer": "^3.4.0", 52 | "calculate-size": "^1.1.1", 53 | "classnames": "^2.3.2", 54 | "d3-shape": "^3.0.1", 55 | "elkjs": "^0.10.0", 56 | "ellipsize": "^0.2.0", 57 | "kld-affine": "^2.1.1", 58 | "kld-intersections": "^0.7.0", 59 | "motion": "^12.4.2", 60 | "p-cancelable": "^3.0.0", 61 | "reablocks": "^8.7.6", 62 | "react-cool-dimensions": "^2.0.7", 63 | "react-fast-compare": "^3.2.2", 64 | "react-use-gesture": "^8.0.1", 65 | "reakeys": "^2.0.5", 66 | "undoo": "^0.5.0", 67 | "web-worker": "^1.5.0" 68 | }, 69 | "peerDependencies": { 70 | "react": ">=16", 71 | "react-dom": ">=16" 72 | }, 73 | "devDependencies": { 74 | "@storybook/addon-docs": "^7.6.17", 75 | "@storybook/addon-essentials": "^7.6.17", 76 | "@storybook/addon-mdx-gfm": "^7.6.17", 77 | "@storybook/addon-storysource": "^7.6.17", 78 | "@storybook/addons": "^7.6.17", 79 | "@storybook/react": "^7.6.17", 80 | "@storybook/react-vite": "^7.6.17", 81 | "@storybook/theming": "^7.6.17", 82 | "@testing-library/react": "^14.0.0", 83 | "@types/classnames": "^2.3.1", 84 | "@types/d3-shape": "^3.1.1", 85 | "@types/react": "^18.2.60", 86 | "@types/react-dom": "^18.2.19", 87 | "@typescript-eslint/eslint-plugin": "^7.1.0", 88 | "@typescript-eslint/parser": "^7.1.0", 89 | "@vitejs/plugin-react": "^4.0.3", 90 | "autoprefixer": "^10.4.14", 91 | "chromatic": "6.20.0", 92 | "eslint": "^8.57.0", 93 | "eslint-config-prettier": "^9.1.0", 94 | "eslint-plugin-react": "^7.33.2", 95 | "eslint-plugin-react-hooks": "^4.6.0", 96 | "eslint-plugin-storybook": "^0.8.0", 97 | "husky": "^9.0.11", 98 | "jsdom": "^24.0.0", 99 | "lint-staged": "^15.2.2", 100 | "postcss-nested": "^6.0.1", 101 | "postcss-preset-env": "^9.4.0", 102 | "prettier": "^3.2.5", 103 | "react": "^18.2.0", 104 | "react-dom": "^18.2.0", 105 | "rollup-plugin-peer-deps-external": "2.2.4", 106 | "storybook": "^7.6.17", 107 | "typescript": "^4.9.5", 108 | "vite": "^4.4.7", 109 | "vite-plugin-checker": "^0.6.1", 110 | "vite-plugin-css-injected-by-js": "^3.2.1", 111 | "vite-plugin-dts": "^3.3.1", 112 | "vite-plugin-svgr": "^3.2.0", 113 | "vite-tsconfig-paths": "^4.2.0", 114 | "vitest": "^0.33.0" 115 | }, 116 | "lint-staged": { 117 | "src/**/*.{js,jsx,ts,tsx}": [ 118 | "eslint --ext js,ts,tsx --fix" 119 | ], 120 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 121 | "prettier --write", 122 | "git add" 123 | ] 124 | }, 125 | "husky": { 126 | "hooks": { 127 | "pre-commit": "lint-staged" 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable no-undef */ 3 | // eslint-disable-next-line no-undef 4 | module.exports = { 5 | plugins: [ 6 | require('postcss-nested'), 7 | require('postcss-preset-env')({ stage: 1 }), 8 | require('autoprefixer') 9 | ] 10 | }; 11 | -------------------------------------------------------------------------------- /src/Canvas.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | &.pannable { 3 | overflow: auto; 4 | } 5 | 6 | &:focus { 7 | outline: none; 8 | } 9 | } 10 | 11 | :global(.dragging) { 12 | -webkit-touch-callout: none; 13 | -webkit-user-select: none; 14 | -khtml-user-select: none; 15 | -moz-user-select: none; 16 | -ms-user-select: none; 17 | user-select: none; 18 | } 19 | 20 | .dragNode { 21 | pointer-events: none; 22 | } 23 | 24 | .draggable { 25 | scrollbar-width: none; /* Firefox */ 26 | -ms-overflow-style: none; /* Internet Explorer 10+ */ 27 | cursor: grab; 28 | 29 | &::-webkit-scrollbar { 30 | display: none; /* WebKit */ 31 | } 32 | 33 | &:active { 34 | cursor: grabbing; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/helpers/crudHelpers.test.ts: -------------------------------------------------------------------------------- 1 | import { upsertNode } from './crudHelpers'; 2 | 3 | const nodes = [ 4 | { 5 | id: '1' 6 | }, 7 | { 8 | id: '2', 9 | text: 'Node 2', 10 | parent: '1' 11 | }, 12 | { 13 | id: '3', 14 | text: 'Node 3', 15 | parent: '1' 16 | }, 17 | { 18 | id: '4', 19 | text: 'Node 4', 20 | parent: '1' 21 | } 22 | ]; 23 | const edges = [ 24 | { 25 | id: '2-3', 26 | from: '2', 27 | to: '3', 28 | parent: '1' 29 | }, 30 | { 31 | id: '2-4', 32 | from: '2', 33 | to: '4', 34 | parent: '1' 35 | } 36 | ]; 37 | 38 | test('should upsert node with edges using properties from original one and in right order', () => { 39 | const newNode = { 40 | id: '5', 41 | text: 'Node 5', 42 | parent: '1' 43 | }; 44 | const expectedResults = { 45 | nodes: nodes.concat(newNode), 46 | edges: [ 47 | { 48 | id: '2-5', 49 | from: '2', 50 | to: '5', 51 | parent: '1' 52 | }, 53 | { 54 | id: '5-3', 55 | from: '5', 56 | to: '3', 57 | parent: '1' 58 | }, 59 | { 60 | id: '2-4', 61 | from: '2', 62 | to: '4', 63 | parent: '1' 64 | } 65 | ] 66 | }; 67 | 68 | expect(upsertNode(nodes, edges, edges[0], newNode)).toEqual(expectedResults); 69 | }); 70 | -------------------------------------------------------------------------------- /src/helpers/crudHelpers.ts: -------------------------------------------------------------------------------- 1 | import { EdgeData, NodeData, PortData } from '../types'; 2 | 3 | /** 4 | * Helper function for upserting a node in a edge. 5 | */ 6 | export function upsertNode( 7 | nodes: NodeData[], 8 | edges: EdgeData[], 9 | edge: EdgeData, 10 | newNode: NodeData 11 | ) { 12 | const oldEdgeIndex = edges.findIndex((e) => e.id === edge.id); 13 | const edgeBeforeNewNode = { 14 | ...edge, 15 | id: `${edge.from}-${newNode.id}`, 16 | to: newNode.id 17 | }; 18 | const edgeAfterNewNode = { 19 | ...edge, 20 | id: `${newNode.id}-${edge.to}`, 21 | from: newNode.id 22 | }; 23 | 24 | if (edge.fromPort && edge.toPort) { 25 | edgeBeforeNewNode.fromPort = edge.fromPort; 26 | edgeBeforeNewNode.toPort = `${newNode.id}-to`; 27 | 28 | edgeAfterNewNode.fromPort = `${newNode.id}-from`; 29 | edgeAfterNewNode.toPort = edge.toPort; 30 | } 31 | 32 | edges.splice(oldEdgeIndex, 1, edgeBeforeNewNode, edgeAfterNewNode); 33 | 34 | return { 35 | nodes: [...nodes, newNode], 36 | edges: [...edges] 37 | }; 38 | } 39 | 40 | /** 41 | * Helper function for removing a node between edges and 42 | * linking the children. 43 | */ 44 | export function removeAndUpsertNodes( 45 | nodes: NodeData[], 46 | edges: EdgeData[], 47 | removeNodes: NodeData | NodeData[], 48 | onNodeLinkCheck?: ( 49 | newNodes: NodeData[], 50 | newEdges: EdgeData[], 51 | from: NodeData, 52 | to: NodeData, 53 | port?: PortData 54 | ) => undefined | boolean 55 | ) { 56 | if (!Array.isArray(removeNodes)) { 57 | removeNodes = [removeNodes]; 58 | } 59 | 60 | const nodeIds = removeNodes.map((n) => n.id); 61 | const newNodes = nodes.filter((n) => !nodeIds.includes(n.id)); 62 | const newEdges = edges.filter( 63 | (e) => !nodeIds.includes(e.from) && !nodeIds.includes(e.to) 64 | ); 65 | 66 | for (const nodeId of nodeIds) { 67 | const sourceEdges = edges.filter((e) => e.to === nodeId); 68 | const targetEdges = edges.filter((e) => e.from === nodeId); 69 | 70 | for (const sourceEdge of sourceEdges) { 71 | for (const targetEdge of targetEdges) { 72 | const sourceNode = nodes.find((n) => n.id === sourceEdge.from); 73 | const targetNode = nodes.find((n) => n.id === targetEdge.to); 74 | if (sourceNode && targetNode) { 75 | const canLink = onNodeLinkCheck?.( 76 | newNodes, 77 | newEdges, 78 | sourceNode, 79 | targetNode 80 | ); 81 | if (canLink === undefined || canLink) { 82 | newEdges.push({ 83 | id: `${sourceNode.id}-${targetNode.id}`, 84 | from: sourceNode.id, 85 | to: targetNode.id, 86 | parent: sourceNode?.parent 87 | }); 88 | } 89 | } 90 | } 91 | } 92 | } 93 | 94 | return { 95 | edges: newEdges, 96 | nodes: newNodes 97 | }; 98 | } 99 | 100 | /** 101 | * Helper function to remove a node and its related edges. 102 | */ 103 | export function removeNode( 104 | nodes: NodeData[], 105 | edges: EdgeData[], 106 | removeNodes: string | string[] 107 | ) { 108 | if (!Array.isArray(removeNodes)) { 109 | removeNodes = [removeNodes]; 110 | } 111 | 112 | const newNodes = []; 113 | const newEdges = []; 114 | 115 | for (const node of nodes) { 116 | const has = removeNodes.some((n) => n === node.id); 117 | if (!has) { 118 | newNodes.push(node); 119 | } 120 | } 121 | 122 | for (const edge of edges) { 123 | const has = removeNodes.some((n) => n === edge.from || n === edge.to); 124 | if (!has) { 125 | newEdges.push(edge); 126 | } 127 | } 128 | 129 | return { 130 | nodes: newNodes, 131 | edges: newEdges 132 | }; 133 | } 134 | 135 | /** 136 | * Helper function to remove a node's related edges. 137 | */ 138 | export function removeEdgesFromNode(nodeId: string, edges: EdgeData[]) { 139 | return edges.filter((edge) => !(edge.to === nodeId || edge.from === nodeId)); 140 | } 141 | 142 | /** 143 | * Remove edge(s) 144 | */ 145 | export function removeEdge(edges: EdgeData[], edge: EdgeData | EdgeData[]) { 146 | const deletions: EdgeData[] = !Array.isArray(edge) ? [edge] : edge; 147 | const edgeIds = deletions.map((e) => e.id); 148 | return edges.filter((e) => !edgeIds.includes(e.id)); 149 | } 150 | 151 | /** 152 | * Create an edge given 2 nodes. 153 | */ 154 | export function createEdgeFromNodes(fromNode: NodeData, toNode: NodeData) { 155 | return { 156 | id: `${fromNode.id}-${toNode.id}`, 157 | from: fromNode.id, 158 | to: toNode.id, 159 | parent: toNode.parent 160 | }; 161 | } 162 | 163 | /** 164 | * Add a node and optional edge. 165 | */ 166 | export function addNodeAndEdge( 167 | nodes: NodeData[], 168 | edges: EdgeData[], 169 | node: NodeData, 170 | toNode?: NodeData 171 | ) { 172 | return { 173 | nodes: [...nodes, node], 174 | edges: [...edges, ...(toNode ? [createEdgeFromNodes(toNode, node)] : [])] 175 | }; 176 | } 177 | -------------------------------------------------------------------------------- /src/helpers/graphHelpers.ts: -------------------------------------------------------------------------------- 1 | import { EdgeData, NodeData } from '../types'; 2 | 3 | /** 4 | * Helper function to determine if edge already has a link. 5 | */ 6 | export function hasLink(edges: EdgeData[], from: NodeData, to: NodeData) { 7 | return edges.some((e) => e.from === from.id && e.to === to.id); 8 | } 9 | 10 | /** 11 | * Get sources pointing to a node. 12 | */ 13 | function getSourceNodesForTargetId( 14 | nodes: NodeData[], 15 | edges: EdgeData[], 16 | nodeId: string 17 | ) { 18 | const sourceNodeIds = edges.reduce((acc, edge) => { 19 | if (edge.to === nodeId) { 20 | acc.push(edge.from); 21 | } 22 | return acc; 23 | }, []); 24 | 25 | const node = nodes.find((n) => n.id === nodeId); 26 | 27 | if (node?.parent) { 28 | sourceNodeIds.push(node.parent); 29 | } 30 | 31 | return nodes.filter((n) => sourceNodeIds.includes(n.id)); 32 | } 33 | 34 | /** 35 | * Detect if there is a circular reference from the from to the source node. 36 | */ 37 | export function detectCircular( 38 | nodes: NodeData[], 39 | edges: EdgeData[], 40 | fromNode: NodeData, 41 | toNode: NodeData 42 | ) { 43 | let found = false; 44 | 45 | const traverse = (nodeId: string) => { 46 | const sourceNodes = getSourceNodesForTargetId(nodes, edges, nodeId); 47 | for (const node of sourceNodes) { 48 | if (node.id !== toNode.id) { 49 | traverse(node.id); 50 | } else { 51 | found = true; 52 | break; 53 | } 54 | } 55 | }; 56 | 57 | traverse(fromNode.id); 58 | 59 | return found; 60 | } 61 | 62 | /** 63 | * Given a node id, get all the parent nodes recursively. 64 | */ 65 | export const getParentsForNodeId = ( 66 | nodes: NodeData[], 67 | edges: EdgeData[], 68 | startId: string 69 | ) => { 70 | const result = []; 71 | 72 | const traverse = (nodeId: string) => { 73 | const sourceNodes = getSourceNodesForTargetId(nodes, edges, nodeId); 74 | for (const node of sourceNodes) { 75 | const has = result.find((n) => n.id === node.id); 76 | if (!has) { 77 | result.push(node); 78 | traverse(node.id); 79 | } 80 | } 81 | }; 82 | 83 | traverse(startId); 84 | 85 | return result; 86 | }; 87 | 88 | /** 89 | * Get edge data given a node. 90 | */ 91 | export function getEdgesByNode(edges: EdgeData[], node: NodeData) { 92 | const to = []; 93 | const from = []; 94 | 95 | for (const edge of edges) { 96 | if (edge.to === node.id) { 97 | to.push(edge); 98 | } 99 | if (edge.from === node.id) { 100 | from.push(edge); 101 | } 102 | } 103 | 104 | return { 105 | to, 106 | from, 107 | all: [...to, ...from] 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useSelection'; 2 | export * from './useUndo'; 3 | export * from './useProximity'; 4 | export * from './crudHelpers'; 5 | export * from './graphHelpers'; 6 | -------------------------------------------------------------------------------- /src/helpers/useProximity.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; 2 | import { CanvasRef } from '../Canvas'; 3 | import { getCoords } from '../utils/helpers'; 4 | import { Matrix2D, Point2D } from 'kld-affine'; 5 | import { IntersectionQuery } from 'kld-intersections'; 6 | import { LayoutNodeData } from '../types'; 7 | 8 | export interface ProximityProps { 9 | /** 10 | * Disable proximity or not. 11 | */ 12 | disabled?: boolean; 13 | 14 | /** 15 | * Min distance required before match is made. 16 | * 17 | * @default 40 18 | */ 19 | minDistance?: number; 20 | 21 | /** 22 | * Ref pointer to the canvas. 23 | */ 24 | canvasRef?: RefObject; 25 | 26 | /** 27 | * Distance from the match. 28 | */ 29 | onDistanceChange?: (distance: number | null) => void; 30 | 31 | /** 32 | * When a match state has changed. 33 | */ 34 | onMatchChange?: (matche: string | null, distance: number | null) => void; 35 | 36 | /** 37 | * When the pointer intersects a node. 38 | */ 39 | onIntersects?: (matche: string | null) => void; 40 | } 41 | 42 | export interface ProximityResult { 43 | /** 44 | * The matched id of the node. 45 | */ 46 | match: string | null; 47 | 48 | /** 49 | * Event for drag started. 50 | */ 51 | onDragStart: (event: PointerEvent) => void; 52 | 53 | /** 54 | * Event for active dragging. 55 | */ 56 | onDrag: (event: PointerEvent) => void; 57 | 58 | /** 59 | * Event for drag ended. 60 | */ 61 | onDragEnd: (event: PointerEvent) => void; 62 | } 63 | 64 | interface PointNode { 65 | points: Point2D[]; 66 | node: LayoutNodeData; 67 | } 68 | 69 | const buildPoints = (nodes: LayoutNodeData[], parent?: LayoutNodeData) => { 70 | const results: PointNode[] = []; 71 | 72 | if (nodes?.length) { 73 | for (const node of nodes) { 74 | let x = node.x; 75 | let y = node.y; 76 | 77 | // NOTE: If we have a parent, let's update the points 78 | // to account for the parent's position 79 | if (parent) { 80 | x = parent.x + x; 81 | y = parent.y + y; 82 | } 83 | 84 | const points = [ 85 | // top-left 86 | new Point2D(x, y), 87 | // bottom-right 88 | new Point2D(x + node.width, y + node.height) 89 | ]; 90 | 91 | results.push({ 92 | points, 93 | node 94 | }); 95 | 96 | if (node.children?.length) { 97 | results.push(...buildPoints(node.children, node)); 98 | } 99 | } 100 | } 101 | 102 | return results; 103 | }; 104 | 105 | const distanceFromNode = (mousePoint: Point2D, node: PointNode) => { 106 | const [tl, br] = node.points; 107 | let dx = 0; 108 | let dy = 0; 109 | 110 | // Compute distance to elem in X 111 | if (mousePoint.x < tl.x) { 112 | dx = tl.x - mousePoint.x; 113 | } else if (mousePoint.x > br.x) { 114 | dx = br.x - mousePoint.x; 115 | } 116 | 117 | // Compute distance to elem in Y 118 | if (mousePoint.y < tl.y) { 119 | dy = tl.y - mousePoint.y; 120 | } else if (mousePoint.y > br.y) { 121 | dy = br.y - mousePoint.y; 122 | } 123 | 124 | return Math.floor(Math.sqrt(dx * dx + dy * dy)); 125 | }; 126 | 127 | const findNodeIntersection = ( 128 | event: PointerEvent, 129 | matrix: Matrix2D, 130 | points: PointNode[], 131 | minDistance: number 132 | ) => { 133 | const cubes = []; 134 | const mousePoint = new Point2D(event.x, event.y).transform(matrix); 135 | 136 | for (const point of points) { 137 | // TODO: Make this support other shape types... 138 | const intersects = IntersectionQuery.pointInRectangle( 139 | mousePoint, 140 | point.points[0], 141 | point.points[1] 142 | ); 143 | 144 | // Calc the distances 145 | // https://github.com/thelonious/kld-affine/issues/24 146 | const minDist = distanceFromNode(mousePoint, point); 147 | 148 | cubes.push({ 149 | node: point.node, 150 | minDist, 151 | intersects 152 | }); 153 | } 154 | 155 | let foundDist = minDistance; 156 | let intersectedNodeId = null; 157 | let foundNodeId = null; 158 | for (const cube of cubes) { 159 | if (cube.minDist < foundDist && !cube.intersects) { 160 | foundNodeId = cube.node.id; 161 | foundDist = cube.minDist; 162 | } 163 | 164 | if (cube.intersects) { 165 | intersectedNodeId = cube.node.id; 166 | } 167 | } 168 | 169 | if (intersectedNodeId) { 170 | // We are are just inside a node already 171 | // and there is no closer children ( nested case ) 172 | if (!foundNodeId || foundNodeId === intersectedNodeId) { 173 | // If we are inside the intersected node and its the 174 | // closest node, let's reset the distance to 0 175 | foundNodeId = intersectedNodeId; 176 | foundDist = 0; 177 | } 178 | } 179 | 180 | return { 181 | intersectedNodeId, 182 | foundNodeId, 183 | foundDist 184 | }; 185 | }; 186 | 187 | export const useProximity = ({ 188 | canvasRef, 189 | disabled, 190 | minDistance = 40, 191 | ...rest 192 | }: ProximityProps) => { 193 | const lastIntersectRef = useRef(null); 194 | const lastMatchRef = useRef(null); 195 | const lastDistance = useRef(null); 196 | const frame = useRef(0); 197 | 198 | // Reference: https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback 199 | const eventRefs = useRef(rest); 200 | useEffect(() => { 201 | eventRefs.current = rest; 202 | }, [rest]); 203 | 204 | const [match, setMatch] = useState(null); 205 | const [matrix, setMatrix] = useState(null); 206 | const [points, setPoints] = useState(null); 207 | 208 | const onDragStart = useCallback(() => { 209 | if (disabled) { 210 | return; 211 | } 212 | 213 | const ref = canvasRef.current; 214 | 215 | // @ts-ignore 216 | setMatrix( 217 | getCoords({ 218 | containerRef: ref.containerRef, 219 | zoom: ref.zoom, 220 | layoutXY: ref.xy 221 | }) 222 | ); 223 | setPoints(buildPoints(ref.layout.children)); 224 | // eslint-disable-next-line react-hooks/exhaustive-deps 225 | }, [disabled]); 226 | 227 | const onDrag = useCallback( 228 | (event: PointerEvent) => { 229 | if (!matrix || disabled) { 230 | return; 231 | } 232 | 233 | const { onMatchChange, onIntersects, onDistanceChange } = 234 | eventRefs.current; 235 | 236 | const { intersectedNodeId, foundNodeId, foundDist } = 237 | findNodeIntersection(event, matrix, points, minDistance); 238 | const nextDist = foundDist !== minDistance ? foundDist : null; 239 | 240 | if (foundNodeId !== lastMatchRef.current) { 241 | onMatchChange?.(foundNodeId, foundDist); 242 | } 243 | 244 | if (intersectedNodeId !== lastIntersectRef.current) { 245 | onIntersects?.(intersectedNodeId); 246 | } 247 | 248 | if (onDistanceChange && nextDist !== lastDistance.current) { 249 | cancelAnimationFrame(frame.current); 250 | frame.current = requestAnimationFrame(() => { 251 | onDistanceChange(nextDist); 252 | }); 253 | } 254 | 255 | // Hold these in refs for race cases 256 | lastIntersectRef.current = intersectedNodeId; 257 | lastMatchRef.current = foundNodeId; 258 | lastDistance.current = nextDist; 259 | 260 | setMatch(foundNodeId); 261 | }, 262 | [matrix, disabled, minDistance, points] 263 | ); 264 | 265 | useEffect(() => { 266 | return () => cancelAnimationFrame(frame.current); 267 | }); 268 | 269 | const onDragEnd = useCallback(() => { 270 | if (!disabled) { 271 | setMatch(null); 272 | setMatrix(null); 273 | setPoints(null); 274 | } 275 | }, [disabled]); 276 | 277 | return { 278 | match, 279 | onDragStart, 280 | onDrag, 281 | onDragEnd 282 | } as ProximityResult; 283 | }; 284 | -------------------------------------------------------------------------------- /src/helpers/useSelection.ts: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useHotkeys } from 'reakeys'; 3 | import { EdgeData, NodeData } from '../types'; 4 | import { removeNode } from './crudHelpers'; 5 | 6 | export type HotkeyTypes = 'selectAll' | 'deselect' | 'delete'; 7 | 8 | export interface SelectionProps { 9 | /** 10 | * Current selections. 11 | * 12 | * Contains both nodes and edges ids. 13 | */ 14 | selections?: string[]; 15 | 16 | /** 17 | * Node datas. 18 | */ 19 | nodes?: NodeData[]; 20 | 21 | /** 22 | * Edge datas. 23 | */ 24 | edges?: EdgeData[]; 25 | 26 | /** 27 | * Disabled or not. 28 | */ 29 | disabled?: boolean; 30 | 31 | /** 32 | * Hotkey types 33 | */ 34 | hotkeys?: HotkeyTypes[]; 35 | 36 | /** 37 | * On selection change. 38 | */ 39 | onSelection?: (newSelectedIds: string[]) => void; 40 | 41 | /** 42 | * On data change. 43 | */ 44 | onDataChange?: (nodes: NodeData[], edges: EdgeData[]) => void; 45 | } 46 | 47 | export interface SelectionResult { 48 | /** 49 | * Selections id array (of nodes and edges). 50 | */ 51 | selections: string[]; 52 | 53 | /** 54 | * Clear selections method. 55 | */ 56 | clearSelections: (value?: string[]) => void; 57 | 58 | /** 59 | * A selection method. 60 | */ 61 | addSelection: (value: string) => void; 62 | 63 | /** 64 | * Remove selection method. 65 | */ 66 | removeSelection: (value: string) => void; 67 | 68 | /** 69 | * Toggle existing selection on/off method. 70 | */ 71 | toggleSelection: (value: string) => void; 72 | 73 | /** 74 | * Set internal selections. 75 | */ 76 | setSelections: (value: string[]) => void; 77 | 78 | /** 79 | * On click event pass through. 80 | */ 81 | onClick?: ( 82 | event: React.MouseEvent, 83 | data: any 84 | ) => void; 85 | 86 | /** 87 | * On canvas click event pass through. 88 | */ 89 | onCanvasClick?: (event?: React.MouseEvent) => void; 90 | 91 | /** 92 | * On keydown event pass through. 93 | */ 94 | onKeyDown?: (event: React.KeyboardEvent) => void; 95 | } 96 | 97 | export const useSelection = ({ 98 | selections = [], 99 | nodes = [], 100 | edges = [], 101 | hotkeys = ['selectAll', 'deselect', 'delete'], 102 | disabled, 103 | onSelection, 104 | onDataChange 105 | }: SelectionProps): SelectionResult => { 106 | const [internalSelections, setInternalSelections] = 107 | useState(selections); 108 | const [metaKeyDown, setMetaKeyDown] = useState(false); 109 | 110 | const addSelection = (item: string) => { 111 | if (!disabled) { 112 | const has = internalSelections.includes(item); 113 | if (!has) { 114 | const next = [...internalSelections, item]; 115 | onSelection?.(next); 116 | setInternalSelections(next); 117 | } 118 | } 119 | }; 120 | 121 | const removeSelection = (item: string) => { 122 | if (!disabled) { 123 | const has = internalSelections.includes(item); 124 | if (has) { 125 | const next = internalSelections.filter((i) => i !== item); 126 | onSelection?.(next); 127 | setInternalSelections(next); 128 | } 129 | } 130 | }; 131 | 132 | const toggleSelection = (item: string) => { 133 | const has = internalSelections.includes(item); 134 | if (has) { 135 | removeSelection(item); 136 | } else { 137 | addSelection(item); 138 | } 139 | }; 140 | 141 | const clearSelections = (next = []) => { 142 | if (!disabled) { 143 | setInternalSelections(next); 144 | onSelection?.(next); 145 | } 146 | }; 147 | 148 | const onClick = (event, data) => { 149 | event.preventDefault(); 150 | event.stopPropagation(); 151 | 152 | if (!metaKeyDown) { 153 | clearSelections([data.id]); 154 | } else { 155 | toggleSelection(data.id); 156 | } 157 | 158 | setMetaKeyDown(false); 159 | }; 160 | 161 | const onKeyDown = (event) => { 162 | event.preventDefault(); 163 | setMetaKeyDown(event.metaKey || event.ctrlKey); 164 | }; 165 | 166 | const onCanvasClick = () => { 167 | clearSelections(); 168 | setMetaKeyDown(false); 169 | }; 170 | 171 | useHotkeys([ 172 | { 173 | name: 'Select All', 174 | keys: 'mod+a', 175 | disabled: !hotkeys.includes('selectAll'), 176 | category: 'Canvas', 177 | description: 'Select all nodes and edges', 178 | callback: (event) => { 179 | event.preventDefault(); 180 | 181 | if (!disabled) { 182 | const next = nodes.map((n) => n.id); 183 | onDataChange?.(nodes, edges); 184 | onSelection?.(next); 185 | setInternalSelections(next); 186 | } 187 | } 188 | }, 189 | { 190 | name: 'Delete Selections', 191 | category: 'Canvas', 192 | disabled: !hotkeys.includes('delete'), 193 | description: 'Delete selected nodes and edges', 194 | keys: 'backspace', 195 | callback: (event) => { 196 | if (!disabled) { 197 | event.preventDefault(); 198 | const result = removeNode(nodes, edges, internalSelections); 199 | onDataChange?.(result.nodes, result.edges); 200 | onSelection?.([]); 201 | setInternalSelections([]); 202 | } 203 | } 204 | }, 205 | { 206 | name: 'Deselect Selections', 207 | category: 'Canvas', 208 | disabled: !hotkeys.includes('deselect'), 209 | description: 'Deselect selected nodes and edges', 210 | keys: 'escape', 211 | callback: (event) => { 212 | if (!disabled) { 213 | event.preventDefault(); 214 | onSelection?.([]); 215 | setInternalSelections([]); 216 | } 217 | } 218 | } 219 | ]); 220 | 221 | return { 222 | onClick, 223 | onKeyDown, 224 | onCanvasClick, 225 | selections: internalSelections, 226 | clearSelections, 227 | addSelection, 228 | removeSelection, 229 | toggleSelection, 230 | setSelections: setInternalSelections 231 | }; 232 | }; 233 | -------------------------------------------------------------------------------- /src/helpers/useUndo.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | import { useHotkeys } from 'reakeys'; 3 | import { EdgeData, NodeData } from '../types'; 4 | import Undoo from 'undoo'; 5 | 6 | export interface UndoRedoEvent { 7 | /** 8 | * Updated node datas. 9 | */ 10 | nodes?: NodeData[]; 11 | 12 | /** 13 | * Updated edge datas. 14 | */ 15 | edges?: EdgeData[]; 16 | 17 | /** 18 | * Type of change. 19 | */ 20 | type: 'undo' | 'redo' | 'clear'; 21 | 22 | /** 23 | * Whether you can undo now. 24 | */ 25 | canUndo: boolean; 26 | 27 | /** 28 | * Whether you can redo now. 29 | */ 30 | canRedo: boolean; 31 | } 32 | 33 | export interface UndoProps { 34 | /** 35 | * Current node datas. 36 | */ 37 | nodes: NodeData[]; 38 | 39 | /** 40 | * Current edge datas. 41 | */ 42 | edges: EdgeData[]; 43 | 44 | /** 45 | * Max history count. 46 | * 47 | * @default 20 48 | */ 49 | maxHistory?: number; 50 | 51 | /** 52 | * Disabled or not. 53 | * 54 | * @default false 55 | */ 56 | disabled?: boolean; 57 | 58 | /** 59 | * On undo/redo event handler. 60 | */ 61 | onUndoRedo: (state: UndoRedoEvent) => void; 62 | } 63 | 64 | export interface UndoResult { 65 | /** 66 | * Can undo or not. 67 | */ 68 | canUndo: boolean; 69 | 70 | /** 71 | * Can redo or not. 72 | */ 73 | canRedo: boolean; 74 | 75 | /** 76 | * Count of existing changes. 77 | */ 78 | count: () => number; 79 | 80 | /** 81 | * Clear state. 82 | */ 83 | clear: (nodes: NodeData[], edges: EdgeData[]) => void; 84 | 85 | /** 86 | * Get history of state. 87 | */ 88 | history: () => { nodes: NodeData[]; edges: EdgeData[] }[]; 89 | 90 | /** 91 | * Perform an redo. 92 | */ 93 | redo: () => void; 94 | 95 | /** 96 | * Perform a undo. 97 | */ 98 | undo: () => void; 99 | } 100 | 101 | export const useUndo = ({ 102 | nodes, 103 | edges, 104 | disabled, 105 | maxHistory = 20, 106 | onUndoRedo 107 | }: UndoProps): UndoResult => { 108 | const [canUndo, setCanUndo] = useState(false); 109 | const [canRedo, setCanRedo] = useState(false); 110 | 111 | const manager = useRef( 112 | new Undoo({ 113 | maxLength: maxHistory 114 | }) 115 | ); 116 | 117 | // Reference: https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback 118 | const callbackRef = useRef(onUndoRedo); 119 | useEffect(() => { 120 | callbackRef.current = onUndoRedo; 121 | }, [onUndoRedo]); 122 | 123 | useEffect(() => { 124 | manager.current.save({ 125 | nodes, 126 | edges 127 | }); 128 | 129 | setCanUndo(manager.current.canUndo()); 130 | setCanRedo(manager.current.canRedo()); 131 | }, [nodes, edges]); 132 | 133 | const undo = useCallback(() => { 134 | manager.current.undo((state) => { 135 | const nextUndo = manager.current.canUndo(); 136 | const nextRedo = manager.current.canRedo(); 137 | setCanUndo(nextUndo); 138 | setCanRedo(nextRedo); 139 | 140 | callbackRef.current({ 141 | ...state, 142 | type: 'undo', 143 | canUndo: nextUndo, 144 | canRedo: nextRedo 145 | }); 146 | }); 147 | }, []); 148 | 149 | const redo = useCallback(() => { 150 | manager.current.redo((state) => { 151 | const nextUndo = manager.current.canUndo(); 152 | const nextRedo = manager.current.canRedo(); 153 | setCanUndo(nextUndo); 154 | setCanRedo(nextRedo); 155 | 156 | callbackRef.current({ 157 | ...state, 158 | type: 'redo', 159 | canUndo: nextUndo, 160 | canRedo: nextRedo 161 | }); 162 | }); 163 | }, []); 164 | 165 | const clear = useCallback((nodes: NodeData[], edges: EdgeData[]) => { 166 | manager.current.clear(); 167 | setCanUndo(false); 168 | setCanRedo(false); 169 | 170 | callbackRef.current({ 171 | type: 'clear', 172 | canUndo: false, 173 | canRedo: false 174 | }); 175 | 176 | manager.current.save({ 177 | nodes, 178 | edges 179 | }); 180 | }, []); 181 | 182 | useHotkeys([ 183 | { 184 | name: 'Undo', 185 | keys: 'mod+z', 186 | category: 'Canvas', 187 | description: 'Undo changes', 188 | callback: (event) => { 189 | event.preventDefault(); 190 | if (!disabled && canUndo) { 191 | undo(); 192 | } 193 | } 194 | }, 195 | { 196 | name: 'Redo', 197 | keys: 'mod+shift+z', 198 | category: 'Canvas', 199 | description: 'Redo changes', 200 | callback: (event) => { 201 | event.preventDefault(); 202 | if (!disabled && canRedo) { 203 | redo(); 204 | } 205 | } 206 | } 207 | ]); 208 | 209 | return { 210 | canUndo, 211 | canRedo, 212 | count: () => manager.current.count(), 213 | history: () => manager.current.history(), 214 | clear, 215 | redo, 216 | undo 217 | } as UndoResult; 218 | }; 219 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Canvas'; 2 | export * from './symbols'; 3 | export * from './types'; 4 | export * from './utils'; 5 | export * from './layout'; 6 | export * from './helpers'; 7 | -------------------------------------------------------------------------------- /src/layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './elkLayout'; 2 | export * from './useLayout'; 3 | export * from './utils'; 4 | -------------------------------------------------------------------------------- /src/layout/useLayout.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; 2 | import useDimensions from 'react-cool-dimensions'; 3 | import isEqual from 'react-fast-compare'; 4 | import { CanvasPosition, EdgeData, NodeData } from '../types'; 5 | import { CanvasDirection, ElkCanvasLayoutOptions, elkLayout } from './elkLayout'; 6 | import { calculateScrollPosition, calculateZoom, findNode } from './utils'; 7 | 8 | export interface ElkRoot { 9 | x?: number; 10 | y?: number; 11 | width?: number; 12 | height?: number; 13 | children?: any[]; 14 | edges?: any[]; 15 | direction?: CanvasDirection; 16 | } 17 | 18 | export interface LayoutProps { 19 | maxHeight: number; 20 | maxWidth: number; 21 | nodes: NodeData[]; 22 | edges: EdgeData[]; 23 | pannable: boolean; 24 | defaultPosition: CanvasPosition; 25 | fit: boolean; 26 | zoom: number; 27 | layoutOptions?: ElkCanvasLayoutOptions; 28 | direction: CanvasDirection; 29 | setZoom: (factor: number) => void; 30 | onLayoutChange: (layout: ElkRoot) => void; 31 | } 32 | 33 | export interface LayoutResult { 34 | /** 35 | * X/Y offset. 36 | */ 37 | xy: [number, number]; 38 | 39 | /** 40 | * Scroll offset. 41 | */ 42 | scrollXY: [number, number]; 43 | 44 | /** 45 | * ELK Layout object. 46 | */ 47 | layout: ElkRoot; 48 | 49 | /** 50 | * Ref to container div. 51 | */ 52 | containerRef: RefObject; 53 | 54 | /** 55 | * Height of the svg. 56 | */ 57 | canvasHeight?: number; 58 | 59 | /** 60 | * Width of the svg. 61 | */ 62 | canvasWidth?: number; 63 | 64 | /** 65 | * Width of the container div. 66 | */ 67 | containerWidth?: number; 68 | 69 | /** 70 | * Height of the container div. 71 | */ 72 | containerHeight?: number; 73 | 74 | /** 75 | * Positions the canvas to the viewport. 76 | */ 77 | positionCanvas?: (position: CanvasPosition, animated?: boolean) => void; 78 | 79 | /** 80 | * Fit the canvas to the viewport. 81 | */ 82 | fitCanvas?: (animated?: boolean) => void; 83 | 84 | /** 85 | * Fit a group of nodes to the viewport. 86 | */ 87 | fitNodes?: (nodeIds: string | string[], animated?: boolean) => void; 88 | 89 | /** 90 | * Scroll to X/Y 91 | */ 92 | setScrollXY?: (xy: [number, number], animated?: boolean) => void; 93 | 94 | observe: (el: HTMLDivElement) => void; 95 | } 96 | 97 | export const useLayout = ({ maxWidth, maxHeight, nodes = [], edges = [], fit, pannable, defaultPosition, direction, layoutOptions = {}, zoom, setZoom, onLayoutChange }: LayoutProps) => { 98 | const scrolled = useRef(false); 99 | const ref = useRef(); 100 | const { observe, width, height } = useDimensions(); 101 | const [layout, setLayout] = useState(null); 102 | const [xy, setXY] = useState<[number, number]>([0, 0]); 103 | const [scrollXY, setScrollXY] = useState<[number, number]>([0, 0]); 104 | const canvasHeight = pannable ? maxHeight : height; 105 | const canvasWidth = pannable ? maxWidth : width; 106 | 107 | const scrollToXY = (xy: [number, number], animated = false) => { 108 | ref.current.scrollTo({ left: xy[0], top: xy[1], behavior: animated ? 'smooth' : 'auto' }); 109 | setScrollXY(xy); 110 | }; 111 | 112 | useEffect(() => { 113 | const promise = elkLayout(nodes, edges, { 114 | 'elk.direction': direction, 115 | ...layoutOptions 116 | }); 117 | 118 | promise 119 | .then((result) => { 120 | if (!isEqual(layout, result)) { 121 | setLayout(result); 122 | onLayoutChange(result); 123 | } 124 | }) 125 | .catch((err) => { 126 | if (err.name !== 'CancelError') { 127 | console.error('Layout Error:', err); 128 | } 129 | }); 130 | 131 | return () => promise.cancel(); 132 | // eslint-disable-next-line react-hooks/exhaustive-deps 133 | }, [nodes, edges]); 134 | 135 | const positionVector = useCallback( 136 | (position: CanvasPosition) => { 137 | if (layout) { 138 | const centerX = (canvasWidth - layout.width * zoom) / 2; 139 | const centerY = (canvasHeight - layout.height * zoom) / 2; 140 | switch (position) { 141 | case CanvasPosition.CENTER: 142 | setXY([centerX, centerY]); 143 | break; 144 | case CanvasPosition.TOP: 145 | setXY([centerX, 0]); 146 | break; 147 | case CanvasPosition.LEFT: 148 | setXY([0, centerY]); 149 | break; 150 | case CanvasPosition.RIGHT: 151 | setXY([canvasWidth - layout.width * zoom, centerY]); 152 | break; 153 | case CanvasPosition.BOTTOM: 154 | setXY([centerX, canvasHeight - layout.height * zoom]); 155 | break; 156 | } 157 | } 158 | }, 159 | [canvasWidth, canvasHeight, layout, zoom] 160 | ); 161 | 162 | const positionScroll = useCallback( 163 | (position: CanvasPosition, animated = false) => { 164 | const scrollCenterX = (canvasWidth - width) / 2; 165 | const scrollCenterY = (canvasHeight - height) / 2; 166 | if (pannable) { 167 | switch (position) { 168 | case CanvasPosition.CENTER: 169 | scrollToXY([scrollCenterX, scrollCenterY], animated); 170 | break; 171 | case CanvasPosition.TOP: 172 | scrollToXY([scrollCenterX, 0], animated); 173 | break; 174 | case CanvasPosition.LEFT: 175 | scrollToXY([0, scrollCenterY], animated); 176 | break; 177 | case CanvasPosition.RIGHT: 178 | scrollToXY([canvasWidth - width, scrollCenterY], animated); 179 | break; 180 | case CanvasPosition.BOTTOM: 181 | scrollToXY([scrollCenterX, canvasHeight - height], animated); 182 | break; 183 | } 184 | } 185 | }, 186 | [canvasWidth, canvasHeight, width, height, pannable] 187 | ); 188 | 189 | const positionCanvas = useCallback( 190 | (position: CanvasPosition, animated = false) => { 191 | positionVector(position); 192 | positionScroll(position, animated); 193 | }, 194 | [positionScroll, positionVector] 195 | ); 196 | 197 | useEffect(() => { 198 | if (scrolled.current && defaultPosition) { 199 | positionVector(defaultPosition); 200 | } 201 | }, [positionVector, zoom, defaultPosition]); 202 | 203 | const fitCanvas = useCallback( 204 | (animated = false) => { 205 | if (layout) { 206 | const heightZoom = height / layout.height; 207 | const widthZoom = width / layout.width; 208 | const scale = Math.min(heightZoom, widthZoom, 1); 209 | setZoom(scale - 1); 210 | positionCanvas(CanvasPosition.CENTER, animated); 211 | } 212 | }, 213 | [height, layout, width, setZoom, positionCanvas] 214 | ); 215 | 216 | /** 217 | * This centers the chart on the canvas, zooms in to fit the specified nodes, and scrolls to center the nodes in the viewport 218 | */ 219 | const fitNodes = useCallback( 220 | (nodeIds: string | string[], animated = true) => { 221 | if (layout && layout.children) { 222 | const nodes = Array.isArray(nodeIds) ? nodeIds.map((nodeId) => findNode(layout.children, nodeId)) : [findNode(layout.children, nodeIds)]; 223 | 224 | if (nodes) { 225 | // center the chart 226 | positionVector(CanvasPosition.CENTER); 227 | 228 | const updatedZoom = calculateZoom({ nodes, viewportWidth: width, viewportHeight: height, maxViewportCoverage: 0.9, minViewportCoverage: 0.2 }); 229 | const scrollPosition = calculateScrollPosition({ nodes, viewportWidth: width, viewportHeight: height, canvasWidth, canvasHeight, chartWidth: layout.width, chartHeight: layout.height, zoom: updatedZoom }); 230 | 231 | setZoom(updatedZoom - 1); 232 | scrollToXY(scrollPosition, animated); 233 | } 234 | } 235 | }, 236 | [canvasHeight, canvasWidth, height, layout, positionVector, setZoom, width] 237 | ); 238 | 239 | useLayoutEffect(() => { 240 | const scroller = ref.current; 241 | if (scroller && !scrolled.current && layout && height && width) { 242 | if (fit) { 243 | fitCanvas(); 244 | } else if (defaultPosition) { 245 | positionCanvas(defaultPosition); 246 | } 247 | 248 | scrolled.current = true; 249 | } 250 | }, [canvasWidth, pannable, canvasHeight, layout, height, fit, width, defaultPosition, positionCanvas, fitCanvas, ref]); 251 | 252 | useLayoutEffect(() => { 253 | function onResize() { 254 | if (fit) { 255 | fitCanvas(); 256 | } else if (defaultPosition) { 257 | positionCanvas(defaultPosition); 258 | } 259 | } 260 | 261 | window.addEventListener('resize', onResize); 262 | 263 | return () => window.removeEventListener('resize', onResize); 264 | }, [fit, positionCanvas, defaultPosition, fitCanvas]); 265 | 266 | return { 267 | xy, 268 | observe, 269 | containerRef: ref, 270 | canvasHeight, 271 | canvasWidth, 272 | containerWidth: width, 273 | containerHeight: height, 274 | layout, 275 | scrollXY, 276 | positionCanvas, 277 | fitCanvas, 278 | fitNodes, 279 | setScrollXY: scrollToXY 280 | } as LayoutResult; 281 | }; 282 | -------------------------------------------------------------------------------- /src/layout/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { parsePadding, findNode, getChildCount, calculateZoom, calculateScrollPosition } from './utils'; 2 | 3 | test('should set all sides to input number, when a number is provided', () => { 4 | const expectedPadding = { 5 | top: 10, 6 | right: 10, 7 | bottom: 10, 8 | left: 10 9 | }; 10 | expect(parsePadding(10)).toEqual(expectedPadding); 11 | }); 12 | 13 | test('should set horizontal and vertical padding, when an array with numbers is provided', () => { 14 | const expectedPadding = { 15 | top: 20, 16 | right: 50, 17 | bottom: 20, 18 | left: 50 19 | }; 20 | expect(parsePadding([20, 50])).toEqual(expectedPadding); 21 | }); 22 | 23 | test('should set each padding value individually, when an array with four numbers is provided', () => { 24 | const expectedPadding = { 25 | top: 20, 26 | right: 50, 27 | bottom: 100, 28 | left: 150 29 | }; 30 | expect(parsePadding([20, 50, 100, 150])).toEqual(expectedPadding); 31 | }); 32 | 33 | test('should find a node by id', () => { 34 | const layout = [ 35 | { 36 | x: 0, 37 | y: 0, 38 | id: '1', 39 | children: [{ x: 0, y: 0, id: '1', children: [] }] 40 | }, 41 | { 42 | x: 0, 43 | y: 0, 44 | id: '3', 45 | children: [{ x: 0, y: 0, id: '4', children: [] }] 46 | } 47 | ]; 48 | const node = findNode(layout, '4'); 49 | 50 | expect(node).toEqual({ x: 0, y: 0, id: '4', children: [] }); 51 | }); 52 | 53 | test('should get the number of children a node has', () => { 54 | const node = { 55 | x: 0, 56 | y: 0, 57 | id: '1', 58 | children: [ 59 | { x: 0, y: 0, id: '1', children: [] }, 60 | { x: 0, y: 0, id: '2', children: [{ x: 0, y: 0, id: '3', children: [] }] } 61 | ] 62 | }; 63 | const count = getChildCount(node); 64 | 65 | expect(count).toEqual(3); 66 | }); 67 | 68 | describe('calculateZoom', () => { 69 | test('should calculate the zoom for a node', () => { 70 | const node = { width: 100, height: 100, x: 0, y: 0, id: '1' }; 71 | const zoom = calculateZoom({ nodes: [node], viewportWidth: 1000, viewportHeight: 1000, minViewportCoverage: 0.2, maxViewportCoverage: 0.9 }); 72 | 73 | expect(zoom).toEqual(2); 74 | }); 75 | 76 | test('should calculate the zoom for a node with many children', () => { 77 | const node = { width: 100, height: 100, x: 0, y: 0, id: '0', children: [{ x: 0, y: 0, id: '1', children: [{ x: 0, y: 0, id: '2', children: [{ x: 0, y: 0, id: '3', children: [] }] }] }] }; 78 | const zoom = calculateZoom({ nodes: [node], viewportWidth: 1000, viewportHeight: 1000, minViewportCoverage: 0.2, maxViewportCoverage: 0.9 }); 79 | 80 | expect(zoom).toEqual(5); 81 | }); 82 | 83 | test('should calculate the zoom for a group of nodes', () => { 84 | const nodes = [ 85 | { width: 100, height: 100, x: 0, y: 0, id: '0' }, 86 | { width: 100, height: 100, x: 50, y: 50, id: '1' } 87 | ]; 88 | const zoom = calculateZoom({ nodes, viewportWidth: 1000, viewportHeight: 1000, minViewportCoverage: 0.2, maxViewportCoverage: 0.9 }); 89 | 90 | expect(zoom).toEqual(2); 91 | }); 92 | }); 93 | 94 | describe('calculateScrollPosition', () => { 95 | test('should calculate the scroll position for a node', () => { 96 | const node = { width: 100, height: 100, x: 0, y: 0, id: '1' }; 97 | const scrollPosition = calculateScrollPosition({ nodes: [node], viewportWidth: 1000, viewportHeight: 1000, canvasWidth: 2000, canvasHeight: 2000, chartWidth: 500, chartHeight: 500, zoom: 1 }); 98 | 99 | expect(scrollPosition).toEqual([300, 300]); 100 | }); 101 | 102 | test('should calculate the scroll position for a group of nodes', () => { 103 | const nodes = [ 104 | { width: 100, height: 100, x: 0, y: 0, id: '0' }, 105 | { width: 100, height: 100, x: 50, y: 50, id: '1' } 106 | ]; 107 | const scrollPosition = calculateScrollPosition({ nodes, viewportWidth: 1000, viewportHeight: 1000, canvasWidth: 2000, canvasHeight: 2000, chartWidth: 500, chartHeight: 500, zoom: 1 }); 108 | 109 | expect(scrollPosition).toEqual([325, 325]); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/layout/utils.ts: -------------------------------------------------------------------------------- 1 | import calculateSize from 'calculate-size'; 2 | import { LayoutNodeData, NodeData } from '../types'; 3 | import ellipsize from 'ellipsize'; 4 | 5 | const MAX_CHAR_COUNT = 35; 6 | const MIN_NODE_WIDTH = 50; 7 | const DEFAULT_NODE_HEIGHT = 50; 8 | const NODE_PADDING = 30; 9 | const ICON_PADDING = 10; 10 | 11 | export function measureText(text: string) { 12 | let result = { height: 0, width: 0 }; 13 | 14 | if (text) { 15 | // Reference: https://github.com/reaviz/reaflow/pull/229 16 | // @ts-ignore 17 | const fn = typeof calculateSize === 'function' ? calculateSize : calculateSize.default; 18 | result = fn(text, { 19 | font: 'Arial, sans-serif', 20 | fontSize: '14px' 21 | }); 22 | } 23 | 24 | return result; 25 | } 26 | 27 | export function parsePadding(padding: NodeData['nodePadding']) { 28 | let top = 50; 29 | let right = 50; 30 | let bottom = 50; 31 | let left = 50; 32 | 33 | if (Array.isArray(padding)) { 34 | if (padding.length === 2) { 35 | top = padding[0]; 36 | bottom = padding[0]; 37 | left = padding[1]; 38 | right = padding[1]; 39 | } else if (padding.length === 4) { 40 | top = padding[0]; 41 | right = padding[1]; 42 | bottom = padding[2]; 43 | left = padding[3]; 44 | } 45 | } else if (padding !== undefined) { 46 | top = padding; 47 | right = padding; 48 | bottom = padding; 49 | left = padding; 50 | } 51 | 52 | return { 53 | top, 54 | right, 55 | bottom, 56 | left 57 | }; 58 | } 59 | 60 | export function formatText(node: NodeData) { 61 | const text = node.text ? ellipsize(node.text, MAX_CHAR_COUNT) : node.text; 62 | 63 | const labelDim = measureText(text); 64 | const nodePadding = parsePadding(node.nodePadding); 65 | 66 | let width = node.width; 67 | if (width === undefined) { 68 | if (text && node.icon) { 69 | width = labelDim.width + node.icon.width + NODE_PADDING + ICON_PADDING; 70 | } else { 71 | if (text) { 72 | width = labelDim.width + NODE_PADDING; 73 | } else if (node.icon) { 74 | width = node.icon.width + NODE_PADDING; 75 | } 76 | 77 | width = Math.max(width, MIN_NODE_WIDTH); 78 | } 79 | } 80 | 81 | let height = node.height; 82 | if (height === undefined) { 83 | if (text && node.icon) { 84 | height = labelDim.height + node.icon.height; 85 | } else if (text) { 86 | height = labelDim.height + NODE_PADDING; 87 | } else if (node.icon) { 88 | height = node.icon.height + NODE_PADDING; 89 | } 90 | 91 | height = Math.max(height, DEFAULT_NODE_HEIGHT); 92 | } 93 | 94 | return { 95 | text, 96 | originalText: node.text, 97 | width, 98 | height, 99 | nodePadding, 100 | labelHeight: labelDim.height, 101 | labelWidth: labelDim.width 102 | }; 103 | } 104 | 105 | /** 106 | * Finds a node in a tree of nodes 107 | * @param nodes - The nodes to search through 108 | * @param nodeId - The id of the node to find 109 | * @returns The node if found, undefined otherwise 110 | */ 111 | export const findNode = (nodes: LayoutNodeData[], nodeId: string): any | undefined => { 112 | for (const node of nodes) { 113 | if (node.id === nodeId) { 114 | return node; 115 | } 116 | if (node.children) { 117 | const foundNode = findNode(node.children, nodeId); 118 | if (foundNode) { 119 | return foundNode; 120 | } 121 | } 122 | } 123 | return undefined; 124 | }; 125 | 126 | /** 127 | * Finds the number of nested children a node has 128 | * @param node - The node to search through 129 | * @returns The number of children 130 | */ 131 | export const getChildCount = (node: LayoutNodeData): number => { 132 | return ( 133 | node.children?.reduce((acc, child) => { 134 | if (child.children) { 135 | return acc + 1 + getChildCount(child); 136 | } 137 | return acc + 1; 138 | }, 0) ?? 0 139 | ); 140 | }; 141 | 142 | /** 143 | * Calculates the zoom for a group of nodes when fitting to the viewport 144 | * @param nodes - The nodes to calculate the zoom for 145 | * @param viewportWidth - The width of the viewport 146 | * @param viewportHeight - The height of the viewport 147 | * @param maxViewportCoverage - The maximum percentage of the viewport that the node group will take up 148 | * @param minViewportCoverage - The minimum percentage of the viewport that the node group will take up 149 | * @returns The zoom 150 | */ 151 | export const calculateZoom = ({ nodes, viewportWidth, viewportHeight, maxViewportCoverage = 0.9, minViewportCoverage = 0.2 }: { nodes: LayoutNodeData[]; viewportWidth: number; viewportHeight: number; maxViewportCoverage?: number; minViewportCoverage?: number }) => { 152 | const maxChildren = Math.max( 153 | 0, 154 | nodes.map(getChildCount).reduce((acc, curr) => acc + curr, 0) 155 | ); 156 | const boundingBox = getNodesBoundingBox(nodes); 157 | const boundingBoxWidth = boundingBox.x1 - boundingBox.x0; 158 | const boundingBoxHeight = boundingBox.y1 - boundingBox.y0; 159 | 160 | // calculate the maximum zoom to ensure no single node takes up more than 20% of the viewport 161 | const maxNodeWidth = Math.max(...nodes.map((node) => node.width)); 162 | const maxNodeHeight = Math.max(...nodes.map((node) => node.height)); 163 | // if a node has children, let it take up an extra 10% per child 164 | const maxNodeZoomX = ((0.2 + maxChildren * 0.1) * viewportWidth) / maxNodeWidth; 165 | const maxNodeZoomY = ((0.2 + maxChildren * 0.1) * viewportHeight) / maxNodeHeight; 166 | const maxNodeZoom = Math.min(maxNodeZoomX, maxNodeZoomY); 167 | 168 | const viewportCoverage = Math.max(Math.min(maxViewportCoverage, maxNodeZoom), minViewportCoverage); 169 | 170 | const updatedHorizontalZoom = (viewportCoverage * viewportWidth) / boundingBoxWidth; 171 | const updatedVerticalZoom = (viewportCoverage * viewportHeight) / boundingBoxHeight; 172 | const updatedZoom = Math.min(updatedHorizontalZoom, updatedVerticalZoom, maxNodeZoom); 173 | 174 | return updatedZoom; 175 | }; 176 | 177 | /** 178 | * Calculates the scroll position for the canvas when fitting nodes to the viewport - assumes the chart is centered 179 | * @param nodes - The nodes to calculate the zoom and position for 180 | * @param viewportWidth - The width of the viewport 181 | * @param viewportHeight - The height of the viewport 182 | * @param canvasWidth - The width of the canvas 183 | * @param canvasHeight - The height of the canvas 184 | * @param chartWidth - The width of the chart 185 | * @param chartHeight - The height of the chart 186 | * @param zoom - The zoom level of the canvas 187 | * @returns The scroll position 188 | */ 189 | export const calculateScrollPosition = ({ nodes, viewportWidth, viewportHeight, canvasWidth, canvasHeight, chartWidth, chartHeight, zoom }: { nodes: LayoutNodeData[]; viewportWidth: number; viewportHeight: number; canvasWidth: number; canvasHeight: number; chartWidth: number; chartHeight: number; zoom: number }): [number, number] => { 190 | const { x0, y0, x1, y1 } = getNodesBoundingBox(nodes); 191 | const boundingBoxWidth = (x1 - x0) * zoom; 192 | const boundingBoxHeight = (y1 - y0) * zoom; 193 | 194 | // the chart is centered so we can assume the x and y positions 195 | const chartPosition = { 196 | x: (canvasWidth - chartWidth * zoom) / 2, 197 | y: (canvasHeight - chartHeight * zoom) / 2 198 | }; 199 | 200 | const boxXPosition = chartPosition.x + x0 * zoom; 201 | const boxYPosition = chartPosition.y + y0 * zoom; 202 | 203 | const boxCenterXPosition = boxXPosition + boundingBoxWidth / 2; 204 | const boxCenterYPosition = boxYPosition + boundingBoxHeight / 2; 205 | 206 | // scroll to the spot that centers the node in the viewport 207 | const scrollX = boxCenterXPosition - viewportWidth / 2; 208 | const scrollY = boxCenterYPosition - viewportHeight / 2; 209 | 210 | return [scrollX, scrollY]; 211 | }; 212 | 213 | /** 214 | * Calculates the bounding box of a group of nodes 215 | * @param nodes - The nodes to calculate the bounding box for 216 | * @returns The bounding box 217 | */ 218 | export const getNodesBoundingBox = (nodes: LayoutNodeData[]) => { 219 | return nodes.reduce( 220 | (acc, node) => ({ 221 | x0: Math.min(acc.x0, node.x), 222 | y0: Math.min(acc.y0, node.y), 223 | x1: Math.max(acc.x1, node.x + node.width), 224 | y1: Math.max(acc.y1, node.y + node.height) 225 | }), 226 | { x0: nodes[0].x, y0: nodes[0].y, x1: nodes[0].x + nodes[0].width, y1: nodes[0].y + nodes[0].height } 227 | ); 228 | }; 229 | -------------------------------------------------------------------------------- /src/symbols/Add/Add.module.css: -------------------------------------------------------------------------------- 1 | .plus { 2 | stroke: black; 3 | pointer-events: none; 4 | } 5 | 6 | .container { 7 | will-change: transform, opacity; 8 | } 9 | 10 | .drop { 11 | cursor: pointer; 12 | opacity: 0; 13 | } 14 | 15 | .rect { 16 | shape-rendering: geometricPrecision; 17 | fill: #46FECB; 18 | border-radius: 2px; 19 | pointer-events: none; 20 | } 21 | -------------------------------------------------------------------------------- /src/symbols/Add/Add.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import classNames from 'classnames'; 3 | import { motion } from 'motion/react'; 4 | import css from './Add.module.css'; 5 | 6 | export interface AddProps { 7 | x: number; 8 | y: number; 9 | size?: number; 10 | className?: string; 11 | hidden?: boolean; 12 | onEnter?: (event: React.MouseEvent) => void; 13 | onLeave?: (event: React.MouseEvent) => void; 14 | onClick?: (event: React.MouseEvent) => void; 15 | } 16 | 17 | export const Add: FC> = ({ x, y, className, size = 15, hidden = true, onEnter = () => undefined, onLeave = () => undefined, onClick = () => undefined }) => { 18 | if (hidden) { 19 | return null; 20 | } 21 | 22 | const half = size / 2; 23 | const translateX = x - half; 24 | const translateY = y - half; 25 | 26 | return ( 27 | 28 | { 33 | event.preventDefault(); 34 | event.stopPropagation(); 35 | onClick(event); 36 | }} 37 | onMouseEnter={onEnter} 38 | onMouseLeave={onLeave} 39 | /> 40 | 41 | 42 | 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/symbols/Add/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Add'; 2 | -------------------------------------------------------------------------------- /src/symbols/Arrow/Arrow.module.css: -------------------------------------------------------------------------------- 1 | .arrow { 2 | pointer-events: none; 3 | shape-rendering: geometricPrecision; 4 | fill: #485a74; 5 | } 6 | -------------------------------------------------------------------------------- /src/symbols/Arrow/Arrow.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import classNames from 'classnames'; 3 | import css from './Arrow.module.css'; 4 | 5 | export interface ArrowProps { 6 | size?: number; 7 | x?: number; 8 | y?: number; 9 | angle?: number; 10 | className?: string; 11 | style?: any; 12 | } 13 | 14 | export const Arrow: FC = ({ 15 | size = 8, 16 | y = 0, 17 | x = 0, 18 | angle = 0, 19 | className, 20 | style 21 | }) => ( 22 | 28 | ); 29 | -------------------------------------------------------------------------------- /src/symbols/Arrow/MarkerArrow.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Arrow } from './Arrow'; 3 | 4 | export interface MarkerArrowProps { 5 | size?: number; 6 | style?: any; 7 | className?: string; 8 | } 9 | 10 | export const MarkerArrow: FC> = ({ 11 | size = 8, 12 | className, 13 | style 14 | }) => ( 15 | 24 | 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /src/symbols/Arrow/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Arrow'; 2 | export * from './MarkerArrow'; 3 | -------------------------------------------------------------------------------- /src/symbols/Edge/Edge.module.css: -------------------------------------------------------------------------------- 1 | .edge { 2 | &.disabled { 3 | pointer-events: none; 4 | } 5 | 6 | &:not(.selectionDisabled):not(.disabled) { 7 | &:hover { 8 | .path { 9 | stroke: #a5a9e2; 10 | 11 | &.active { 12 | stroke: #46fecb; 13 | } 14 | 15 | &.deleteHovered { 16 | stroke: #ff005d; 17 | stroke-dasharray: 4 2; 18 | } 19 | } 20 | } 21 | 22 | .clicker { 23 | cursor: pointer; 24 | } 25 | } 26 | } 27 | 28 | .path { 29 | fill: transparent; 30 | stroke: #485a74; 31 | pointer-events: none; 32 | shape-rendering: geometricPrecision; 33 | stroke-width: 1pt; 34 | } 35 | 36 | .clicker { 37 | fill: none; 38 | stroke: transparent; 39 | stroke-width: 15px; 40 | 41 | &:focus { 42 | outline: none; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/symbols/Edge/Edge.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, Fragment, MutableRefObject, ReactElement, ReactNode, useEffect, useMemo, useRef, useState } from 'react'; 2 | import { EdgeData } from '../../types'; 3 | import { Label, LabelProps } from '../Label'; 4 | import { CloneElement } from 'reablocks'; 5 | import classNames from 'classnames'; 6 | import { CenterCoords, getBezierPath, getPathCenter } from './utils'; 7 | import { curveBundle, line } from 'd3-shape'; 8 | import { Remove, RemoveProps } from '../Remove'; 9 | import { Add, AddProps } from '../Add'; 10 | import { useCanvas } from '../../utils/CanvasProvider'; 11 | import css from './Edge.module.css'; 12 | 13 | export interface EdgeSections { 14 | id?: string; 15 | startPoint?: { 16 | x: number; 17 | y: number; 18 | }; 19 | endPoint?: { 20 | x: number; 21 | y: number; 22 | }; 23 | bendPoints?: { 24 | x: number; 25 | y: number; 26 | }; 27 | } 28 | 29 | export interface EdgeChildProps { 30 | edge: EdgeData; 31 | pathRef: MutableRefObject | null; 32 | center: CenterCoords | null; 33 | } 34 | 35 | export type EdgeChildrenAsFunction = (edgeChildProps: EdgeChildProps) => ReactNode; 36 | 37 | export interface EdgeProps { 38 | id: string; 39 | disabled?: boolean; 40 | removable?: boolean; 41 | selectable?: boolean; 42 | upsertable?: boolean; 43 | source: string; 44 | sourcePort: string; 45 | target: string; 46 | targetPort: string; 47 | properties?: EdgeData; 48 | style?: any; 49 | children?: ReactNode | EdgeChildrenAsFunction; 50 | sections: EdgeSections[]; 51 | interpolation: 'linear' | 'curved' | Function; 52 | labels?: LabelProps[]; 53 | className?: string; 54 | containerClassName?: string; 55 | 56 | add: ReactElement; 57 | label: ReactElement; 58 | remove: ReactElement; 59 | 60 | onClick?: (event: React.MouseEvent, data: EdgeData) => void; 61 | onKeyDown?: (event: React.KeyboardEvent, data: EdgeData) => void; 62 | onEnter?: (event: React.MouseEvent, node: EdgeData) => void; 63 | onLeave?: (event: React.MouseEvent, node: EdgeData) => void; 64 | onRemove?: (event: React.MouseEvent, edge: EdgeData) => void; 65 | onAdd?: (event: React.MouseEvent, edge: EdgeData) => void; 66 | } 67 | 68 | export const Edge: FC> = ({ sections, interpolation = 'curved', properties, labels, className, containerClassName, disabled, removable = true, selectable = true, upsertable = true, style, children, add = , remove = , label =