├── .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 |
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 |
36 |
37 |
38 | ---
39 |
40 |
41 |
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 |
28 | You need to enable JavaScript to run this app.
29 |
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 = , onClick = () => undefined, onKeyDown = () => undefined, onEnter = () => undefined, onLeave = () => undefined, onRemove = () => undefined, onAdd = () => undefined }) => {
69 | const pathRef = useRef(null);
70 | const [deleteHovered, setDeleteHovered] = useState(false);
71 | const [center, setCenter] = useState(null);
72 | const { selections, readonly } = useCanvas();
73 | const isActive: boolean = selections?.length ? selections.includes(properties?.id) : false;
74 | const isDisabled = disabled || properties?.disabled;
75 | const canSelect = selectable && !properties?.selectionDisabled;
76 |
77 | // The "d" attribute defines a path to be drawn. See https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
78 | const d = useMemo(() => {
79 | if (!sections?.length) {
80 | return null;
81 | }
82 |
83 | // Handle bend points that elk gives
84 | // us separately from drag points
85 | if (sections[0].bendPoints) {
86 | const points: any[] = sections ? [sections[0].startPoint, ...(sections[0].bendPoints || ([] as any)), sections[0].endPoint] : [];
87 |
88 | let pathFn: any = line()
89 | .x((d: any) => d.x)
90 | .y((d: any) => d.y);
91 | if (interpolation !== 'linear') {
92 | pathFn = interpolation === 'curved' ? pathFn.curve(curveBundle.beta(1)) : interpolation;
93 | }
94 | return pathFn(points);
95 | } else {
96 | return getBezierPath({
97 | sourceX: sections[0].startPoint.x,
98 | sourceY: sections[0].startPoint.y,
99 | targetX: sections[0].endPoint.x,
100 | targetY: sections[0].endPoint.y
101 | });
102 | }
103 | }, [interpolation, sections]);
104 |
105 | useEffect(() => {
106 | if (sections?.length > 0) {
107 | setCenter(getPathCenter(pathRef.current, sections[0].startPoint, sections[0].endPoint));
108 | }
109 | }, [sections]);
110 |
111 | const edgeChildProps: EdgeChildProps = {
112 | edge: properties,
113 | center,
114 | pathRef
115 | };
116 |
117 | return (
118 |
124 |
134 | {
139 | event.preventDefault();
140 | event.stopPropagation();
141 | if (!isDisabled && canSelect) {
142 | onClick(event, properties);
143 | }
144 | }}
145 | onKeyDown={(event) => {
146 | event.preventDefault();
147 | event.stopPropagation();
148 | if (!isDisabled) {
149 | onKeyDown(event, properties);
150 | }
151 | }}
152 | onMouseEnter={(event) => {
153 | event.stopPropagation();
154 | if (!isDisabled) {
155 | onEnter(event, properties);
156 | }
157 | }}
158 | onMouseLeave={(event) => {
159 | event.stopPropagation();
160 | if (!isDisabled) {
161 | onLeave(event, properties);
162 | }
163 | }}
164 | />
165 | {children && {typeof children === 'function' ? (children as EdgeChildrenAsFunction)(edgeChildProps) : children} }
166 | {labels?.length > 0 && labels.map((l, index) => element={label} key={index} edgeChildProps={edgeChildProps} {...(l as LabelProps)} />)}
167 | {!isDisabled && center && !readonly && remove && removable && (
168 |
169 | element={remove}
170 | {...center}
171 | hidden={remove.props.hidden !== undefined ? remove.props.hidden : !isActive}
172 | onClick={(event: React.MouseEvent) => {
173 | event.preventDefault();
174 | event.stopPropagation();
175 | onRemove(event, properties);
176 | setDeleteHovered(false);
177 | }}
178 | onEnter={() => setDeleteHovered(true)}
179 | onLeave={() => setDeleteHovered(false)}
180 | />
181 | )}
182 | {!isDisabled && center && !readonly && add && upsertable && (
183 |
184 | element={add}
185 | {...center}
186 | onClick={(event) => {
187 | event.preventDefault();
188 | event.stopPropagation();
189 | onAdd(event, properties);
190 | }}
191 | />
192 | )}
193 |
194 | );
195 | };
196 |
--------------------------------------------------------------------------------
/src/symbols/Edge/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Edge';
2 |
--------------------------------------------------------------------------------
/src/symbols/Edge/utils.ts:
--------------------------------------------------------------------------------
1 | export interface PointCoords {
2 | x: number;
3 | y: number;
4 | }
5 |
6 | export interface CenterCoords {
7 | angle: number;
8 | x: number;
9 | y: number;
10 | }
11 |
12 | /**
13 | * Center helper.
14 | * Ref: https://github.com/wbkd/react-flow/blob/main/src/components/Edges/utils.ts#L18
15 | */
16 | function getBezierCenter({
17 | sourceX,
18 | sourceY,
19 | targetX,
20 | targetY
21 | }): [number, number, number, number] {
22 | const xOffset = Math.abs(targetX - sourceX) / 2;
23 | const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset;
24 |
25 | const yOffset = Math.abs(targetY - sourceY) / 2;
26 | const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset;
27 |
28 | return [centerX, centerY, xOffset, yOffset];
29 | }
30 |
31 | /**
32 | * Path helper utils.
33 | * Ref: https://github.com/wbkd/react-flow/blob/main/src/components/Edges/BezierEdge.tsx#L19
34 | */
35 | export function getBezierPath({
36 | sourceX,
37 | sourceY,
38 | sourcePosition = 'bottom',
39 | targetX,
40 | targetY,
41 | targetPosition = 'top'
42 | }): string {
43 | const leftAndRight = ['left', 'right'];
44 | const [centerX, centerY] = getBezierCenter({
45 | sourceX,
46 | sourceY,
47 | targetX,
48 | targetY
49 | });
50 |
51 | let path = `M${sourceX},${sourceY} C${sourceX},${centerY} ${targetX},${centerY} ${targetX},${targetY}`;
52 |
53 | if (
54 | leftAndRight.includes(sourcePosition) &&
55 | leftAndRight.includes(targetPosition)
56 | ) {
57 | path = `M${sourceX},${sourceY} C${centerX},${sourceY} ${centerX},${targetY} ${targetX},${targetY}`;
58 | } else if (leftAndRight.includes(targetPosition)) {
59 | path = `M${sourceX},${sourceY} C${sourceX},${targetY} ${sourceX},${targetY} ${targetX},${targetY}`;
60 | } else if (leftAndRight.includes(sourcePosition)) {
61 | path = `M${sourceX},${sourceY} C${targetX},${sourceY} ${targetX},${sourceY} ${targetX},${targetY}`;
62 | }
63 |
64 | return path;
65 | }
66 |
67 | /**
68 | * Calculate actual center for a path element.
69 | */
70 | function getCenter(pathElm: SVGPathElement) {
71 | const pLength = pathElm.getTotalLength();
72 | const pieceSize = pLength / 2;
73 | const { x, y } = pathElm.getPointAtLength(pieceSize);
74 | const angle = (Math.atan2(x, y) * 180) / Math.PI;
75 | return { x, y, angle };
76 | }
77 |
78 | /**
79 | * Get the angle for the path.
80 | */
81 | function getAngle(source: PointCoords, target: PointCoords) {
82 | const dx = source.x - target.x;
83 | const dy = source.y - target.y;
84 |
85 | let theta = Math.atan2(-dy, -dx);
86 | theta *= 180 / Math.PI;
87 | if (theta < 0) {
88 | theta += 360;
89 | }
90 |
91 | return theta;
92 | }
93 |
94 | /**
95 | * Get the center for the path element.
96 | */
97 | export function getPathCenter(
98 | pathElm: SVGPathElement,
99 | firstPoint: PointCoords,
100 | lastPoint: PointCoords
101 | ): CenterCoords {
102 | if (!pathElm) {
103 | return null;
104 | }
105 |
106 | const angle = getAngle(firstPoint, lastPoint);
107 | const point = getCenter(pathElm);
108 | return {
109 | ...point,
110 | angle
111 | };
112 | }
113 |
--------------------------------------------------------------------------------
/src/symbols/Icon/Icon.module.css:
--------------------------------------------------------------------------------
1 | .icon {
2 | pointer-events: none;
3 | }
4 |
--------------------------------------------------------------------------------
/src/symbols/Icon/Icon.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react';
2 | import classNames from 'classnames';
3 | import css from './Icon.module.css';
4 |
5 | export interface IconProps {
6 | x: number;
7 | y: number;
8 | url: string;
9 | height: number;
10 | width: number;
11 | style?: any;
12 | className?: string;
13 | }
14 |
15 | export const Icon: FC> = ({
16 | x,
17 | y,
18 | url,
19 | style,
20 | className,
21 | height = 40,
22 | width = 40
23 | }) => (
24 |
28 |
29 |
30 | );
31 |
--------------------------------------------------------------------------------
/src/symbols/Icon/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Icon';
2 |
--------------------------------------------------------------------------------
/src/symbols/Label/Label.module.css:
--------------------------------------------------------------------------------
1 | .text {
2 | fill: #d6e7ff;
3 | pointer-events: none;
4 | font-size: 14px;
5 | text-rendering: geometricPrecision;
6 | user-select: none;
7 | }
8 |
--------------------------------------------------------------------------------
/src/symbols/Label/Label.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react';
2 | import classNames from 'classnames';
3 | import css from './Label.module.css';
4 |
5 | export interface LabelProps {
6 | x: number;
7 | y: number;
8 | height: number;
9 | width: number;
10 | text: string;
11 | style?: any;
12 | className?: string;
13 | originalText?: string;
14 | }
15 |
16 | export const Label: FC> = ({ text, x, y, style, className, originalText }) => {
17 | const isString = typeof originalText === 'string';
18 | return (
19 | <>
20 | {isString && {originalText} }
21 |
22 |
23 | {text}
24 |
25 |
26 | >
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/symbols/Label/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Label';
2 |
--------------------------------------------------------------------------------
/src/symbols/Node/Node.module.css:
--------------------------------------------------------------------------------
1 | .rect {
2 | fill: #2b2c3e;
3 | transition: stroke 100ms ease-in-out;
4 | stroke: #475872;
5 | shape-rendering: geometricPrecision;
6 | stroke-width: 1pt;
7 |
8 | &:not(.selectionDisabled):not(.disabled) {
9 | cursor: pointer;
10 |
11 | &:hover {
12 | stroke: #a5a9e2;
13 | }
14 |
15 | &.dragging {
16 | stroke: #a5a9e2;
17 | }
18 |
19 | &.active {
20 | stroke: #46fecb;
21 | }
22 |
23 | &.unlinkable {
24 | stroke: #ff005d;
25 | }
26 |
27 | &.deleteHovered {
28 | stroke: #ff005d !important;
29 | stroke-dasharray: 4 2;
30 | }
31 | }
32 |
33 | &:focus {
34 | outline: none;
35 | }
36 |
37 | &.children {
38 | fill: transparent;
39 | stroke: #475872;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/symbols/Node/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Node';
2 |
--------------------------------------------------------------------------------
/src/symbols/Port/Port.module.css:
--------------------------------------------------------------------------------
1 | .port {
2 | stroke: #0d0e17;
3 | fill: #3e405a;
4 | stroke-width: 2px;
5 | shape-rendering: geometricPrecision;
6 | pointer-events: none;
7 | }
8 |
9 | .clicker {
10 | opacity: 0;
11 |
12 | &:not(.disabled) {
13 | cursor: crosshair;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/symbols/Port/Port.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef, Fragment, ReactNode, Ref, useState } from 'react';
2 | import { motion } from 'motion/react';
3 | import { PortData } from '../../types';
4 | import { NodeDragEvents, DragEvent, useNodeDrag, Position } from '../../utils/useNodeDrag';
5 | import classNames from 'classnames';
6 | import { useCanvas } from '../../utils/CanvasProvider';
7 | import css from './Port.module.css';
8 |
9 | export interface ElkPortProperties {
10 | index: number;
11 | width: number;
12 | height: number;
13 | 'port.side': string;
14 | 'port.alignment': string;
15 | }
16 |
17 | export interface PortChildProps {
18 | port: PortData;
19 | isDisabled: boolean;
20 | isDragging: boolean;
21 | isHovered: boolean;
22 | x: number;
23 | y: number;
24 | rx: number;
25 | ry: number;
26 | offsetX: number;
27 | offsetY: number;
28 | }
29 |
30 | export type PortChildrenAsFunction = (portChildProps: PortChildProps) => ReactNode;
31 |
32 | export interface PortProps extends NodeDragEvents {
33 | id: string;
34 | x: number;
35 | y: number;
36 | rx: number;
37 | ry: number;
38 | offsetX: number;
39 | offsetY: number;
40 | disabled?: boolean;
41 | className?: string;
42 | properties: ElkPortProperties & PortData;
43 | style?: any;
44 | children?: ReactNode | PortChildrenAsFunction;
45 | active?: boolean;
46 | onEnter?: (event: React.MouseEvent, port: PortData) => void;
47 | onLeave?: (event: React.MouseEvent, port: PortData) => void;
48 | onClick?: (event: React.MouseEvent, port: PortData) => void;
49 | }
50 |
51 | export const Port = forwardRef(({ id, x, y, rx, ry, disabled, style, children, properties, offsetX, offsetY, className, active, onDrag = () => undefined, onDragStart = () => undefined, onDragEnd = () => undefined, onEnter = () => undefined, onLeave = () => undefined, onClick = () => undefined }: Partial, ref: Ref) => {
52 | const { readonly } = useCanvas();
53 | const [isDragging, setIsDragging] = useState(false);
54 | const [isHovered, setIsHovered] = useState(false);
55 | const newX = x - properties.width / 2;
56 | const newY = y - properties.height / 2;
57 |
58 | const onDragStartInternal = (event: DragEvent, initial: Position) => {
59 | onDragStart(event, initial, properties);
60 | setIsDragging(true);
61 | };
62 |
63 | const onDragEndInternal = (event: DragEvent, initial: Position) => {
64 | onDragEnd(event, initial, properties);
65 | setIsDragging(false);
66 | };
67 |
68 | const bind = useNodeDrag({
69 | x: newX + offsetX,
70 | y: newY + offsetY,
71 | height: properties.height,
72 | width: properties.width,
73 | disabled: disabled || readonly || properties?.disabled,
74 | node: properties,
75 | onDrag,
76 | onDragStart: onDragStartInternal,
77 | onDragEnd: onDragEndInternal
78 | });
79 |
80 | if (properties.hidden) {
81 | return null;
82 | }
83 |
84 | const isDisabled = properties.disabled || disabled;
85 |
86 | const portChildProps: PortChildProps = {
87 | port: properties,
88 | isDragging,
89 | isHovered,
90 | isDisabled,
91 | x,
92 | y,
93 | rx,
94 | ry,
95 | offsetX,
96 | offsetY
97 | };
98 |
99 | return (
100 |
101 | {
110 | event.stopPropagation();
111 | if (!isDisabled) {
112 | setIsHovered(true);
113 | onEnter(event, properties);
114 | }
115 | }}
116 | onMouseLeave={(event) => {
117 | event.stopPropagation();
118 | if (!isDisabled) {
119 | setIsHovered(false);
120 | onLeave(event, properties);
121 | }
122 | }}
123 | onClick={(event) => {
124 | event.stopPropagation();
125 | if (!isDisabled) {
126 | onClick(event, properties);
127 | }
128 | }}
129 | />
130 |
151 | {children && {typeof children === 'function' ? (children as PortChildrenAsFunction)(portChildProps) : children} }
152 |
153 | );
154 | });
155 |
--------------------------------------------------------------------------------
/src/symbols/Port/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './Port';
2 |
--------------------------------------------------------------------------------
/src/symbols/Remove/Remove.module.css:
--------------------------------------------------------------------------------
1 | .deleteX {
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: #ff005d;
18 | border-radius: 2px;
19 | pointer-events: none;
20 | }
21 |
--------------------------------------------------------------------------------
/src/symbols/Remove/Remove.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react';
2 | import classNames from 'classnames';
3 | import { motion } from 'motion/react';
4 | import css from './Remove.module.css';
5 |
6 | export interface RemoveProps {
7 | x: number;
8 | y: number;
9 | hidden?: boolean;
10 | size?: number;
11 | className?: string;
12 | onEnter?: (event: React.MouseEvent) => void;
13 | onLeave?: (event: React.MouseEvent) => void;
14 | onClick?: (event: React.MouseEvent) => void;
15 | }
16 |
17 | export const Remove: FC> = ({ size = 15, className, hidden, x, y, onClick = () => undefined, onEnter = () => undefined, onLeave = () => 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 | {
35 | event.preventDefault();
36 | event.stopPropagation();
37 | onClick(event);
38 | }}
39 | />
40 |
41 |
42 |
43 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/src/symbols/Remove/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Remove';
2 |
--------------------------------------------------------------------------------
/src/symbols/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Arrow';
2 | export * from './Edge';
3 | export * from './Label';
4 | export * from './Node';
5 | export * from './Port';
6 | export * from './Icon';
7 | export * from './Remove';
8 | export * from './Add';
9 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { ElkNodeLayoutOptions } from './layout';
2 |
3 | export enum CanvasPosition {
4 | CENTER = 'center',
5 | TOP = 'top',
6 | LEFT = 'left',
7 | RIGHT = 'right',
8 | BOTTOM = 'bottom'
9 | }
10 |
11 | export interface NodeData {
12 | /**
13 | * Unique ID for the node.
14 | */
15 | id: string;
16 |
17 | /**
18 | * Whether the node is disabled or not.
19 | */
20 | disabled?: boolean;
21 |
22 | /**
23 | * Text label for the node.
24 | */
25 | text?: any;
26 |
27 | /**
28 | * Optional height attribute. If not passed with calculate
29 | * default sizes using text.
30 | */
31 | height?: number;
32 |
33 | /**
34 | * Optional width attribute. If not passed with calculate
35 | * default sizes using text.
36 | */
37 | width?: number;
38 |
39 | /**
40 | * Parent node id for nesting.
41 | */
42 | parent?: string;
43 |
44 | /**
45 | * List of ports.
46 | */
47 | ports?: PortData[];
48 |
49 | /**
50 | * Icon for the node.
51 | */
52 | icon?: IconData;
53 |
54 | /**
55 | * Padding for the node.
56 | */
57 | nodePadding?: number | [number, number] | [number, number, number, number];
58 |
59 | /**
60 | * Data for the node.
61 | */
62 | data?: T;
63 |
64 | /**
65 | * CSS classname for the node.
66 | */
67 | className?: string;
68 |
69 | /**
70 | * ELK layout options.
71 | */
72 | layoutOptions?: ElkNodeLayoutOptions;
73 |
74 | /**
75 | * Whether the node can be clicked.
76 | */
77 | selectionDisabled?: boolean;
78 | }
79 |
80 | export interface LayoutNodeData extends NodeData {
81 | x: number;
82 | y: number;
83 | children?: LayoutNodeData[];
84 | }
85 |
86 | export interface IconData {
87 | /**
88 | * URL for the icon.
89 | */
90 | url: string;
91 |
92 | /**
93 | * Height of the icon.
94 | */
95 | height: number;
96 |
97 | /**
98 | * Width of the icon.
99 | */
100 | width: number;
101 | }
102 |
103 | export interface EdgeData {
104 | /**
105 | * Unique ID of the edge.
106 | */
107 | id: string;
108 |
109 | /**
110 | * Whether the edge is disabled or not.
111 | */
112 | disabled?: boolean;
113 |
114 | /**
115 | * Text label for the edge.
116 | */
117 | text?: any;
118 |
119 | /**
120 | * ID of the from node.
121 | */
122 | from?: string;
123 |
124 | /**
125 | * ID of the to node.
126 | */
127 | to?: string;
128 |
129 | /**
130 | * Optional ID of the from port.
131 | */
132 | fromPort?: string;
133 |
134 | /**
135 | * Optional ID of the to port.
136 | */
137 | toPort?: string;
138 |
139 | /**
140 | * Data about the edge.
141 | */
142 | data?: T;
143 |
144 | /**
145 | * CSS class name for the edge ("path" element).
146 | */
147 | className?: string;
148 |
149 | /**
150 | * CSS class name for the edge (main "g" element).
151 | */
152 | containerClassName?: 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 | export type PortSide = 'NORTH' | 'SOUTH' | 'EAST' | 'WEST';
171 |
172 | export interface PortData {
173 | /**
174 | * Unique ID of the port.
175 | */
176 | id: string;
177 |
178 | /**
179 | * Port is disabled.
180 | */
181 | disabled?: boolean;
182 |
183 | /**
184 | * Height of the port.
185 | */
186 | height: number;
187 |
188 | /**
189 | * Width of the port.
190 | */
191 | width: number;
192 |
193 | /**
194 | * Whether the port is visually hidden or not.
195 | */
196 | hidden?: boolean;
197 |
198 | /**
199 | * Classname for the port.
200 | */
201 | className?: string;
202 |
203 | /**
204 | * Alignment of the port.
205 | */
206 | alignment?: 'CENTER';
207 |
208 | /**
209 | * Side the port is located.
210 | */
211 | side: PortSide;
212 | }
213 |
--------------------------------------------------------------------------------
/src/typings.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.json';
2 | declare module '*.css';
3 | declare module '*.scss';
4 | declare module '*.md';
5 |
6 | type BigInt = string;
7 |
--------------------------------------------------------------------------------
/src/utils/CanvasProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext } from 'react';
2 | import { LayoutResult, useLayout } from '../layout/useLayout';
3 | import { NodeData, PortData } from '../types';
4 | import { EdgeDragResult, useEdgeDrag } from './useEdgeDrag';
5 | import { useZoom, ZoomResult } from './useZoom';
6 |
7 | export interface CanvasProviderValue
8 | extends EdgeDragResult,
9 | LayoutResult,
10 | ZoomResult {
11 | selections?: string[];
12 | readonly?: boolean;
13 | pannable: boolean;
14 | panType: 'scroll' | 'drag';
15 | }
16 |
17 | export const CanvasContext = createContext({} as any);
18 |
19 | export interface CanvasProviderProps {
20 | onNodeLink?: (
21 | event: any,
22 | fromNode: NodeData,
23 | toNode: NodeData,
24 | fromPort?: PortData
25 | ) => void;
26 | onNodeLinkCheck?: (
27 | event: any,
28 | fromNode: NodeData,
29 | toNode: NodeData,
30 | fromPort?: PortData
31 | ) => undefined | boolean;
32 | }
33 |
34 | export const CanvasProvider = ({
35 | selections,
36 | onNodeLink,
37 | readonly,
38 | children,
39 | nodes,
40 | edges,
41 | maxHeight,
42 | fit,
43 | maxWidth,
44 | direction,
45 | layoutOptions,
46 | pannable,
47 | panType,
48 | defaultPosition,
49 | zoomable,
50 | zoom,
51 | minZoom,
52 | maxZoom,
53 | onNodeLinkCheck,
54 | onLayoutChange,
55 | onZoomChange
56 | }) => {
57 | const zoomProps = useZoom({
58 | zoom,
59 | minZoom,
60 | maxZoom,
61 | disabled: !zoomable,
62 | onZoomChange
63 | });
64 |
65 | const layoutProps = useLayout({
66 | nodes,
67 | edges,
68 | maxHeight,
69 | maxWidth,
70 | direction,
71 | pannable,
72 | panType,
73 | defaultPosition,
74 | fit,
75 | layoutOptions,
76 | zoom: zoomProps.zoom,
77 | setZoom: zoomProps.setZoom,
78 | onLayoutChange
79 | });
80 |
81 | const dragProps = useEdgeDrag({
82 | onNodeLink,
83 | onNodeLinkCheck
84 | });
85 |
86 | return (
87 |
98 | {children}
99 |
100 | );
101 | };
102 |
103 | export const useCanvas = () => {
104 | const context = useContext(CanvasContext);
105 |
106 | if (context === undefined) {
107 | throw new Error(
108 | '`useCanvas` hook must be used within a `CanvasContext` component'
109 | );
110 | }
111 |
112 | return context;
113 | };
114 |
--------------------------------------------------------------------------------
/src/utils/helpers.test.ts:
--------------------------------------------------------------------------------
1 | import { findNestedNode } from './helpers';
2 |
3 | describe('findNestedNode', () => {
4 | it('should find a node within a list of nodes', () => {
5 | const canvasChildren = [
6 | {
7 | id: '1'
8 | },
9 | {
10 | id: '2'
11 | },
12 | {
13 | id: '3'
14 | }
15 | ];
16 |
17 | const result = findNestedNode('2', canvasChildren);
18 | expect(result).toEqual(canvasChildren[1]);
19 | });
20 |
21 | it('should find a node within a nested node with parentId', () => {
22 | const canvasChildren = [
23 | {
24 | id: '1',
25 | children: [
26 | {
27 | id: '2',
28 | parent: '1'
29 | },
30 | {
31 | id: '3',
32 | parent: '1'
33 | }
34 | ]
35 | }
36 | ];
37 |
38 | const result = findNestedNode('2', canvasChildren, '1');
39 | expect(result).toEqual({
40 | id: '2',
41 | parent: '1'
42 | });
43 | });
44 |
45 | it('should find a node within a nested node without parentId', () => {
46 | const canvasChildren = [
47 | {
48 | id: '1',
49 | children: [
50 | {
51 | id: '2',
52 | parent: '1'
53 | },
54 | {
55 | id: '3',
56 | parent: '1'
57 | }
58 | ]
59 | }
60 | ];
61 |
62 | const result = findNestedNode('2', canvasChildren);
63 | expect(result).toEqual({
64 | id: '2',
65 | parent: '1'
66 | });
67 | });
68 |
69 | it('should find a node within a nested nested node', () => {
70 | const canvasChildren = [
71 | {
72 | id: '1',
73 | children: [
74 | {
75 | id: '2',
76 | parent: '1',
77 | children: [
78 | {
79 | id: '3',
80 | parent: '2'
81 | }
82 | ]
83 | }
84 | ]
85 | }
86 | ];
87 |
88 | const result = findNestedNode('3', canvasChildren);
89 | expect(result).toEqual({
90 | id: '3',
91 | parent: '2'
92 | });
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/src/utils/helpers.ts:
--------------------------------------------------------------------------------
1 | import { RefObject } from 'react';
2 | import { NodeData } from '../types';
3 | import { Matrix2D } from 'kld-affine';
4 |
5 | /**
6 | * Checks if the node can be linked or not.
7 | */
8 | export function checkNodeLinkable(
9 | curNode: NodeData,
10 | enteredNode: NodeData | null,
11 | canLinkNode: boolean | null
12 | ) {
13 | if (canLinkNode === null || !enteredNode) {
14 | return null;
15 | }
16 |
17 | if (!enteredNode || !curNode) {
18 | return false;
19 | }
20 |
21 | // TODO: Revisit how to do self-linking better...
22 | return !(canLinkNode === false && enteredNode.id === curNode.id);
23 | }
24 |
25 | export interface CoordProps {
26 | zoom: number;
27 | layoutXY: [number, number];
28 | containerRef: RefObject;
29 | }
30 |
31 | /**
32 | * Given various dimensions and positions, create a matrix
33 | * used for determining position.
34 | */
35 | export function getCoords({ zoom, layoutXY, containerRef }: CoordProps) {
36 | const { top, left } = containerRef.current.getBoundingClientRect();
37 | const tx = layoutXY[0] - containerRef.current.scrollLeft + left;
38 | const ty = layoutXY[1] - containerRef.current.scrollTop + top;
39 |
40 | return new Matrix2D().translate(tx, ty).scale(zoom).inverse();
41 | }
42 |
43 | /**
44 | * Given a nodeId to find, a list of nodes to check against, and an optional parentId of the node
45 | * find the node from the list of nodes
46 | */
47 | export function findNestedNode(
48 | nodeId: string,
49 | children: any[],
50 | parentId?: string
51 | ): { [key: string]: any } {
52 | if (!nodeId || !children) {
53 | return {};
54 | }
55 |
56 | const foundNode = children.find((n) => n.id === nodeId);
57 | if (foundNode) {
58 | return foundNode;
59 | }
60 |
61 | if (parentId) {
62 | const parentNode = children.find((n) => n.id === parentId);
63 | if (parentNode?.children) {
64 | return findNestedNode(nodeId, parentNode.children, parentId);
65 | }
66 | }
67 |
68 | // Check for nested children
69 | const nodesWithChildren = children.filter((n) => n.children?.length);
70 | // Iterate over all nested nodes and check if any of them contain the node
71 | for (const n of nodesWithChildren) {
72 | const foundChild = findNestedNode(nodeId, n.children, parentId);
73 |
74 | if (foundChild && Object.keys(foundChild).length) {
75 | return foundChild;
76 | }
77 | }
78 |
79 | return {};
80 | }
81 |
82 | /**
83 | * Return the layout node that is currently being dragged on the Canvas
84 | */
85 | export function getDragNodeData(
86 | dragNode: NodeData,
87 | children: any[] = []
88 | ): { [key: string]: any } {
89 | if (!dragNode) {
90 | return {};
91 | }
92 |
93 | const { parent } = dragNode;
94 | if (!parent) {
95 | return children?.find((n) => n.id === dragNode.id) || {};
96 | }
97 |
98 | return findNestedNode(dragNode.id, children, parent);
99 | }
100 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useNodeDrag';
2 | export * from './CanvasProvider';
3 | export * from './helpers';
4 | export * from './useZoom';
5 | export * from './useEdgeDrag';
6 |
--------------------------------------------------------------------------------
/src/utils/useEdgeDrag.ts:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from 'react';
2 | import { EdgeSections } from '../symbols/Edge';
3 | import { NodeData, PortData } from '../types';
4 | import { DragEvent, NodeDragEvents, Position } from './useNodeDrag';
5 | import { Point2D } from 'kld-affine';
6 | import { NodeDragType } from '../symbols/Node';
7 |
8 | export interface EdgeDragResult extends NodeDragEvents {
9 | dragCoords: EdgeSections[] | null;
10 | canLinkNode: boolean | null;
11 | dragNode: NodeData | null;
12 | dragPort: PortData | null;
13 | enteredNode: NodeData | null;
14 | onEnter?: (
15 | event: React.MouseEvent,
16 | data: NodeData | PortData
17 | ) => void;
18 | onLeave?: (
19 | event: React.MouseEvent,
20 | data: NodeData | PortData
21 | ) => void;
22 | }
23 |
24 | export const useEdgeDrag = ({
25 | onNodeLink,
26 | onNodeLinkCheck
27 | }): EdgeDragResult => {
28 | const [dragNode, setDragNode] = useState(null);
29 | const [dragPort, setDragPort] = useState(null);
30 | const [dragType, setDragType] = useState(null);
31 | const [enteredNode, setEnteredNode] = useState(null);
32 | const [dragCoords, setDragCoords] = useState(null);
33 | const [canLinkNode, setCanLinkNode] = useState(null);
34 |
35 | const onDragStart = useCallback(
36 | (state: DragEvent, _initial: Position, node: NodeData, port?: PortData) => {
37 | setDragType(state.dragType);
38 | setDragNode(node);
39 | setDragPort(port);
40 | },
41 | []
42 | );
43 |
44 | const onDrag = useCallback(
45 | ({ memo: [matrix], xy: [x, y] }: DragEvent, [ix, iy]: Position) => {
46 | const endPoint = new Point2D(x, y).transform(matrix);
47 | setDragCoords([
48 | {
49 | startPoint: {
50 | x: ix,
51 | y: iy
52 | },
53 | endPoint
54 | }
55 | ]);
56 | },
57 | []
58 | );
59 |
60 | const onDragEnd = useCallback(
61 | (event: DragEvent) => {
62 | if (dragNode && enteredNode && canLinkNode) {
63 | onNodeLink(event, dragNode, enteredNode, dragPort);
64 | }
65 |
66 | setDragNode(null);
67 | setDragPort(null);
68 | setEnteredNode(null);
69 | setDragCoords(null);
70 | },
71 | [canLinkNode, dragNode, dragPort, enteredNode, onNodeLink]
72 | );
73 |
74 | const onEnter = useCallback(
75 | (event: React.MouseEvent, node: NodeData) => {
76 | if (dragNode && node) {
77 | setEnteredNode(node);
78 | const canLink = onNodeLinkCheck(event, dragNode, node, dragPort);
79 | const result =
80 | (canLink === undefined || canLink) &&
81 | (dragNode.parent === node.parent || dragType === 'node');
82 |
83 | setCanLinkNode(result);
84 | }
85 | },
86 | [dragNode, dragPort, dragType, onNodeLinkCheck]
87 | );
88 |
89 | const onLeave = useCallback(
90 | (event: React.MouseEvent, node: NodeData) => {
91 | if (dragNode && node) {
92 | setEnteredNode(null);
93 | setCanLinkNode(null);
94 | }
95 | },
96 | [dragNode]
97 | );
98 |
99 | return {
100 | dragCoords,
101 | canLinkNode,
102 | dragNode,
103 | dragPort,
104 | enteredNode,
105 | onDragStart,
106 | onDrag,
107 | onDragEnd,
108 | onEnter,
109 | onLeave
110 | };
111 | };
112 |
--------------------------------------------------------------------------------
/src/utils/useNodeDrag.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 | import { useDrag } from 'react-use-gesture';
3 | import { State } from 'react-use-gesture/dist/types';
4 | import { NodeDragType } from 'symbols';
5 | import { NodeData } from '../types';
6 | import { useCanvas } from './CanvasProvider';
7 | import { getCoords } from './helpers';
8 |
9 | export type DragEvent = State['drag'] & { dragType?: NodeDragType };
10 | export type Position = [number, number];
11 |
12 | export interface NodeDragEvents {
13 | onDrag?: (event: DragEvent, initial: Position, data: T, extra?: TT) => void;
14 | onDragEnd?: (
15 | event: DragEvent,
16 | initial: Position,
17 | data: T,
18 | extra?: TT
19 | ) => void;
20 | onDragStart?: (
21 | event: DragEvent,
22 | initial: Position,
23 | data: T,
24 | extra?: TT
25 | ) => void;
26 | }
27 |
28 | export interface NodeDragProps extends NodeDragEvents {
29 | node: NodeData;
30 | height: number;
31 | width: number;
32 | x: number;
33 | y: number;
34 | disabled: boolean;
35 | }
36 |
37 | export const useNodeDrag = ({
38 | x,
39 | y,
40 | height,
41 | width,
42 | onDrag,
43 | onDragEnd,
44 | onDragStart,
45 | node,
46 | disabled
47 | }: NodeDragProps) => {
48 | const initial: Position = [width / 2 + x, height + y];
49 | const targetRef = useRef(null);
50 | const { zoom, xy, containerRef } = useCanvas();
51 |
52 | const bind = useDrag(
53 | (state) => {
54 | if (state.event.type === 'pointerdown') {
55 | targetRef.current = state.event.currentTarget;
56 | }
57 |
58 | if (!state.intentional || !targetRef.current) {
59 | return;
60 | }
61 |
62 | if (state.first) {
63 | const matrix = getCoords({
64 | containerRef,
65 | zoom,
66 | layoutXY: xy
67 | });
68 |
69 | // memo will hold the difference between the
70 | // first point of impact and the origin
71 | const memo = [matrix];
72 |
73 | onDragStart({ ...state, memo }, initial, node);
74 |
75 | return memo;
76 | }
77 |
78 | onDrag(state, initial, node);
79 |
80 | if (state.last) {
81 | targetRef.current = null;
82 | onDragEnd(state, initial, node);
83 | }
84 | },
85 | {
86 | enabled: !disabled,
87 | triggerAllEvents: true,
88 | threshold: 5
89 | }
90 | );
91 |
92 | return bind;
93 | };
94 |
--------------------------------------------------------------------------------
/src/utils/useZoom.ts:
--------------------------------------------------------------------------------
1 | import { RefObject, useCallback, useRef, useState } from 'react';
2 | import { useGesture } from 'react-use-gesture';
3 |
4 | const limit = (scale: number, min: number, max: number) => (scale < max ? (scale > min ? scale : min) : max);
5 |
6 | export interface ZoomProps {
7 | disabled?: boolean;
8 | zoom?: number;
9 | minZoom?: number;
10 | maxZoom?: number;
11 | onZoomChange: (zoom: number) => void;
12 | }
13 |
14 | export interface ZoomResult {
15 | /**
16 | * Factor of zoom.
17 | */
18 | zoom: number;
19 |
20 | /**
21 | * SVG Ref for the Canvas.
22 | */
23 | svgRef: RefObject;
24 |
25 | /**
26 | * Set a zoom factor of the canvas.
27 | */
28 | setZoom?: (factor: number) => void;
29 |
30 | /**
31 | * Zoom in on the canvas.
32 | */
33 | zoomIn?: (zoomFactor?: number) => void;
34 |
35 | /**
36 | * Zoom out on the canvas.
37 | */
38 | zoomOut?: (zoomFactor?: number) => void;
39 | }
40 |
41 | export const useZoom = ({ disabled = false, zoom = 1, minZoom = -0.5, maxZoom = 1, onZoomChange }: ZoomProps) => {
42 | const [factor, setFactor] = useState(zoom - 1);
43 | const svgRef = useRef(null);
44 |
45 | useGesture(
46 | {
47 | onPinch: ({ offset: [d], event }) => {
48 | event.preventDefault();
49 | // TODO: Set X/Y on center of zoom
50 | const next = limit(d / 100, minZoom, maxZoom);
51 | setFactor(next);
52 | onZoomChange(next + 1);
53 | }
54 | },
55 | {
56 | enabled: !disabled,
57 | domTarget: svgRef,
58 | eventOptions: { passive: false }
59 | }
60 | );
61 |
62 | const setZoom = useCallback(
63 | (f: number) => {
64 | const next = limit(f, minZoom, maxZoom);
65 | setFactor(next);
66 | onZoomChange(next + 1);
67 | },
68 | [maxZoom, minZoom, onZoomChange]
69 | );
70 |
71 | const zoomIn = useCallback(
72 | (zoomFactor: number = 0.1) => {
73 | setZoom(factor + zoomFactor);
74 | },
75 | [factor, setZoom]
76 | );
77 |
78 | const zoomOut = useCallback(
79 | (zoomFactor: number = -0.1) => {
80 | setZoom(factor + zoomFactor);
81 | },
82 | [factor, setZoom]
83 | );
84 |
85 | return {
86 | svgRef,
87 | zoom: factor + 1,
88 | setZoom,
89 | zoomIn,
90 | zoomOut
91 | } as ZoomResult;
92 | };
93 |
--------------------------------------------------------------------------------
/stories/Controls.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState } from 'react';
2 | import { Canvas, CanvasRef } from '../src/Canvas';
3 | import { Node, Edge, MarkerArrow, Port, Icon, Arrow, Label, Remove, Add } from '../src/symbols';
4 | import { CanvasPosition } from '../src/types';
5 |
6 | export default {
7 | title: 'Demos/Controls',
8 | component: Canvas,
9 | subcomponents: {
10 | Node,
11 | Edge,
12 | MarkerArrow,
13 | Arrow,
14 | Icon,
15 | Label,
16 | Port,
17 | Remove,
18 | Add
19 | }
20 | };
21 |
22 | export const FixedPosition = () => (
23 |
24 | console.log('Layout', layout)}
53 | />
54 |
55 | );
56 |
57 |
58 | export const Small = () => (
59 |
60 | console.log('Layout', layout)}
103 | />
104 |
105 | );
106 |
107 | export const NonCentered = () => (
108 |
109 | console.log('Layout', layout)}
139 | />
140 |
141 | );
142 |
143 | export const TopPosition = () => (
144 |
145 | console.log('Layout', layout)}
175 | />
176 |
177 | );
178 |
179 |
180 | export const Fit = () => (
181 |
182 | console.log('Layout', layout)}
211 | />
212 |
213 | );
214 |
215 | export const Zoom = () => {
216 | const [zoom, setZoom] = useState(0.7);
217 | const ref = useRef(null);
218 |
219 | return (
220 |
221 |
222 | Zoom: {zoom}
223 | ref.current.zoomIn()}>Zoom In
224 | ref.current.zoomOut()}>Zoom Out
225 | ref.current.fitCanvas(true)}>Fit
226 | ref.current?.fitNodes('1')}>Fit to Node 1
227 | ref.current?.fitNodes('2')}>Fit to Node 2
228 |
229 |
{
263 | console.log('zooming', z);
264 | setZoom(z);
265 | }}
266 | onLayoutChange={layout => console.log('Layout', layout)}
267 | />
268 |
269 | );
270 | };
271 |
272 | export const DragPan = () => (
273 |
274 | console.log('Layout', layout)}
304 | />
305 |
306 | );
307 |
--------------------------------------------------------------------------------
/stories/Editor.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Canvas } from '../src/Canvas';
3 | import { Node, Edge, MarkerArrow, Port, Icon, Arrow, Label, Remove, Add } from '../src/symbols';
4 | import { motion, useDragControls } from 'motion/react';
5 | import { Portal } from 'reablocks';
6 | import { EdgeData, NodeData } from '../src/types';
7 | import { addNodeAndEdge } from '../src/helpers';
8 |
9 | export default {
10 | title: 'Demos/Editor',
11 | component: Canvas,
12 | subcomponents: {
13 | Node,
14 | Edge,
15 | MarkerArrow,
16 | Arrow,
17 | Icon,
18 | Label,
19 | Port,
20 | Remove,
21 | Add
22 | }
23 | };
24 |
25 | export const Simple = () => {
26 | const dragControls = useDragControls();
27 | const [enteredNode, setEnteredNode] = useState(null);
28 | const [activeDrag, setActiveDrag] = useState(null);
29 | const [droppable, setDroppable] = useState(false);
30 | const [edges, setEdges] = useState([
31 | {
32 | id: '1-2',
33 | from: '1',
34 | to: '2'
35 | }
36 | ]);
37 | const [nodes, setNodes] = useState([
38 | {
39 | id: '1',
40 | text: '1'
41 | },
42 | {
43 | id: '2',
44 | text: '2'
45 | }
46 | ]);
47 |
48 | const onDragStart = (event, data) => {
49 | console.log('Start of Dragging', event, data);
50 | setActiveDrag(data);
51 | dragControls.start(event, { snapToCursor: true });
52 | };
53 |
54 | const onDragEnd = (event) => {
55 | console.log('End of Dragging', event);
56 |
57 | if (droppable) {
58 | const id = `${activeDrag}-${Math.floor(Math.random() * (100 - 1 + 1)) + 1}`;
59 | const result = addNodeAndEdge(
60 | nodes,
61 | edges,
62 | {
63 | id,
64 | text: id
65 | },
66 | enteredNode
67 | );
68 | setNodes(result.nodes);
69 | setEdges(result.edges);
70 | }
71 |
72 | setDroppable(false);
73 | setActiveDrag(null);
74 | setEnteredNode(null);
75 | };
76 |
77 | return (
78 |
79 |
140 |
141 | onDragStart(event, '1')}>
142 | Block 1
143 |
144 | onDragStart(event, '2')}>
145 | Block 2
146 |
147 |
148 |
149 | setEnteredNode(node)}
155 | onLeave={(event, node) => setEnteredNode(null)}
156 | />
157 | }
158 | onMouseEnter={() => setDroppable(true)}
159 | onMouseLeave={() => setDroppable(false)}
160 | onLayoutChange={layout => console.log('Layout', layout)}
161 | />
162 |
163 |
164 |
170 | {activeDrag && (
171 |
172 | {activeDrag}
173 |
174 | )}
175 |
176 |
177 |
178 | );
179 | };
180 |
--------------------------------------------------------------------------------
/stories/Layouts.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Canvas } from '../src/Canvas';
3 | import { Node, Edge, MarkerArrow, Port, Icon, Arrow, Label, Remove, Add } from '../src/symbols';
4 |
5 | export default {
6 | title: 'Demos/Layouts',
7 | component: Canvas,
8 | subcomponents: {
9 | Node,
10 | Edge,
11 | MarkerArrow,
12 | Arrow,
13 | Icon,
14 | Label,
15 | Port,
16 | Remove,
17 | Add
18 | }
19 | };
20 |
21 | export const Direction = () => (
22 |
23 | console.log('Layout', layout)}
44 | />
45 |
46 | );
47 |
48 | export const Circular = () => (
49 |
50 | console.log('Layout', layout)}
79 | />
80 |
81 | );
82 |
83 | export const Joins = () => (
84 |
85 | console.log('Layout', layout)}
127 | />
128 |
129 | );
130 |
131 | export const CustomOptions = () => (
132 |
133 | console.log('Layout', layout)}
191 | />
192 |
193 | );
194 |
--------------------------------------------------------------------------------
/stories/Undo.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Canvas } from '../src/Canvas';
3 | import {
4 | Node,
5 | Edge,
6 | MarkerArrow,
7 | Port,
8 | Icon,
9 | Arrow,
10 | Label,
11 | Remove,
12 | Add
13 | } from '../src/symbols';
14 | import { UndoRedoEvent, useUndo } from '../src/helpers';
15 |
16 | export default {
17 | title: 'Demos/Undo Redo',
18 | component: Canvas,
19 | subcomponents: {
20 | Node,
21 | Edge,
22 | MarkerArrow,
23 | Arrow,
24 | Icon,
25 | Label,
26 | Port,
27 | Remove,
28 | Add
29 | }
30 | };
31 |
32 | export const Simple = () => {
33 | const [nodes, setNodes] = useState([
34 | {
35 | id: '1',
36 | text: 'Node 1'
37 | },
38 | {
39 | id: '2',
40 | text: 'Node 2'
41 | },
42 | {
43 | id: '3',
44 | text: 'Node 3'
45 | }
46 | ]);
47 |
48 | const [edges, setEdges] = useState([
49 | {
50 | id: '1-2',
51 | from: '1',
52 | to: '2'
53 | },
54 | {
55 | id: '1-3',
56 | from: '1',
57 | to: '3'
58 | }
59 | ]);
60 |
61 | const { undo, redo, canUndo, canRedo, history, clear, count } = useUndo({
62 | nodes,
63 | edges,
64 | onUndoRedo: (state: UndoRedoEvent) => {
65 | console.log('Undo / Redo', state);
66 | if (state.type !== 'clear') {
67 | setEdges(state.edges);
68 | setNodes(state.nodes);
69 | }
70 | }
71 | });
72 |
73 | const addNode = () => {
74 | setNodes([
75 | ...nodes,
76 | {
77 | id: `a${Math.random()}`,
78 | text: `Node ${Math.random()}`
79 | }
80 | ]);
81 | };
82 |
83 | return (
84 |
85 |
89 | Add Nodes
90 |
91 |
96 | Undo
97 |
98 |
103 | Redo
104 |
105 | console.log(history())}
108 | >
109 | Print history
110 |
111 | console.log(count())}
114 | disabled={!count()}
115 | >
116 | Print count
117 |
118 | clear(nodes, edges)}
121 | >
122 | Clear history
123 |
124 | console.log('Layout', layout)}
128 | />
129 |
130 | );
131 | };
132 |
--------------------------------------------------------------------------------
/test/mockEnv.ts:
--------------------------------------------------------------------------------
1 | import { ResizeObserverEntry, ResizeObserver } from '@juggle/resize-observer';
2 |
3 | if (!('ResizeObserver' in window)) {
4 | // @ts-ignore
5 | window.ResizeObserver = ResizeObserver;
6 | // @ts-ignore
7 | window.ResizeObserverEntry = ResizeObserverEntry;
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
4 | "module": "ESNext",
5 | "types": [
6 | "vite/client",
7 | "vitest/globals"
8 | ],
9 | "lib": ["dom", "dom.iterable", "esnext"],
10 | "jsx": "react-jsx",
11 | "moduleResolution": "node",
12 | "baseUrl": "./src",
13 | "allowSyntheticDefaultImports": true,
14 | "declaration": true,
15 | "declarationDir": "./dist",
16 | "esModuleInterop": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "noFallthroughCasesInSwitch": true,
19 | "resolveJsonModule": true,
20 | "rootDirs": ["src", "docs"],
21 | "noImplicitAny": false,
22 | "noImplicitThis": false,
23 | "noUnusedLocals": false,
24 | "noUnusedParameters": false,
25 | "pretty": true,
26 | "outDir": "./dist",
27 | "skipLibCheck": true,
28 | "sourceMap": true,
29 | "suppressImplicitAnyIndexErrors": true,
30 | "suppressExcessPropertyErrors": true,
31 | "experimentalDecorators": true,
32 | "emitDecoratorMetadata": true,
33 | "ignoreDeprecations": "5.0"
34 | },
35 | "types": ["node"],
36 | "include": ["src/**/*"],
37 | "exclude": [
38 | "node_modules",
39 | "dist",
40 | "storybook-static"
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { defineConfig } from 'vite';
4 | import react from '@vitejs/plugin-react';
5 | import svgrPlugin from 'vite-plugin-svgr';
6 | import tsconfigPaths from 'vite-tsconfig-paths';
7 | import checker from 'vite-plugin-checker';
8 | import { resolve } from 'path';
9 | import external from 'rollup-plugin-peer-deps-external';
10 | import dts from 'vite-plugin-dts';
11 | import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
12 |
13 | export default defineConfig(({ mode }) =>
14 | mode === 'library'
15 | ? {
16 | plugins: [
17 | svgrPlugin(),
18 | tsconfigPaths(),
19 | cssInjectedByJsPlugin(),
20 | react(),
21 | dts({
22 | insertTypesEntry: true,
23 | include: ['src']
24 | }),
25 | checker({
26 | typescript: true
27 | })
28 | ],
29 | test: {
30 | globals: true,
31 | environment: 'jsdom'
32 | },
33 | build: {
34 | minify: false,
35 | sourcemap: true,
36 | copyPublicDir: false,
37 | lib: {
38 | entry: resolve('src', 'index.ts'),
39 | name: 'reaflow',
40 | fileName: 'index'
41 | },
42 | rollupOptions: {
43 | plugins: [
44 | external({
45 | includeDependencies: true
46 | })
47 | ],
48 | }
49 | }
50 | }
51 | : {
52 | plugins: [
53 | svgrPlugin(),
54 | tsconfigPaths(),
55 | react(),
56 | checker({
57 | typescript: true
58 | })
59 | ],
60 | test: {
61 | globals: true,
62 | environment: 'jsdom'
63 | }
64 | }
65 | );
66 |
--------------------------------------------------------------------------------