├── .gitignore ├── .tokeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── Edge.tsx ├── Input.tsx ├── Node.tsx ├── Output.tsx ├── actions.ts ├── context.ts ├── editor │ ├── EditableCanvas.tsx │ ├── Editor.tsx │ └── Toolbox.tsx ├── index.ts ├── inputDrag.ts ├── nodeDrag.ts ├── options.ts ├── outputDrag.ts ├── reduce.ts ├── state.ts ├── styles.ts ├── target.ts ├── utils.ts └── viewer │ ├── ReadonlyCanvas.tsx │ └── Viewer.tsx ├── static └── example-action-delete-edge.gif └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | lib 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | -------------------------------------------------------------------------------- /.tokeignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | CHANGELOG.md 3 | README.md 4 | LICENSE 5 | package.json 6 | package-lock.json 7 | tsconfig.json -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.1.6] 10 | 11 | ### Added 12 | 13 | - Added this changlog 14 | - Add `makeReducer` method 15 | 16 | [unreleased]: https://github.com/joeltg/react-dataflow-editor/compare/v0.1.6...HEAD 17 | [0.1.6]: https://github.com/joeltg/react-dataflow-editor/releases/tag/v0.1.6 18 | [0.1.5]: https://github.com/joeltg/react-dataflow-editor/releases/tag/v0.1.5 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joel Gustafson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-dataflow-editor 2 | 3 | [![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg)](https://github.com/RichardLitt/standard-readme) [![license](https://img.shields.io/github/license/joeltg/react-dataflow-editor)](https://opensource.org/licenses/MIT) [![NPM version](https://img.shields.io/npm/v/react-dataflow-editor)](https://www.npmjs.com/package/react-dataflow-editor) ![TypeScript types](https://img.shields.io/npm/types/react-dataflow-editor) ![lines of code](https://img.shields.io/tokei/lines/github/joeltg/react-dataflow-editor) 4 | 5 | A generic drag-and-drop dataflow editor for React. 6 | 7 | > ✨ You can read about the design of this component in [this blog post](https://research.protocol.ai/blog/2021/designing-a-dataflow-editor-with-typescript-and-react/)! 8 | 9 | ## Table of Contents 10 | 11 | - [Install](#install) 12 | - [Usage](#usage) 13 | - [Schema](#schema) 14 | - [Editor](#editor) 15 | - [API](#api) 16 | - [Demo](#demo) 17 | - [Contributing](#contributing) 18 | 19 | ## Install 20 | 21 | ``` 22 | npm i react-dataflow-editor 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### Schema 28 | 29 | To use the editor, you must first define a static _schema_ listing the kinds of nodes you want to use. Here's an example schema: 30 | 31 | ```typescript 32 | const kinds = { 33 | add: { 34 | name: "Addition", 35 | inputs: { a: null, b: null }, 36 | outputs: { sum: null }, 37 | backgroundColor: "lavender", 38 | }, 39 | div: { 40 | name: "Division", 41 | inputs: { dividend: null, divisor: null }, 42 | outputs: { quotient: null, remainder: null }, 43 | backgroundColor: "darksalmon", 44 | }, 45 | } 46 | ``` 47 | 48 | This schema declares two kinds of nodes - an `add` node with two inputs `a` and `b` and one output `sum`, and a `div` node with two inputs `dividend` and `divisor` and two outputs `quotient` and `remainder`. 49 | 50 | A schema is an object assignable to the type `Record; outputs: Record; backgroundColor: string }>`. However the schema **must be defined without type annotations** (and with TypeScript's strict mode enabled); this is because the editor is designed to leverage TypeScript's default type assignment rules to derive a more specific concrete typing of each schema that it will use to paramerize the editor component. 51 | 52 | ### Editor 53 | 54 | Once you've defined your schema (with no type annotations), you can pass it into the `Editor` component, along with a state value and a dispatch method. `Editor` is a controlled component - meaning the editor always renders the value of the current state prop, no matter what - but it doesn't use an `onChange: (newState) => void` callback like most controlled React components. Instead, it uses a `dispatch` callback that gets invoked with individual actions when the user tries to create/delete/move nodes or edges. 55 | 56 | If all you want is to get the new state value on every change, you should use the exported `makeReducer` method in conjunction with React's `useReducer` hook, like in this example: 57 | 58 | ```typescript 59 | import React, { useReducer } from "react" 60 | import { 61 | Editor, 62 | EditorState, 63 | GetSchema, 64 | EditorAction, 65 | makeReducer, 66 | } from "react-dataflow-editor" 67 | 68 | // Derive a concrete type-level schema from the kinds catalog 69 | type S = GetSchema 70 | 71 | interface MyEditorProps { 72 | initialValue?: EditorState 73 | } 74 | 75 | const defaultInitialValue: EditorState = { 76 | nodes: {}, 77 | edges: {}, 78 | focus: null, 79 | } 80 | 81 | function MyEditor(props: MyEditorProps) { 82 | const reducer = makeReducer(kinds) 83 | const [state, dispatch] = useReducer( 84 | reducer, 85 | props.initialValue || defaultInitialValue 86 | ) 87 | 88 | return kinds={kinds} state={state} dispatch={dispatch} /> 89 | } 90 | ``` 91 | 92 | If you want to take more fine-grained control over the editor actions - for example, if you wanted to prevent the user from deleting certain nodes - you can write you own `dispatch` method with your own logic inside it. In that case, you'll probably want to make use of the exported `reduce` method instead. 93 | 94 | The `kinds`, `dispatch`, and `options` props provided to the `Editor` component **must not change** - the editor will not update to reflect new values of these props. Only their initial values will be used. 95 | 96 | ### Viewer 97 | 98 | If you want to render a read-only version of the editor, use the separate `Viewer` component. The viewer component takes all the same props as the editor component, including a `dispatch` callback, but the only actions that it will get invoked with are `Focus` actions. If you want users to be able to and deselect nodes and edges (and if you want to use the currently-selected node or edge in your application), you still need to use the `useReducer` hook the same way. 99 | 100 | ## Demo 101 | 102 | - [Editable demo](https://joeltg.github.io/react-dataflow-editor/demo/editable.html) ([source](https://github.com/joeltg/react-dataflow-editor/blob/gh-pages/demo/editable.tsx)) 103 | - [Read-only demo](https://joeltg.github.io/react-dataflow-editor/demo/readonly.html) ([source](https://github.com/joeltg/react-dataflow-editor/blob/gh-pages/demo/readonly.tsx)) 104 | 105 | ![](./static/example-action-delete-edge.gif) 106 | 107 | ## Contributing 108 | 109 | PRs accepted! 110 | 111 | I'm very interested in improving the real-world usability of the library. In particular I don't really know how to expose control over styling and layout in a useful way, so if you're trying to use this component I'd love to hear what kind of interface you'd like to have. Please open an issue to discuss this! 112 | 113 | ## License 114 | 115 | MIT © 2021 Joel Gustafson 116 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dataflow-editor", 3 | "version": "0.1.6", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "react-dataflow-editor", 9 | "version": "0.1.6", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@types/d3-drag": "^3.0.1", 13 | "@types/d3-quadtree": "^3.0.2", 14 | "@types/d3-selection": "^3.0.1", 15 | "@types/hoist-non-react-statics": "^3.3.1", 16 | "@types/react": "^17.0.11", 17 | "d3-drag": "^3.0.0", 18 | "d3-quadtree": "^3.0.1", 19 | "d3-selection": "^3.0.0", 20 | "nanoid": "^3.1.23", 21 | "react-dnd": "^14.0.2", 22 | "react-dnd-html5-backend": "^14.0.0" 23 | }, 24 | "devDependencies": { 25 | "react": "^17.0.2", 26 | "typescript": "^4.3.2" 27 | }, 28 | "peerDependencies": { 29 | "react": "^17.0.1" 30 | } 31 | }, 32 | "node_modules/@babel/runtime": { 33 | "version": "7.15.4", 34 | "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz", 35 | "integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==", 36 | "dependencies": { 37 | "regenerator-runtime": "^0.13.4" 38 | }, 39 | "engines": { 40 | "node": ">=6.9.0" 41 | } 42 | }, 43 | "node_modules/@react-dnd/asap": { 44 | "version": "4.0.0", 45 | "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", 46 | "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==" 47 | }, 48 | "node_modules/@react-dnd/invariant": { 49 | "version": "2.0.0", 50 | "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", 51 | "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==" 52 | }, 53 | "node_modules/@react-dnd/shallowequal": { 54 | "version": "2.0.0", 55 | "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", 56 | "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" 57 | }, 58 | "node_modules/@types/d3-drag": { 59 | "version": "3.0.1", 60 | "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.1.tgz", 61 | "integrity": "sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==", 62 | "dependencies": { 63 | "@types/d3-selection": "*" 64 | } 65 | }, 66 | "node_modules/@types/d3-quadtree": { 67 | "version": "3.0.2", 68 | "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz", 69 | "integrity": "sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==" 70 | }, 71 | "node_modules/@types/d3-selection": { 72 | "version": "3.0.1", 73 | "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.1.tgz", 74 | "integrity": "sha512-aJ1d1SCUtERHH65bB8NNoLpUOI3z8kVcfg2BGm4rMMUwuZF4x6qnIEKjT60Vt0o7gP/a/xkRVs4D9CpDifbyRA==" 75 | }, 76 | "node_modules/@types/hoist-non-react-statics": { 77 | "version": "3.3.1", 78 | "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", 79 | "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", 80 | "dependencies": { 81 | "@types/react": "*", 82 | "hoist-non-react-statics": "^3.3.0" 83 | } 84 | }, 85 | "node_modules/@types/prop-types": { 86 | "version": "15.7.4", 87 | "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", 88 | "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" 89 | }, 90 | "node_modules/@types/react": { 91 | "version": "17.0.27", 92 | "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.27.tgz", 93 | "integrity": "sha512-zgiJwtsggVGtr53MndV7jfiUESTqrbxOcBvwfe6KS/9bzaVPCTDieTWnFNecVNx6EAaapg5xsLLWFfHHR437AA==", 94 | "dependencies": { 95 | "@types/prop-types": "*", 96 | "@types/scheduler": "*", 97 | "csstype": "^3.0.2" 98 | } 99 | }, 100 | "node_modules/@types/scheduler": { 101 | "version": "0.16.2", 102 | "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", 103 | "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" 104 | }, 105 | "node_modules/csstype": { 106 | "version": "3.0.9", 107 | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", 108 | "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==" 109 | }, 110 | "node_modules/d3-dispatch": { 111 | "version": "3.0.1", 112 | "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", 113 | "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", 114 | "engines": { 115 | "node": ">=12" 116 | } 117 | }, 118 | "node_modules/d3-drag": { 119 | "version": "3.0.0", 120 | "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", 121 | "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", 122 | "dependencies": { 123 | "d3-dispatch": "1 - 3", 124 | "d3-selection": "3" 125 | }, 126 | "engines": { 127 | "node": ">=12" 128 | } 129 | }, 130 | "node_modules/d3-quadtree": { 131 | "version": "3.0.1", 132 | "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", 133 | "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", 134 | "engines": { 135 | "node": ">=12" 136 | } 137 | }, 138 | "node_modules/d3-selection": { 139 | "version": "3.0.0", 140 | "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", 141 | "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", 142 | "engines": { 143 | "node": ">=12" 144 | } 145 | }, 146 | "node_modules/dnd-core": { 147 | "version": "14.0.1", 148 | "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", 149 | "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", 150 | "dependencies": { 151 | "@react-dnd/asap": "^4.0.0", 152 | "@react-dnd/invariant": "^2.0.0", 153 | "redux": "^4.1.1" 154 | } 155 | }, 156 | "node_modules/fast-deep-equal": { 157 | "version": "3.1.3", 158 | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 159 | "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 160 | }, 161 | "node_modules/hoist-non-react-statics": { 162 | "version": "3.3.2", 163 | "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", 164 | "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", 165 | "dependencies": { 166 | "react-is": "^16.7.0" 167 | } 168 | }, 169 | "node_modules/js-tokens": { 170 | "version": "4.0.0", 171 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 172 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 173 | }, 174 | "node_modules/loose-envify": { 175 | "version": "1.4.0", 176 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 177 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 178 | "dependencies": { 179 | "js-tokens": "^3.0.0 || ^4.0.0" 180 | }, 181 | "bin": { 182 | "loose-envify": "cli.js" 183 | } 184 | }, 185 | "node_modules/nanoid": { 186 | "version": "3.1.29", 187 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.29.tgz", 188 | "integrity": "sha512-dW2pUSGZ8ZnCFIlBIA31SV8huOGCHb6OwzVCc7A69rb/a+SgPBwfmLvK5TKQ3INPbRkcI8a/Owo0XbiTNH19wg==", 189 | "bin": { 190 | "nanoid": "bin/nanoid.cjs" 191 | }, 192 | "engines": { 193 | "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 194 | } 195 | }, 196 | "node_modules/object-assign": { 197 | "version": "4.1.1", 198 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 199 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", 200 | "engines": { 201 | "node": ">=0.10.0" 202 | } 203 | }, 204 | "node_modules/react": { 205 | "version": "17.0.2", 206 | "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", 207 | "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", 208 | "dependencies": { 209 | "loose-envify": "^1.1.0", 210 | "object-assign": "^4.1.1" 211 | }, 212 | "engines": { 213 | "node": ">=0.10.0" 214 | } 215 | }, 216 | "node_modules/react-dnd": { 217 | "version": "14.0.4", 218 | "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.4.tgz", 219 | "integrity": "sha512-AFJJXzUIWp5WAhgvI85ESkDCawM0lhoVvfo/lrseLXwFdH3kEO3v8I2C81QPqBW2UEyJBIPStOhPMGYGFtq/bg==", 220 | "dependencies": { 221 | "@react-dnd/invariant": "^2.0.0", 222 | "@react-dnd/shallowequal": "^2.0.0", 223 | "dnd-core": "14.0.1", 224 | "fast-deep-equal": "^3.1.3", 225 | "hoist-non-react-statics": "^3.3.2" 226 | }, 227 | "peerDependencies": { 228 | "@types/hoist-non-react-statics": ">= 3.3.1", 229 | "@types/node": ">= 12", 230 | "@types/react": ">= 16", 231 | "react": ">= 16.14" 232 | }, 233 | "peerDependenciesMeta": { 234 | "@types/hoist-non-react-statics": { 235 | "optional": true 236 | }, 237 | "@types/node": { 238 | "optional": true 239 | }, 240 | "@types/react": { 241 | "optional": true 242 | } 243 | } 244 | }, 245 | "node_modules/react-dnd-html5-backend": { 246 | "version": "14.0.2", 247 | "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz", 248 | "integrity": "sha512-QgN6rYrOm4UUj6tIvN8ovImu6uP48xBXF2rzVsp6tvj6d5XQ7OjHI4SJ/ZgGobOneRAU3WCX4f8DGCYx0tuhlw==", 249 | "dependencies": { 250 | "dnd-core": "14.0.1" 251 | } 252 | }, 253 | "node_modules/react-is": { 254 | "version": "16.13.1", 255 | "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", 256 | "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" 257 | }, 258 | "node_modules/redux": { 259 | "version": "4.1.1", 260 | "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.1.tgz", 261 | "integrity": "sha512-hZQZdDEM25UY2P493kPYuKqviVwZ58lEmGQNeQ+gXa+U0gYPUBf7NKYazbe3m+bs/DzM/ahN12DbF+NG8i0CWw==", 262 | "dependencies": { 263 | "@babel/runtime": "^7.9.2" 264 | } 265 | }, 266 | "node_modules/regenerator-runtime": { 267 | "version": "0.13.9", 268 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", 269 | "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" 270 | }, 271 | "node_modules/typescript": { 272 | "version": "4.4.3", 273 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.3.tgz", 274 | "integrity": "sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA==", 275 | "dev": true, 276 | "bin": { 277 | "tsc": "bin/tsc", 278 | "tsserver": "bin/tsserver" 279 | }, 280 | "engines": { 281 | "node": ">=4.2.0" 282 | } 283 | } 284 | }, 285 | "dependencies": { 286 | "@babel/runtime": { 287 | "version": "7.15.4", 288 | "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz", 289 | "integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==", 290 | "requires": { 291 | "regenerator-runtime": "^0.13.4" 292 | } 293 | }, 294 | "@react-dnd/asap": { 295 | "version": "4.0.0", 296 | "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", 297 | "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==" 298 | }, 299 | "@react-dnd/invariant": { 300 | "version": "2.0.0", 301 | "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", 302 | "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==" 303 | }, 304 | "@react-dnd/shallowequal": { 305 | "version": "2.0.0", 306 | "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", 307 | "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" 308 | }, 309 | "@types/d3-drag": { 310 | "version": "3.0.1", 311 | "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.1.tgz", 312 | "integrity": "sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==", 313 | "requires": { 314 | "@types/d3-selection": "*" 315 | } 316 | }, 317 | "@types/d3-quadtree": { 318 | "version": "3.0.2", 319 | "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz", 320 | "integrity": "sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==" 321 | }, 322 | "@types/d3-selection": { 323 | "version": "3.0.1", 324 | "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.1.tgz", 325 | "integrity": "sha512-aJ1d1SCUtERHH65bB8NNoLpUOI3z8kVcfg2BGm4rMMUwuZF4x6qnIEKjT60Vt0o7gP/a/xkRVs4D9CpDifbyRA==" 326 | }, 327 | "@types/hoist-non-react-statics": { 328 | "version": "3.3.1", 329 | "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", 330 | "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", 331 | "requires": { 332 | "@types/react": "*", 333 | "hoist-non-react-statics": "^3.3.0" 334 | } 335 | }, 336 | "@types/prop-types": { 337 | "version": "15.7.4", 338 | "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", 339 | "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" 340 | }, 341 | "@types/react": { 342 | "version": "17.0.27", 343 | "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.27.tgz", 344 | "integrity": "sha512-zgiJwtsggVGtr53MndV7jfiUESTqrbxOcBvwfe6KS/9bzaVPCTDieTWnFNecVNx6EAaapg5xsLLWFfHHR437AA==", 345 | "requires": { 346 | "@types/prop-types": "*", 347 | "@types/scheduler": "*", 348 | "csstype": "^3.0.2" 349 | } 350 | }, 351 | "@types/scheduler": { 352 | "version": "0.16.2", 353 | "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", 354 | "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" 355 | }, 356 | "csstype": { 357 | "version": "3.0.9", 358 | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", 359 | "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==" 360 | }, 361 | "d3-dispatch": { 362 | "version": "3.0.1", 363 | "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", 364 | "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==" 365 | }, 366 | "d3-drag": { 367 | "version": "3.0.0", 368 | "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", 369 | "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", 370 | "requires": { 371 | "d3-dispatch": "1 - 3", 372 | "d3-selection": "3" 373 | } 374 | }, 375 | "d3-quadtree": { 376 | "version": "3.0.1", 377 | "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", 378 | "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==" 379 | }, 380 | "d3-selection": { 381 | "version": "3.0.0", 382 | "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", 383 | "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==" 384 | }, 385 | "dnd-core": { 386 | "version": "14.0.1", 387 | "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", 388 | "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", 389 | "requires": { 390 | "@react-dnd/asap": "^4.0.0", 391 | "@react-dnd/invariant": "^2.0.0", 392 | "redux": "^4.1.1" 393 | } 394 | }, 395 | "fast-deep-equal": { 396 | "version": "3.1.3", 397 | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 398 | "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 399 | }, 400 | "hoist-non-react-statics": { 401 | "version": "3.3.2", 402 | "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", 403 | "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", 404 | "requires": { 405 | "react-is": "^16.7.0" 406 | } 407 | }, 408 | "js-tokens": { 409 | "version": "4.0.0", 410 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 411 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 412 | }, 413 | "loose-envify": { 414 | "version": "1.4.0", 415 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 416 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 417 | "requires": { 418 | "js-tokens": "^3.0.0 || ^4.0.0" 419 | } 420 | }, 421 | "nanoid": { 422 | "version": "3.1.29", 423 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.29.tgz", 424 | "integrity": "sha512-dW2pUSGZ8ZnCFIlBIA31SV8huOGCHb6OwzVCc7A69rb/a+SgPBwfmLvK5TKQ3INPbRkcI8a/Owo0XbiTNH19wg==" 425 | }, 426 | "object-assign": { 427 | "version": "4.1.1", 428 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 429 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" 430 | }, 431 | "react": { 432 | "version": "17.0.2", 433 | "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", 434 | "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", 435 | "requires": { 436 | "loose-envify": "^1.1.0", 437 | "object-assign": "^4.1.1" 438 | } 439 | }, 440 | "react-dnd": { 441 | "version": "14.0.4", 442 | "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.4.tgz", 443 | "integrity": "sha512-AFJJXzUIWp5WAhgvI85ESkDCawM0lhoVvfo/lrseLXwFdH3kEO3v8I2C81QPqBW2UEyJBIPStOhPMGYGFtq/bg==", 444 | "requires": { 445 | "@react-dnd/invariant": "^2.0.0", 446 | "@react-dnd/shallowequal": "^2.0.0", 447 | "dnd-core": "14.0.1", 448 | "fast-deep-equal": "^3.1.3", 449 | "hoist-non-react-statics": "^3.3.2" 450 | } 451 | }, 452 | "react-dnd-html5-backend": { 453 | "version": "14.0.2", 454 | "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz", 455 | "integrity": "sha512-QgN6rYrOm4UUj6tIvN8ovImu6uP48xBXF2rzVsp6tvj6d5XQ7OjHI4SJ/ZgGobOneRAU3WCX4f8DGCYx0tuhlw==", 456 | "requires": { 457 | "dnd-core": "14.0.1" 458 | } 459 | }, 460 | "react-is": { 461 | "version": "16.13.1", 462 | "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", 463 | "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" 464 | }, 465 | "redux": { 466 | "version": "4.1.1", 467 | "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.1.tgz", 468 | "integrity": "sha512-hZQZdDEM25UY2P493kPYuKqviVwZ58lEmGQNeQ+gXa+U0gYPUBf7NKYazbe3m+bs/DzM/ahN12DbF+NG8i0CWw==", 469 | "requires": { 470 | "@babel/runtime": "^7.9.2" 471 | } 472 | }, 473 | "regenerator-runtime": { 474 | "version": "0.13.9", 475 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", 476 | "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" 477 | }, 478 | "typescript": { 479 | "version": "4.4.3", 480 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.3.tgz", 481 | "integrity": "sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA==", 482 | "dev": true 483 | } 484 | } 485 | } 486 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dataflow-editor", 3 | "version": "0.1.6", 4 | "description": "A generic drag-and-drop dataflow editor for React", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib" 8 | ], 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "build": "tsc" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/joeltg/react-dataflow-editor.git" 16 | }, 17 | "author": "Joel Gustafson", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/joeltg/react-dataflow-editor/issues" 21 | }, 22 | "homepage": "https://github.com/joeltg/react-dataflow-editor#readme", 23 | "dependencies": { 24 | "@types/d3-drag": "^3.0.1", 25 | "@types/d3-quadtree": "^3.0.2", 26 | "@types/d3-selection": "^3.0.1", 27 | "@types/hoist-non-react-statics": "^3.3.1", 28 | "@types/react": "^17.0.11", 29 | "d3-drag": "^3.0.0", 30 | "d3-quadtree": "^3.0.1", 31 | "d3-selection": "^3.0.0", 32 | "nanoid": "^3.1.23", 33 | "react-dnd": "^14.0.2", 34 | "react-dnd-html5-backend": "^14.0.0" 35 | }, 36 | "devDependencies": { 37 | "react": "^17.0.2", 38 | "typescript": "^4.3.2" 39 | }, 40 | "peerDependencies": { 41 | "react": "^17.0.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Edge.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useMemo } from "react" 2 | import type { Schema, Kinds, Node, Edge, Focus } from "./state.js" 3 | 4 | import { 5 | getInputOffset, 6 | getOutputOffset, 7 | makeCurvePath, 8 | place, 9 | } from "./utils.js" 10 | import { CanvasContext } from "./context.js" 11 | 12 | export interface GraphEdgeProps { 13 | kinds: Kinds 14 | nodes: Record> 15 | focus: Focus | null 16 | edge: Edge 17 | } 18 | 19 | export function GraphEdge(props: GraphEdgeProps) { 20 | const { kinds, nodes, edge } = props 21 | const source = nodes[edge.source.id] 22 | const target = nodes[edge.target.id] 23 | 24 | const context = useContext(CanvasContext) 25 | 26 | const sourcePosition = useMemo(() => { 27 | const offset = getOutputOffset(kinds, source.kind, edge.source.output) 28 | return place(context, source.position, offset) 29 | }, [edge.source, source.position]) 30 | 31 | const targetPosition = useMemo(() => { 32 | const offset = getInputOffset(kinds, target.kind, edge.target.input) 33 | return place(context, target.position, offset) 34 | }, [edge.target, target.position]) 35 | 36 | const path = useMemo(() => makeCurvePath(sourcePosition, targetPosition), [ 37 | sourcePosition, 38 | targetPosition, 39 | ]) 40 | 41 | const handleClick = useCallback((event: React.MouseEvent) => { 42 | context.onFocus({ element: "edge", id: props.edge.id }) 43 | }, []) 44 | 45 | const { borderColor, backgroundColor } = context.options 46 | 47 | const isFocused = 48 | props.focus !== null && 49 | props.focus.element === "edge" && 50 | props.focus.id === props.edge.id 51 | 52 | return ( 53 | 66 | 67 | 68 | 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useMemo } from "react" 2 | 3 | import { select } from "d3-selection" 4 | import { DragBehavior } from "d3-drag" 5 | 6 | import type { Focus, GetInputs, Kinds, Node, Schema } from "./state.js" 7 | 8 | import { InputDragSubject } from "./inputDrag.js" 9 | 10 | import { CanvasContext } from "./context.js" 11 | 12 | import { 13 | getInputIndex, 14 | getPortOffsetY, 15 | nodeMarginX, 16 | portRadius, 17 | toTranslate, 18 | } from "./utils.js" 19 | 20 | export interface GraphInputProps { 21 | kinds: Kinds 22 | focus: Focus | null 23 | node: Node 24 | input: GetInputs 25 | inputDrag?: DragBehavior> 26 | } 27 | 28 | export function GraphInput( 29 | props: GraphInputProps 30 | ) { 31 | const transform = useMemo(() => { 32 | const index = getInputIndex(props.kinds, props.node.kind, props.input) 33 | const offsetY = getPortOffsetY(index) 34 | return toTranslate([0, offsetY]) 35 | }, []) 36 | 37 | const context = useContext(CanvasContext) 38 | const { backgroundColor } = context.options 39 | 40 | const ref = useCallback((circle: SVGCircleElement | null) => { 41 | if (circle !== null && props.inputDrag) { 42 | select(circle).call(props.inputDrag) 43 | } 44 | }, []) 45 | 46 | const value: string | null = props.node.inputs[props.input] 47 | const handleClick = useCallback( 48 | (event: React.MouseEvent) => { 49 | event.stopPropagation() 50 | if (value !== null) { 51 | context.onFocus({ element: "edge", id: value }) 52 | } 53 | }, 54 | [value] 55 | ) 56 | 57 | const isFocused = 58 | props.focus !== null && 59 | props.focus.element === "edge" && 60 | props.focus.id === value 61 | 62 | return ( 63 | 71 | 76 | {props.input} 77 | 78 | 87 | 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/Node.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useMemo } from "react" 2 | import { select } from "d3-selection" 3 | import { DragBehavior } from "d3-drag" 4 | 5 | import type { Focus, Kinds, Node, Schema } from "./state.js" 6 | import { 7 | makeClipPath, 8 | nodeHeaderHeight, 9 | nodeMarginX, 10 | nodeWidth, 11 | place, 12 | toTranslate, 13 | } from "./utils.js" 14 | import { CanvasContext } from "./context.js" 15 | 16 | import { GraphInput } from "./Input.js" 17 | import { GraphOutput } from "./Output.js" 18 | import { InputDragSubject } from "./inputDrag.js" 19 | import { OutputDragSubject } from "./outputDrag.js" 20 | import { NodeDragSubject } from "./nodeDrag.js" 21 | 22 | export interface GraphNodeProps { 23 | kinds: Kinds 24 | focus: Focus | null 25 | node: Node 26 | nodeDrag?: DragBehavior 27 | inputDrag?: DragBehavior> 28 | outputDrag?: DragBehavior> 29 | children?: React.ReactNode 30 | } 31 | 32 | export function GraphNode(props: GraphNodeProps) { 33 | const { name, backgroundColor, inputs, outputs } = props.kinds[ 34 | props.node.kind 35 | ] 36 | 37 | const clipPath = useMemo(() => makeClipPath(props.kinds, props.node.kind), []) 38 | 39 | const context = useContext(CanvasContext) 40 | 41 | const ref = useCallback((g: SVGGElement | null) => { 42 | if (g !== null && props.nodeDrag) { 43 | select(g).call(props.nodeDrag) 44 | } 45 | }, []) 46 | 47 | const handleClick = useCallback((event: React.MouseEvent) => { 48 | context.onFocus({ element: "node", id: props.node.id }) 49 | }, []) 50 | 51 | const transform = toTranslate(place(context, props.node.position)) 52 | const isFocused = 53 | props.focus !== null && 54 | props.focus.element === "node" && 55 | props.focus.id === props.node.id 56 | 57 | const { borderColor } = context.options 58 | 59 | return ( 60 | 73 | 74 | 75 | {name} 76 | 77 | {props.children} 78 | 86 | 87 | {Object.keys(inputs).map((input) => ( 88 | 89 | key={input} 90 | kinds={props.kinds} 91 | focus={props.focus} 92 | node={props.node} 93 | input={input} 94 | inputDrag={props.inputDrag} 95 | /> 96 | ))} 97 | 98 | 99 | {Object.keys(outputs).map((output) => ( 100 | 101 | key={output} 102 | kinds={props.kinds} 103 | focus={props.focus} 104 | node={props.node} 105 | output={output} 106 | outputDrag={props.outputDrag} 107 | /> 108 | ))} 109 | 110 | 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /src/Output.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useMemo } from "react" 2 | import { select } from "d3-selection" 3 | import { DragBehavior } from "d3-drag" 4 | 5 | import type { Schema, GetOutputs, Focus, Kinds, Node } from "./state.js" 6 | 7 | import { 8 | getOutputIndex, 9 | getPortOffsetY, 10 | nodeMarginX, 11 | portRadius, 12 | toTranslate, 13 | } from "./utils.js" 14 | import { OutputDragSubject } from "./outputDrag.js" 15 | import { CanvasContext } from "./context.js" 16 | 17 | export interface GraphOutputProps { 18 | kinds: Kinds 19 | focus: Focus | null 20 | node: Node 21 | output: GetOutputs 22 | outputDrag?: DragBehavior> 23 | } 24 | 25 | export function GraphOutput( 26 | props: GraphOutputProps 27 | ) { 28 | const transform = useMemo(() => { 29 | const index = getOutputIndex(props.kinds, props.node.kind, props.output) 30 | const offsetY = getPortOffsetY(index) 31 | return toTranslate([0, offsetY]) 32 | }, []) 33 | 34 | const context = useContext(CanvasContext) 35 | const { backgroundColor } = context.options 36 | 37 | const ref = useCallback((circle: SVGCircleElement | null) => { 38 | if (circle !== null && props.outputDrag) { 39 | select(circle).call(props.outputDrag) 40 | } 41 | }, []) 42 | 43 | const values: string[] = props.node.outputs[props.output] 44 | const isFocused = 45 | props.focus !== null && 46 | props.focus.element === "edge" && 47 | values.includes(props.focus.id) 48 | 49 | return ( 50 | 57 | 63 | {props.output} 64 | 65 | 72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid" 2 | import type { Focus, Position, Schema, Source, Target } from "./state.js" 3 | 4 | export type EditorAction = 5 | | CreateNodeAction 6 | | MoveNodeAction 7 | | DeleteNodeAction 8 | | CreateEdgeAction 9 | | MoveEdgeAction 10 | | DeleteEdgeAction 11 | | FocusAction 12 | 13 | export type CreateNodeAction = { 14 | type: "node/create" 15 | id: string 16 | kind: keyof S 17 | position: Position 18 | } 19 | 20 | export const createNode = ( 21 | kind: keyof S, 22 | position: Position 23 | ): CreateNodeAction => ({ 24 | type: "node/create", 25 | id: nanoid(10), 26 | kind, 27 | position, 28 | }) 29 | 30 | export type MoveNodeAction = { 31 | type: "node/move" 32 | id: string 33 | position: Position 34 | } 35 | 36 | export const moveNode = (id: string, position: Position): MoveNodeAction => ({ 37 | type: "node/move", 38 | id, 39 | position, 40 | }) 41 | 42 | export type DeleteNodeAction = { type: "node/delete"; id: string } 43 | 44 | export const deleteNode = (id: string): DeleteNodeAction => ({ 45 | type: "node/delete", 46 | id, 47 | }) 48 | 49 | export type CreateEdgeAction = { 50 | type: "edge/create" 51 | id: string 52 | source: Source 53 | target: Target 54 | } 55 | 56 | export const createEdge = ( 57 | source: Source, 58 | target: Target 59 | ): CreateEdgeAction => ({ 60 | type: "edge/create", 61 | id: nanoid(10), 62 | source, 63 | target, 64 | }) 65 | 66 | export type MoveEdgeAction = { 67 | type: "edge/move" 68 | id: string 69 | target: Target 70 | } 71 | 72 | export const moveEdge = ( 73 | id: string, 74 | target: Target 75 | ): MoveEdgeAction => ({ 76 | type: "edge/move", 77 | id, 78 | target, 79 | }) 80 | 81 | export type DeleteEdgeAction = { type: "edge/delete"; id: string } 82 | 83 | export const deleteEdge = (id: string): DeleteEdgeAction => ({ 84 | type: "edge/delete", 85 | id, 86 | }) 87 | 88 | export type FocusAction = { 89 | type: "focus" 90 | subject: Focus | null 91 | } 92 | 93 | export const focus = (subject: Focus | null): FocusAction => ({ 94 | type: "focus", 95 | subject, 96 | }) 97 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import React, { createContext } from "react" 2 | 3 | import type { Focus } from "./state.js" 4 | import { Options, defaultOptions } from "./options.js" 5 | 6 | export interface CanvasContext { 7 | options: Options 8 | svgRef: React.MutableRefObject 9 | nodesRef: React.MutableRefObject 10 | edgesRef: React.MutableRefObject 11 | previewRef: React.MutableRefObject 12 | onFocus: (subject: Focus | null) => void 13 | } 14 | 15 | export const CanvasContext = createContext({ 16 | options: defaultOptions, 17 | svgRef: { current: null }, 18 | nodesRef: { current: null }, 19 | edgesRef: { current: null }, 20 | previewRef: { current: null }, 21 | onFocus: (subject) => {}, 22 | }) 23 | -------------------------------------------------------------------------------- /src/editor/EditableCanvas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useMemo } from "react" 2 | 3 | import { useDrop } from "react-dnd" 4 | 5 | import type { EditorState, Kinds, Schema } from "../state.js" 6 | import { createNode, deleteNode, EditorAction } from "../actions.js" 7 | 8 | import { GraphNode } from "../Node.js" 9 | import { GraphEdge } from "../Edge.js" 10 | import { makeInputDragBehavior } from "../inputDrag.js" 11 | import { makeOutputDragBehavior } from "../outputDrag.js" 12 | import { makeNodeDragBehavior } from "../nodeDrag.js" 13 | 14 | import { getCanvasWidth, nodeWidth, portRadius, snap } from "../utils.js" 15 | import { CanvasContext } from "../context.js" 16 | import { useStyles } from "../styles.js" 17 | 18 | export interface CanvasProps { 19 | kinds: Kinds 20 | state: EditorState 21 | dispatch: (action: EditorAction) => void 22 | } 23 | 24 | export function Canvas(props: CanvasProps) { 25 | const context = useContext(CanvasContext) 26 | 27 | const [{}, drop] = useDrop<{ kind: keyof S }, void, {}>({ 28 | accept: ["node"], 29 | drop({ kind }, monitor) { 30 | const { x, y } = monitor.getSourceClientOffset()! 31 | const { left, top } = context.svgRef.current!.getBoundingClientRect() 32 | const position = snap(context, [x - left, y - top]) 33 | props.dispatch(createNode(kind, position)) 34 | }, 35 | }) 36 | 37 | const styles = useStyles() 38 | 39 | const width = useMemo(() => { 40 | return getCanvasWidth(context, props.state.nodes) 41 | }, [props.state.nodes]) 42 | 43 | const nodeDrag = useMemo(() => { 44 | return makeNodeDragBehavior(context, props.kinds, props.dispatch) 45 | }, []) 46 | 47 | const inputDrag = useMemo(() => { 48 | return makeInputDragBehavior(context, props.kinds, props.dispatch) 49 | }, []) 50 | 51 | const outputDrag = useMemo(() => { 52 | return makeOutputDragBehavior(context, props.kinds, props.dispatch) 53 | }, []) 54 | 55 | const handleBackgroundClick = useCallback( 56 | (event: React.MouseEvent) => { 57 | context.onFocus(null) 58 | }, 59 | [] 60 | ) 61 | 62 | const { borderColor, backgroundColor, unit, height } = context.options 63 | const borderWidth = props.state.focus === null ? 1 : 0 64 | const boxShadow = `0 0 0 ${borderWidth}px ${borderColor}` 65 | 66 | return ( 67 |
68 | 74 | 81 | 82 | {Object.values(props.state.edges).map((edge) => ( 83 | 84 | key={edge.id} 85 | kinds={props.kinds} 86 | nodes={props.state.nodes} 87 | focus={props.state.focus} 88 | edge={edge} 89 | /> 90 | ))} 91 | 92 | 93 | {Object.values(props.state.nodes).map((node) => ( 94 | 95 | key={node.id} 96 | kinds={props.kinds} 97 | focus={props.state.focus} 98 | node={node} 99 | nodeDrag={nodeDrag} 100 | inputDrag={inputDrag} 101 | outputDrag={outputDrag} 102 | > 103 | props.dispatch(deleteNode(node.id))} 110 | > 111 | 𝖷 112 | 113 | 114 | ))} 115 | 116 | 124 | 125 | 126 | 127 | 128 | 129 |
130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /src/editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useRef } from "react" 2 | 3 | import { DndProvider } from "react-dnd" 4 | import { HTML5Backend } from "react-dnd-html5-backend" 5 | 6 | import { Toolbox } from "./Toolbox.js" 7 | import { Canvas } from "./EditableCanvas.js" 8 | 9 | import type { Kinds, EditorState, Schema } from "../state.js" 10 | 11 | import { EditorAction, focus } from "../actions.js" 12 | import { CanvasContext } from "../context.js" 13 | import { isFocusEqual } from "../utils.js" 14 | import { Options, defaultOptions } from "../options.js" 15 | 16 | export interface EditorProps { 17 | kinds: Kinds 18 | state: EditorState 19 | dispatch: (action: EditorAction) => void 20 | options?: Partial 21 | } 22 | 23 | export function Editor(props: EditorProps) { 24 | const stateRef = useRef(props.state) 25 | stateRef.current = props.state 26 | 27 | const dispatch = useCallback( 28 | (action: EditorAction) => props.dispatch(action), 29 | [] 30 | ) 31 | 32 | const context = useMemo(() => { 33 | return { 34 | options: { ...defaultOptions, ...props.options }, 35 | nodesRef: { current: null }, 36 | edgesRef: { current: null }, 37 | svgRef: { current: null }, 38 | previewRef: { current: null }, 39 | onFocus: (subject) => { 40 | if (!isFocusEqual(stateRef.current.focus, subject)) { 41 | dispatch(focus(subject)) 42 | } 43 | }, 44 | } 45 | }, []) 46 | 47 | return ( 48 | 49 | 50 |
54 | 55 | 56 |
57 |
58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/editor/Toolbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react" 2 | import { useDrag } from "react-dnd" 3 | import { CanvasContext } from "../context.js" 4 | 5 | import type { Kinds, Schema } from "../state.js" 6 | import { portMargin } from "../utils.js" 7 | 8 | export interface PreviewNodeProps { 9 | kind: keyof S 10 | kinds: Kinds 11 | } 12 | 13 | export function PreviewNode(props: PreviewNodeProps) { 14 | const [_, drag] = useDrag({ type: "node", item: { kind: props.kind } }) 15 | const context = useContext(CanvasContext) 16 | const { borderColor } = context.options 17 | const { backgroundColor, name } = props.kinds[props.kind] 18 | return ( 19 |
34 |
41 | {name} 42 |
43 |
44 | ) 45 | } 46 | 47 | export interface ToolboxProps { 48 | kinds: Kinds 49 | } 50 | 51 | export function Toolbox(props: ToolboxProps) { 52 | return ( 53 |
54 | {Object.keys(props.kinds).map((key) => { 55 | const kind = key as keyof S 56 | return 57 | })} 58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./editor/Editor.js" 2 | export * from "./editor/Toolbox.js" 3 | export * from "./state.js" 4 | export * from "./utils.js" 5 | export * from "./actions.js" 6 | export * from "./reduce.js" 7 | export * from "./viewer/Viewer.js" 8 | -------------------------------------------------------------------------------- /src/inputDrag.ts: -------------------------------------------------------------------------------- 1 | import { D3DragEvent, DragBehavior, drag } from "d3-drag" 2 | import { Quadtree } from "d3-quadtree" 3 | import { select, Selection } from "d3-selection" 4 | 5 | import type { Kinds, Schema, Target } from "./state.js" 6 | import { deleteEdge, EditorAction, moveEdge } from "./actions.js" 7 | import { CanvasContext } from "./context.js" 8 | import { DragTarget, getTargets } from "./target.js" 9 | import { 10 | getEdgeSource, 11 | getEdgeTarget, 12 | getSourcePosition, 13 | getTargetPosition, 14 | makeCurvePath, 15 | portRadius, 16 | } from "./utils.js" 17 | 18 | export type InputDragSubject = { 19 | x: number 20 | y: number 21 | id: string 22 | edge: Selection 23 | target: Target 24 | targets: Quadtree> 25 | sourcePosition: [number, number] 26 | preview: Selection 27 | } 28 | 29 | type InputDragEvent = D3DragEvent< 30 | SVGCircleElement, 31 | unknown, 32 | InputDragSubject 33 | > 34 | 35 | export function makeInputDragBehavior( 36 | context: CanvasContext, 37 | kinds: Kinds, 38 | dispatch: (action: EditorAction) => void 39 | ): DragBehavior> { 40 | // The binding element here is a g.input > circle.port 41 | return drag>() 42 | .clickDistance(15) 43 | .container(context.svgRef.current!) 44 | .on("start", function onStart(event: InputDragEvent) { 45 | event.subject.preview.attr("display", "initial") 46 | const [x, y] = event.subject.sourcePosition 47 | 48 | event.subject.edge.attr("display", "none") 49 | event.subject.preview 50 | .select("path") 51 | .attr("d", makeCurvePath([x, y], [event.x, event.y])) 52 | event.subject.preview.select("circle.source").attr("cx", x).attr("cy", y) 53 | event.subject.preview 54 | .selectAll("circle.target") 55 | .attr("cx", event.x) 56 | .attr("cy", event.y) 57 | 58 | context.onFocus({ element: "edge", id: event.subject.id }) 59 | }) 60 | .on("drag", function onDrag(event: InputDragEvent) { 61 | this.setAttribute("display", "none") 62 | 63 | const result = event.subject.targets.find( 64 | event.x, 65 | event.y, 66 | portRadius * 2 67 | ) 68 | 69 | const [x, y] = 70 | result !== undefined ? [result.x, result.y] : [event.x, event.y] 71 | 72 | event.subject.preview.select("circle.target").attr("cx", x).attr("cy", y) 73 | const path = makeCurvePath(event.subject.sourcePosition, [x, y]) 74 | event.subject.preview.select("path").attr("d", path) 75 | }) 76 | .on("end", function onEnd(event: InputDragEvent) { 77 | const result = event.subject.targets.find( 78 | event.x, 79 | event.y, 80 | 2 * portRadius 81 | ) 82 | 83 | this.setAttribute("display", "initial") 84 | event.subject.edge.attr("display", "initial") 85 | 86 | if (result === undefined) { 87 | dispatch(deleteEdge(event.subject.id)) 88 | } else if ( 89 | result.target.id !== event.subject.target.id || 90 | result.target.input !== event.subject.target.input 91 | ) { 92 | dispatch(moveEdge(event.subject.id, result.target)) 93 | } else { 94 | context.onFocus({ element: "edge", id: event.subject.id }) 95 | } 96 | 97 | event.subject.preview.attr("display", "none") 98 | event.subject.preview.select("path").attr("d", null) 99 | event.subject.preview 100 | .selectAll("circle") 101 | .attr("cx", null) 102 | .attr("cy", null) 103 | }) 104 | .subject(function (event: InputDragEvent): InputDragSubject { 105 | const value = select(this.parentElement).attr("data-value") 106 | const edges = select(context.edgesRef.current) 107 | const edge = edges.select(`g.edge[data-id="${value}"]`) 108 | const id = edge.attr("data-id") 109 | const source = getEdgeSource(edge) 110 | const target = getEdgeTarget(edge) 111 | const sourcePosition = getSourcePosition(context, kinds, source) 112 | const [x, y] = getTargetPosition(context, kinds, target) 113 | const targets = getTargets(context, kinds, target) 114 | const preview = select(context.previewRef.current) 115 | return { x, y, id, edge, target, targets, sourcePosition, preview } 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /src/nodeDrag.ts: -------------------------------------------------------------------------------- 1 | import { select, Selection } from "d3-selection" 2 | import { D3DragEvent, drag, DragBehavior } from "d3-drag" 3 | 4 | import type { Kinds, Position, Schema } from "./state.js" 5 | import { 6 | getEdgeSource, 7 | getEdgeTarget, 8 | getInputOffset, 9 | getNodeAttributes, 10 | getOutputOffset, 11 | getSourcePosition, 12 | getTargetPosition, 13 | makeCurvePath, 14 | place, 15 | snap, 16 | toTranslate, 17 | } from "./utils.js" 18 | import { CanvasContext } from "./context.js" 19 | 20 | import { EditorAction, moveNode } from "./actions.js" 21 | 22 | type IncomingEdge = { 23 | sourcePosition: [number, number] 24 | inputOffset: [number, number] 25 | } 26 | 27 | type OutgoingEdge = { 28 | targetPosition: [number, number] 29 | outputOffset: [number, number] 30 | } 31 | 32 | export type NodeDragSubject = { 33 | x: number 34 | y: number 35 | id: string 36 | incoming: Selection 37 | outgoing: Selection 38 | position: Position 39 | } 40 | 41 | type NodeDragEvent = D3DragEvent 42 | 43 | export function makeNodeDragBehavior( 44 | context: CanvasContext, 45 | kinds: Kinds, 46 | dispatch: (action: EditorAction) => void 47 | ): DragBehavior { 48 | // The binding element here is a g.node 49 | return drag() 50 | .on("start", function onStart(event: NodeDragEvent) { 51 | this.setAttribute("cursor", "grabbing") 52 | context.onFocus({ element: "node", id: event.subject.id }) 53 | }) 54 | .on("drag", function onDrag(event: NodeDragEvent) { 55 | const { x, y, subject } = event 56 | setNodePosition.call(this, subject, [x, y]) 57 | }) 58 | .on("end", function onEnd(event: NodeDragEvent) { 59 | this.setAttribute("cursor", "grab") 60 | 61 | const position = snap(context, [event.x, event.y]) 62 | if ( 63 | position.x !== event.subject.position.x || 64 | position.y !== event.subject.position.y 65 | ) { 66 | dispatch(moveNode(event.subject.id, position)) 67 | } else { 68 | setNodePosition.call(this, event.subject, [ 69 | event.subject.x, 70 | event.subject.y, 71 | ]) 72 | } 73 | }) 74 | .subject(function getSubject(event: NodeDragEvent): NodeDragSubject { 75 | const node = select(this) 76 | const { id, position } = getNodeAttributes(node) 77 | const [x, y] = place(context, position) 78 | 79 | const edges = select( 80 | context.edgesRef.current 81 | ) 82 | 83 | const incoming = edges 84 | .selectAll(`g[data-target-id="${id}"]`) 85 | .datum(function () { 86 | const edge = select(this) 87 | const source = getEdgeSource(edge) 88 | const { kind, input } = getEdgeTarget(edge) 89 | return { 90 | sourcePosition: getSourcePosition(context, kinds, source), 91 | inputOffset: getInputOffset(kinds, kind, input), 92 | } 93 | }) 94 | 95 | const outgoing = edges 96 | .selectAll(`g[data-source-id="${id}"]`) 97 | .datum(function () { 98 | const edge = select(this) 99 | const { kind, output } = getEdgeSource(edge) 100 | const target = getEdgeTarget(edge) 101 | return { 102 | outputOffset: getOutputOffset(kinds, kind, output), 103 | targetPosition: getTargetPosition(context, kinds, target), 104 | } 105 | }) 106 | 107 | return { x, y, id, incoming, outgoing, position } 108 | }) 109 | } 110 | 111 | function setEdgePosition( 112 | this: SVGGElement, 113 | sourcePosition: [number, number], 114 | targetPosition: [number, number] 115 | ) { 116 | const d = makeCurvePath(sourcePosition, targetPosition) 117 | for (const path of this.querySelectorAll("path")) { 118 | path.setAttribute("d", d) 119 | } 120 | } 121 | 122 | function setNodePosition( 123 | this: SVGGElement, 124 | subject: NodeDragSubject, 125 | [x, y]: [number, number] 126 | ) { 127 | this.setAttribute("transform", toTranslate([x, y])) 128 | 129 | subject.incoming.each(function ({ sourcePosition, inputOffset: [dx, dy] }) { 130 | setEdgePosition.call(this, sourcePosition, [x + dx, y + dy]) 131 | }) 132 | 133 | subject.outgoing.each(function ({ outputOffset: [dx, dy], targetPosition }) { 134 | setEdgePosition.call(this, [x + dx, y + dy], targetPosition) 135 | }) 136 | } 137 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | export interface Options { 2 | borderColor: string 3 | backgroundColor: string 4 | unit: number 5 | height: number 6 | nodeWidth: number 7 | portRadius: number 8 | portMargin: number 9 | nodeMarginX: number 10 | nodeHeaderHeight: number 11 | canvasPaddingRight: number 12 | } 13 | 14 | export const defaultOptions: Options = { 15 | borderColor: "dimgray", 16 | backgroundColor: "lightgray", 17 | unit: 54, 18 | height: 12, 19 | nodeWidth: 156, 20 | portRadius: 12, 21 | portMargin: 12, 22 | nodeMarginX: 4, 23 | nodeHeaderHeight: 24, 24 | canvasPaddingRight: 480, 25 | } 26 | -------------------------------------------------------------------------------- /src/outputDrag.ts: -------------------------------------------------------------------------------- 1 | import { select, Selection } from "d3-selection" 2 | import { D3DragEvent, DragBehavior, drag } from "d3-drag" 3 | import { Quadtree } from "d3-quadtree" 4 | 5 | import type { Schema, Source, Kinds } from "./state.js" 6 | 7 | import { getSourcePosition, makeCurvePath, portRadius } from "./utils.js" 8 | import { CanvasContext } from "./context.js" 9 | import { DragTarget, getTargets } from "./target.js" 10 | import { createEdge, EditorAction } from "./actions.js" 11 | 12 | export type OutputDragSubject = { 13 | x: number 14 | y: number 15 | targets: Quadtree> 16 | source: Source 17 | preview: Selection 18 | } 19 | 20 | type OutputDragEvent = D3DragEvent< 21 | SVGCircleElement, 22 | unknown, 23 | OutputDragSubject 24 | > 25 | 26 | export function makeOutputDragBehavior( 27 | context: CanvasContext, 28 | kinds: Kinds, 29 | dispatch: (action: EditorAction) => void 30 | ): DragBehavior> { 31 | // The binding element here is a g.output > circle 32 | return drag>() 33 | .on("start", function onStart(event: OutputDragEvent) { 34 | event.subject.preview.attr("display", null) 35 | event.subject.preview 36 | .selectAll("circle") 37 | .attr("cx", event.x) 38 | .attr("cy", event.y) 39 | }) 40 | .on("drag", function onDrag(event: OutputDragEvent) { 41 | const result = event.subject.targets.find( 42 | event.x, 43 | event.y, 44 | portRadius * 2 45 | ) 46 | 47 | const [x, y] = 48 | result !== undefined ? [result.x, result.y] : [event.x, event.y] 49 | 50 | event.subject.preview.select("circle.target").attr("cx", x).attr("cy", y) 51 | const path = makeCurvePath([event.subject.x, event.subject.y], [x, y]) 52 | event.subject.preview.select("path").attr("d", path) 53 | }) 54 | .on("end", function onEnd(event: OutputDragEvent) { 55 | const result = event.subject.targets.find( 56 | event.x, 57 | event.y, 58 | 2 * portRadius 59 | ) 60 | 61 | if (result !== undefined) { 62 | const { target } = result 63 | dispatch(createEdge(event.subject.source, target)) 64 | } 65 | 66 | event.subject.preview.attr("display", "none") 67 | event.subject.preview 68 | .selectAll("circle") 69 | .attr("cx", null) 70 | .attr("cy", null) 71 | event.subject.preview.select("path").attr("d", null) 72 | }) 73 | 74 | .subject(function (event: OutputDragEvent): OutputDragSubject { 75 | const targets = getTargets(context, kinds) 76 | 77 | const port = select(this.parentElement) 78 | const id = port.attr("data-id") 79 | const output = port.attr("data-output") 80 | 81 | const source = { id, output } 82 | const [x, y] = getSourcePosition(context, kinds, source) 83 | 84 | const preview = select(context.previewRef.current) 85 | 86 | return { x, y, targets, source, preview } 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /src/reduce.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | EditorState, 3 | Kinds, 4 | Schema, 5 | Node, 6 | GetInputs, 7 | GetOutputs, 8 | Position, 9 | } from "./state.js" 10 | 11 | import type { EditorAction } from "./actions.js" 12 | 13 | import { 14 | forInputs, 15 | forOutputs, 16 | isFocusEqual, 17 | signalInvalidType, 18 | } from "./utils.js" 19 | 20 | export const makeReducer = 21 | (kinds: Kinds) => 22 | (state: EditorState, action: EditorAction) => 23 | reduce(kinds, state, action) 24 | 25 | export function reduce( 26 | kinds: Kinds, 27 | state: EditorState, 28 | action: EditorAction 29 | ): EditorState { 30 | if (action.type === "node/create") { 31 | const { id, kind, position } = action 32 | const nodes = { ...state.nodes } 33 | nodes[id] = createInitialNode(kinds, id, kind, position) 34 | return { ...state, nodes, focus: { element: "node", id } } 35 | } else if (action.type === "node/move") { 36 | const { id, position } = action 37 | const node = { ...state.nodes[id], position } 38 | const nodes = { ...state.nodes, [id]: node } 39 | return { ...state, nodes, focus: { element: "node", id } } 40 | } else if (action.type === "node/delete") { 41 | const { id } = action 42 | const { kind, inputs, outputs } = state.nodes[id] 43 | const nodes = { ...state.nodes } 44 | const edges = { ...state.edges } 45 | for (const input of forInputs(kinds, kind)) { 46 | const edgeId: null | string = inputs[input] 47 | if (edgeId !== null) { 48 | const { 49 | source: { id: sourceId, output }, 50 | } = edges[edgeId] 51 | delete edges[edgeId] 52 | const source = nodes[sourceId] 53 | const outputs = new Set(source.outputs[output]) 54 | outputs.delete(edgeId) 55 | nodes[sourceId] = { 56 | ...source, 57 | outputs: { ...source.outputs, [output]: Array.from(outputs) }, 58 | } 59 | } 60 | } 61 | 62 | for (const output of forOutputs(kinds, kind)) { 63 | for (const edgeId of outputs[output]) { 64 | const { 65 | target: { id: targetId, input }, 66 | } = edges[edgeId] 67 | delete edges[edgeId] 68 | const target = nodes[targetId] 69 | nodes[targetId] = { 70 | ...target, 71 | inputs: { ...target.inputs, [input]: null }, 72 | } 73 | } 74 | } 75 | 76 | delete nodes[id] 77 | 78 | const focus = isFocusEqual(state.focus, { element: "node", id }) 79 | ? null 80 | : state.focus 81 | 82 | return { ...state, nodes, edges, focus } 83 | } else if (action.type === "edge/create") { 84 | const { id, source, target } = action 85 | const edges = { ...state.edges } 86 | 87 | edges[id] = { id, source, target } 88 | 89 | const nodes = { ...state.nodes } 90 | 91 | const { id: sourceId, output } = source 92 | const sourceNode = nodes[sourceId] 93 | const sourceOutput = new Set(sourceNode.outputs[output]) 94 | sourceOutput.add(id) 95 | nodes[sourceId] = { 96 | ...sourceNode, 97 | outputs: { ...sourceNode.outputs, [output]: Array.from(sourceOutput) }, 98 | } 99 | 100 | const { id: targetId, input } = target 101 | const targetNode = nodes[targetId] 102 | nodes[targetId] = { 103 | ...targetNode, 104 | inputs: { ...targetNode.inputs, [input]: id }, 105 | } 106 | 107 | return { ...state, edges, nodes, focus: { element: "edge", id } } 108 | } else if (action.type === "edge/move") { 109 | const { id, target } = action 110 | const edges = { ...state.edges } 111 | const edge = edges[id] 112 | const { id: fromNodeId, input: fromInput } = edge.target 113 | 114 | const nodes = { ...state.nodes } 115 | const fromNode = nodes[fromNodeId] 116 | nodes[fromNodeId] = { 117 | ...fromNode, 118 | inputs: { ...fromNode.inputs, [fromInput]: null }, 119 | } 120 | 121 | const { id: toId, input: toInput } = target 122 | const toNode = nodes[toId] 123 | nodes[toId] = { ...toNode, inputs: { ...toNode.inputs, [toInput]: id } } 124 | 125 | edges[id] = { ...edge, target } 126 | 127 | return { ...state, edges, nodes, focus: { element: "edge", id } } 128 | } else if (action.type === "edge/delete") { 129 | const { id } = action 130 | const edges = { ...state.edges } 131 | const edge = edges[id] 132 | 133 | const nodes = { ...state.nodes } 134 | 135 | const { id: sourceId, output } = edge.source 136 | const sourceNode = nodes[sourceId] 137 | const sourceOutput = new Set(sourceNode.outputs[output]) 138 | sourceOutput.delete(id) 139 | nodes[sourceId] = { 140 | ...sourceNode, 141 | outputs: { ...sourceNode.outputs, [output]: Array.from(sourceOutput) }, 142 | } 143 | 144 | const { id: targetId, input } = edge.target 145 | const targetNode = nodes[targetId] 146 | nodes[targetId] = { 147 | ...targetNode, 148 | inputs: { ...targetNode.inputs, [input]: null }, 149 | } 150 | 151 | delete edges[id] 152 | 153 | const focus = isFocusEqual(state.focus, { element: "edge", id }) 154 | ? null 155 | : state.focus 156 | 157 | return { ...state, edges, nodes, focus } 158 | } else if (action.type === "focus") { 159 | if (action.subject !== null) { 160 | if (action.subject.element === "node") { 161 | if (!(action.subject.id in state.nodes)) { 162 | console.error(action.subject) 163 | throw new Error("Invalid focus node subject") 164 | } 165 | } else if (action.subject.element === "edge") { 166 | if (!(action.subject.id in state.edges)) { 167 | console.error(action.subject) 168 | throw new Error("Invalid focus edge subject") 169 | } 170 | } 171 | } 172 | return { ...state, focus: action.subject } 173 | } else { 174 | signalInvalidType(action) 175 | } 176 | } 177 | 178 | function createInitialNode( 179 | kinds: Kinds, 180 | id: string, 181 | kind: keyof S, 182 | position: Position 183 | ): Node { 184 | const inputs = Object.fromEntries( 185 | Object.keys(kinds[kind].inputs).map((input) => [input, null]) 186 | ) as Record, null | string> 187 | 188 | const outputs = Object.fromEntries( 189 | Object.keys(kinds[kind].outputs).map<[GetOutputs, string[]]>( 190 | (output) => [output, []] 191 | ) 192 | ) as Record, string[]> 193 | 194 | return { id, kind, position, inputs, outputs } 195 | } 196 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | export type Position = { x: number; y: number } 2 | 3 | export type Kind = Readonly<{ 4 | name: string 5 | inputs: Readonly> 6 | outputs: Readonly> 7 | backgroundColor: string 8 | }> 9 | 10 | export type Schema = Record 11 | 12 | export type GetInputs< 13 | S extends Schema, 14 | K extends keyof S = keyof S 15 | > = S[K]["inputs"] 16 | 17 | export type GetOutputs< 18 | S extends Schema, 19 | K extends keyof S = keyof S 20 | > = S[K]["outputs"] 21 | 22 | export type Kinds = { 23 | readonly [K in keyof S]: Kind, GetOutputs> 24 | } 25 | 26 | export type GetSchema> = { 27 | [k in keyof B]: B[k] extends Kind 28 | ? { inputs: I; outputs: O } 29 | : never 30 | } 31 | 32 | export type Node = { 33 | [k in K]: { 34 | id: string 35 | kind: k 36 | inputs: Record, null | string> 37 | outputs: Record, string[]> 38 | position: Position 39 | } 40 | }[K] 41 | 42 | export type Source = { 43 | id: string 44 | output: GetOutputs 45 | } 46 | 47 | export type Target = { 48 | id: string 49 | input: GetInputs 50 | } 51 | 52 | export type Edge< 53 | S extends Schema, 54 | SK extends keyof S = keyof S, 55 | TK extends keyof S = keyof S 56 | > = { 57 | id: string 58 | source: Source 59 | target: Target 60 | } 61 | 62 | export type Focus = 63 | | { element: "node"; id: string } 64 | | { element: "edge"; id: string } 65 | 66 | export type EditorState = { 67 | nodes: Record> 68 | edges: Record> 69 | focus: Focus | null 70 | } 71 | -------------------------------------------------------------------------------- /src/styles.ts: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from "react" 2 | import { CanvasContext } from "./context.js" 3 | 4 | import { Options } from "./options.js" 5 | 6 | export const getCanvasStyle = ({ 7 | borderColor, 8 | }: Options): React.CSSProperties => ({ 9 | borderColor, 10 | borderWidth: 1, 11 | borderStyle: "solid", 12 | width: "100%", 13 | overflowX: "scroll", 14 | }) 15 | 16 | export const getSVGStyle = ({ 17 | borderColor, 18 | unit, 19 | height, 20 | }: Options): React.CSSProperties => ({ 21 | backgroundImage: `radial-gradient(circle, ${borderColor} 1px, rgba(0, 0, 0, 0) 1px)`, 22 | backgroundSize: `${unit}px ${unit}px`, 23 | backgroundPositionX: `-${unit / 2}px`, 24 | backgroundPositionY: `-${unit / 2}px`, 25 | width: "100%", 26 | height: unit * height, 27 | userSelect: "none", 28 | }) 29 | 30 | export const defaultStyleContext = { getCanvasStyle, getSVGStyle } 31 | 32 | export const StyleContext = React.createContext(defaultStyleContext) 33 | 34 | export function useStyles() { 35 | const { options } = useContext(CanvasContext) 36 | const { getCanvasStyle, getSVGStyle } = useContext(StyleContext) 37 | return useMemo( 38 | () => ({ canvas: getCanvasStyle(options), svg: getSVGStyle(options) }), 39 | [] 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/target.ts: -------------------------------------------------------------------------------- 1 | import { quadtree } from "d3-quadtree" 2 | import { select } from "d3-selection" 3 | 4 | import type { Kinds, Schema, Target } from "./state.js" 5 | import type { CanvasContext } from "./context.js" 6 | import { getInputOffset, getNodeAttributes, place } from "./utils.js" 7 | 8 | export type DragTarget = { 9 | x: number 10 | y: number 11 | target: Target 12 | } 13 | 14 | export function getTargets( 15 | context: CanvasContext, 16 | kinds: Kinds, 17 | target?: Target 18 | ) { 19 | const targets: DragTarget[] = [] 20 | 21 | const nodes = select(context.nodesRef.current) 22 | for (const nodeElement of nodes.selectAll("g.node")) { 23 | const node = select(nodeElement) 24 | const { id, kind, position } = getNodeAttributes(node) 25 | const ports = node.selectAll("g.input") 26 | for (const portElement of ports) { 27 | const port = select(portElement) 28 | const input = port.attr("data-input") 29 | const hasValue = portElement.hasAttribute("data-value") 30 | const isCurrentTarget = 31 | target !== undefined && target.id === id && target.input === input 32 | if (!hasValue || isCurrentTarget) { 33 | const offset = getInputOffset(kinds, kind, input) 34 | const [x, y] = place(context, position, offset) 35 | targets.push({ target: { id, input }, x, y }) 36 | } 37 | } 38 | } 39 | 40 | return quadtree(targets, getX, getY) 41 | } 42 | 43 | export const getX = ({ x }: DragTarget) => x 44 | export const getY = ({ y }: DragTarget) => y 45 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { select, Selection } from "d3-selection" 2 | 3 | import type { 4 | Schema, 5 | Position, 6 | Kinds, 7 | GetInputs, 8 | GetOutputs, 9 | EditorState, 10 | Target, 11 | Source, 12 | Focus, 13 | Node, 14 | } from "./state.js" 15 | import type { CanvasContext } from "./context.js" 16 | 17 | export const nodeWidth = 156 18 | export const nodeMarginX = 4 19 | export const nodeHeaderHeight = 24 20 | export const portRadius = 12 21 | export const portMargin = 12 22 | export const portHeight = portRadius * 2 + portMargin * 2 23 | export const canvasPaddingRight = 480 24 | 25 | const inputPortArc = `a ${portRadius} ${portRadius} 0 0 1 0 ${2 * portRadius}` 26 | const inputPort = `v ${portMargin} ${inputPortArc} v ${portMargin}` 27 | 28 | export function signalInvalidType(type: never): never { 29 | console.error(type) 30 | throw new Error("Unexpected type") 31 | } 32 | 33 | export const initialEditorState = (): EditorState => ({ 34 | nodes: {}, 35 | edges: {}, 36 | focus: null, 37 | }) 38 | 39 | export function makeClipPath( 40 | kinds: Kinds, 41 | kind: keyof S 42 | ): string { 43 | const { inputs, outputs } = kinds[kind] 44 | const { length: inputCount } = Object.keys(inputs) 45 | const { length: outputCount } = Object.keys(outputs) 46 | 47 | const nodeHeight = 48 | nodeHeaderHeight + portHeight * Math.max(inputCount, outputCount) 49 | 50 | const path = [`M 0 0 V ${nodeHeaderHeight}`] 51 | 52 | for (let i = 0; i < inputCount; i++) { 53 | path.push(inputPort) 54 | } 55 | 56 | path.push(`V ${nodeHeight} H ${nodeWidth} V 0 Z`) 57 | 58 | return path.join(" ") 59 | } 60 | 61 | export function place( 62 | { options }: CanvasContext, 63 | { x, y }: Position, 64 | offset?: [number, number] 65 | ): [number, number] { 66 | if (offset === undefined) { 67 | return [x * options.unit, y * options.unit] 68 | } else { 69 | const [dx, dy] = offset 70 | return [x * options.unit + dx, y * options.unit + dy] 71 | } 72 | } 73 | 74 | export const toTranslate = ([x, y]: [number, number]) => `translate(${x}, ${y})` 75 | 76 | export function getPortOffsetY(index: number) { 77 | if (index === -1) { 78 | throw new Error("Invalid port offset index") 79 | } 80 | return nodeHeaderHeight + index * portHeight + portMargin + portRadius 81 | } 82 | 83 | const keyIndexCache = new WeakMap< 84 | Record, 85 | Record 86 | >() 87 | 88 | function getKeyIndex(ports: Record, name: string): number { 89 | const indices = keyIndexCache.get(ports) 90 | if (indices !== undefined) { 91 | return indices[name] 92 | } else { 93 | const keys = Object.keys(ports) 94 | const indices = Object.fromEntries(keys.map((key, index) => [key, index])) 95 | keyIndexCache.set(ports, indices) 96 | return indices[name] 97 | } 98 | } 99 | 100 | export const getInputIndex = ( 101 | kinds: Kinds, 102 | kind: K, 103 | input: GetInputs 104 | ) => getKeyIndex(kinds[kind].inputs, input) 105 | 106 | export const getOutputIndex = ( 107 | kinds: Kinds, 108 | kind: K, 109 | output: GetOutputs 110 | ) => getKeyIndex(kinds[kind].outputs, output) 111 | 112 | export function getInputOffset( 113 | kinds: Kinds, 114 | kind: K, 115 | input: GetInputs 116 | ): [number, number] { 117 | const index = getInputIndex(kinds, kind, input) 118 | return [0, getPortOffsetY(index)] 119 | } 120 | 121 | export function getOutputOffset( 122 | kinds: Kinds, 123 | kind: K, 124 | output: GetOutputs 125 | ): [number, number] { 126 | const index = getOutputIndex(kinds, kind, output) 127 | return [nodeWidth, getPortOffsetY(index)] 128 | } 129 | 130 | export function getSourcePosition( 131 | context: CanvasContext, 132 | kinds: Kinds, 133 | { id, output }: Source 134 | ) { 135 | const nodes = select(context.nodesRef.current) 136 | const node = nodes.select(`g.node[data-id="${id}"]`) 137 | const { kind, position } = getNodeAttributes(node) 138 | const offset = getOutputOffset(kinds, kind, output) 139 | return place(context, position, offset) 140 | } 141 | 142 | export function getTargetPosition( 143 | context: CanvasContext, 144 | kinds: Kinds, 145 | { id, input }: Target 146 | ) { 147 | const nodes = select(context.nodesRef.current) 148 | const node = nodes.select(`g.node[data-id="${id}"]`) 149 | const { kind, position } = getNodeAttributes(node) 150 | const offset = getInputOffset(kinds, kind, input) 151 | return place(context, position, offset) 152 | } 153 | 154 | export function* forInputs( 155 | kinds: Kinds, 156 | kind: keyof S 157 | ): Generator> { 158 | for (const input of Object.keys(kinds[kind].inputs)) { 159 | yield input 160 | } 161 | } 162 | 163 | export function* forOutputs( 164 | kinds: Kinds, 165 | kind: keyof S 166 | ): Generator> { 167 | for (const output of Object.keys(kinds[kind].outputs)) { 168 | yield output 169 | } 170 | } 171 | 172 | export const snap = ( 173 | { options }: CanvasContext, 174 | [x, y]: [number, number] 175 | ): Position => ({ 176 | x: Math.max(0, Math.round(x / options.unit)), 177 | y: Math.min(options.height - 1, Math.max(0, Math.round(y / options.unit))), 178 | }) 179 | 180 | const minCurveExtent = 104 181 | export function makeCurvePath( 182 | [x1, y1]: [number, number], 183 | [x2, y2]: [number, number] 184 | ): string { 185 | const dx = x2 - x1 186 | const mx = x1 + dx / 2 187 | const dy = y2 - y1 188 | const my = y1 + dy / 2 189 | const qx = x1 + Math.max(Math.min(minCurveExtent, Math.abs(dy / 2)), dx / 4) 190 | return `M ${x1} ${y1} Q ${qx} ${y1} ${mx} ${my} T ${x2} ${y2}` 191 | } 192 | 193 | export function getNodeAttributes( 194 | node: Selection 195 | ): { id: string; kind: string; position: Position } { 196 | const id = node.attr("data-id") 197 | const kind = node.attr("data-kind") 198 | const x = parseInt(node.attr("data-position-x")) 199 | const y = parseInt(node.attr("data-position-y")) 200 | 201 | return { id, kind, position: { x, y } } 202 | } 203 | 204 | export function getEdgeSource( 205 | edge: Selection 206 | ): { id: string; output: string; kind: string } { 207 | return { 208 | id: edge.attr("data-source-id"), 209 | output: edge.attr("data-source-output"), 210 | kind: edge.attr("data-source-kind"), 211 | } 212 | } 213 | 214 | export function getEdgeTarget( 215 | edge: Selection 216 | ): { id: string; input: string; kind: string } { 217 | return { 218 | id: edge.attr("data-target-id"), 219 | input: edge.attr("data-target-input"), 220 | kind: edge.attr("data-target-kind"), 221 | } 222 | } 223 | 224 | export function isFocusEqual(a: Focus | null, b: Focus | null): boolean { 225 | if (a === null && b === null) { 226 | return true 227 | } else if (a === null || b === null) { 228 | return false 229 | } else { 230 | return a.element === b.element && a.id === b.id 231 | } 232 | } 233 | 234 | export function getCanvasWidth( 235 | { options }: CanvasContext, 236 | nodes: Record> 237 | ) { 238 | const max = Object.values(nodes).reduce( 239 | (x, { position }) => Math.max(x, position.x), 240 | 0 241 | ) 242 | return canvasPaddingRight + options.unit * max 243 | } 244 | -------------------------------------------------------------------------------- /src/viewer/ReadonlyCanvas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useMemo } from "react" 2 | 3 | import type { EditorState, Kinds, Schema } from "../state.js" 4 | import { FocusAction } from "../actions.js" 5 | 6 | import { GraphNode } from "../Node.js" 7 | import { GraphEdge } from "../Edge.js" 8 | 9 | import { CanvasContext } from "../context.js" 10 | import { useStyles } from "../styles.js" 11 | import { getCanvasWidth } from "../utils.js" 12 | 13 | export interface ReadonlyCanvasProps { 14 | kinds: Kinds 15 | state: EditorState 16 | dispatch: (action: FocusAction) => void 17 | } 18 | 19 | export function Canvas(props: ReadonlyCanvasProps) { 20 | const context = useContext(CanvasContext) 21 | 22 | const styles = useStyles() 23 | 24 | const width = useMemo(() => { 25 | return getCanvasWidth(context, props.state.nodes) 26 | }, [props.state.nodes]) 27 | 28 | const handleBackgroundClick = useCallback( 29 | (_: React.MouseEvent) => { 30 | context.onFocus(null) 31 | }, 32 | [] 33 | ) 34 | 35 | const { borderColor, unit, height } = context.options 36 | const borderWidth = props.state.focus === null ? 1 : 0 37 | const boxShadow = `0 0 0 ${borderWidth}px ${borderColor}` 38 | 39 | return ( 40 |
41 | 47 | 54 | 55 | {Object.values(props.state.edges).map((edge) => ( 56 | 57 | key={edge.id} 58 | kinds={props.kinds} 59 | nodes={props.state.nodes} 60 | focus={props.state.focus} 61 | edge={edge} 62 | /> 63 | ))} 64 | 65 | 66 | {Object.values(props.state.nodes).map((node) => ( 67 | 68 | key={node.id} 69 | kinds={props.kinds} 70 | focus={props.state.focus} 71 | node={node} 72 | /> 73 | ))} 74 | 75 | 76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /src/viewer/Viewer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useRef } from "react" 2 | 3 | import { Canvas } from "./ReadonlyCanvas.js" 4 | 5 | import type { Kinds, EditorState, Schema } from "../state.js" 6 | 7 | import { focus, FocusAction } from "../actions.js" 8 | import { CanvasContext } from "../context.js" 9 | import { isFocusEqual } from "../utils.js" 10 | import { Options, defaultOptions } from "../options.js" 11 | 12 | export interface ViewerProps { 13 | kinds: Kinds 14 | state: EditorState 15 | dispatch: (action: FocusAction) => void 16 | options?: Partial 17 | } 18 | 19 | export function Viewer(props: ViewerProps) { 20 | const stateRef = useRef(props.state) 21 | stateRef.current = props.state 22 | 23 | const dispatch = useCallback( 24 | (action: FocusAction) => props.dispatch(action), 25 | [] 26 | ) 27 | 28 | const context = useMemo(() => { 29 | return { 30 | options: { ...defaultOptions, ...props.options }, 31 | nodesRef: { current: null }, 32 | edgesRef: { current: null }, 33 | svgRef: { current: null }, 34 | previewRef: { current: null }, 35 | onFocus: (subject) => { 36 | if (!isFocusEqual(stateRef.current.focus, subject)) { 37 | dispatch(focus(subject)) 38 | } 39 | }, 40 | } 41 | }, []) 42 | 43 | return ( 44 | 45 | 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /static/example-action-delete-edge.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeltg/react-dataflow-editor/809cf313bc30a19ebe198eb9291de4269b23e82b/static/example-action-delete-edge.gif -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "strict": true, 6 | "module": "ES6", 7 | "target": "ES2020", 8 | "sourceMap": false, 9 | "declaration": true, 10 | "alwaysStrict": true, 11 | "jsx": "react", 12 | "moduleResolution": "node", 13 | "esModuleInterop": true 14 | } 15 | } 16 | --------------------------------------------------------------------------------