├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .flowconfig ├── .gitignore ├── Gulpfile.js ├── README.md ├── docs ├── .nojekyll ├── README.md ├── _sidebar.md ├── edge.md ├── graph.md ├── graphstate.md ├── index.html ├── menustate.md ├── node.md └── pin.md ├── package.json └── src ├── edge.js ├── graph.css ├── graph.js ├── index.js ├── node.css ├── node.js ├── pin.js └── state.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ 4 | Gulpfile.js 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "env": { 5 | "browser": true, 6 | "node": true 7 | }, 8 | "globals": { 9 | "ReactClass": true, 10 | "SyntheticMouseEvent": true 11 | }, 12 | "rules": { 13 | "linebreak-style": ["error", "windows"], 14 | "indent": ["error", 4, { 15 | "SwitchCase": 1 16 | }], 17 | "no-underscore-dangle": ["off"], 18 | "no-plusplus": ["off"], 19 | "arrow-parens": ["error", "as-needed", { 20 | "requireForBlockBody": false 21 | }], 22 | 23 | "flowtype-errors/show-errors": ["error"], 24 | 25 | "react/jsx-first-prop-new-line": ["off"], 26 | "react/jsx-closing-bracket-location": ["off"], 27 | "react/jsx-indent": ["error", 4], 28 | "react/jsx-indent-props": ["error", 4], 29 | "react/jsx-filename-extension": ["error", { 30 | "extensions": [".js", ".jsx"] 31 | }] 32 | }, 33 | "plugins": [ 34 | "flowtype-errors", 35 | "flowtype", 36 | "import" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/config-chain/.* 3 | .*/node_modules/fbjs/.* 4 | .*/node_modules/npm/.* 5 | .*/git/.* 6 | 7 | [include] 8 | 9 | [libs] 10 | 11 | [options] 12 | esproposal.class_static_fields=enable 13 | esproposal.class_instance_fields=enable 14 | esproposal.export_star_as=enable 15 | module.name_mapper.extension='css' -> '/flow/CSSModule.js.flow' 16 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe 17 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue 18 | unsafe.enable_getters_and_setters=true 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | 4 | lib 5 | dist 6 | -------------------------------------------------------------------------------- /Gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const gulpUtil = require('gulp-util'); 5 | const babel = require('gulp-babel'); 6 | const del = require('del'); 7 | const cleanCSS = require('gulp-clean-css'); 8 | const derequire = require('gulp-derequire'); 9 | const flatten = require('gulp-flatten'); 10 | const runSequence = require('run-sequence'); 11 | const through = require('through2'); 12 | const webpackStream = require('webpack-stream'); 13 | 14 | const paths = { 15 | dist: 'dist', 16 | lib: 'lib', 17 | src: [ 18 | 'src/**/*.js' 19 | ], 20 | css: [ 21 | 'src/**/*.css', 22 | ], 23 | }; 24 | 25 | const buildDist = opts => { 26 | const webpackOpts = { 27 | debug: opts.debug, 28 | externals: { 29 | immutable: 'Immutable', 30 | react: 'React', 31 | 'react-dom': 'ReactDOM', 32 | }, 33 | module: { 34 | loaders: [{ 35 | test: /\.css$/, 36 | loaders: [ 37 | 'style-loader', 38 | 'css-loader?modules&&localIdentName=__react-graph-editor__[name]__[local]__[hash:base64:5]' 39 | ] 40 | }] 41 | }, 42 | plugins: [ 43 | new webpackStream.webpack.DefinePlugin({ 44 | 'process.env.NODE_ENV': JSON.stringify( 45 | opts.debug ? 'development' : 'production' 46 | ), 47 | }), 48 | new webpackStream.webpack.optimize.OccurenceOrderPlugin(), 49 | new webpackStream.webpack.optimize.DedupePlugin(), 50 | ], 51 | output: { 52 | filename: opts.output, 53 | libraryTarget: 'var', 54 | library: 'ReactGraph', 55 | } 56 | }; 57 | 58 | if (!opts.debug) { 59 | webpackOpts.plugins.push( 60 | new webpackStream.webpack.optimize.UglifyJsPlugin({ 61 | compress: { 62 | screw_ie8: true, 63 | warnings: false 64 | }, 65 | }) 66 | ); 67 | } 68 | 69 | return webpackStream(webpackOpts, null, (err, stats) => { 70 | if (err) { 71 | throw new gulpUtil.PluginError('webpack', err); 72 | } 73 | if (stats.compilation.errors.length) { 74 | gulpUtil.log('webpack', '\n' + stats.toString({colors: true})); 75 | } 76 | }); 77 | }; 78 | 79 | gulp.task('clean', () => 80 | del([paths.dist, paths.lib]) 81 | ); 82 | 83 | gulp.task('modules', () => 84 | gulp 85 | .src(paths.src) 86 | .pipe(babel()) 87 | .pipe(flatten()) 88 | .pipe(gulp.dest(paths.lib)) 89 | ); 90 | 91 | gulp.task('css', () => 92 | gulp 93 | .src(paths.css) 94 | .pipe(cleanCSS({ 95 | advanced: false 96 | })) 97 | .pipe(gulp.dest(paths.lib)) 98 | ); 99 | 100 | gulp.task('dist', ['modules', 'css'], () => 101 | gulp.src('./lib/index.js') 102 | .pipe(buildDist({ 103 | debug: true, 104 | output: 'ReactGraph.js', 105 | })) 106 | .pipe(derequire()) 107 | .pipe(gulp.dest(paths.dist)) 108 | ); 109 | 110 | gulp.task('dist:min', ['modules'], () => 111 | gulp.src('./lib/index.js') 112 | .pipe(buildDist({ 113 | debug: false, 114 | output: 'ReactGraph.min.js', 115 | })) 116 | .pipe(gulp.dest(paths.dist)) 117 | ); 118 | 119 | gulp.task('watch', () => { 120 | gulp.watch(paths.src, ['modules']); 121 | }); 122 | 123 | gulp.task('dev', () => { 124 | gulp.watch(paths.src, ['modules', 'dist']); 125 | }); 126 | 127 | gulp.task('default', cb => { 128 | runSequence('clean', 'modules', ['dist', 'dist:min'], cb); 129 | }); 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | React Graph Editor 2 | =============== 3 | 4 | Heavily based on [Draft.js](https://github.com/facebook/draft-js), React Graph Editor 5 | is a framework for building graph-based editors like the [Rasen 6 | Editor](https://github.com/leops/rasen-editor) or [Focus](https://github.com/leops/focus). 7 | 8 | # Example 9 | ```js 10 | import React from 'react'; 11 | import ReactDOM from 'react-dom'; 12 | import { 13 | Graph, 14 | GraphState, 15 | } from 'react-graph-editor'; 16 | 17 | class Editor extends React.Component { 18 | constructor(props) { 19 | super(props); 20 | this.state = {graph: GraphState.createEmpty()}; 21 | this.onChange = graph => this.setState({graph}); 22 | } 23 | 24 | render() { 25 | const {graph} = this.state; 26 | return ; 27 | } 28 | } 29 | 30 | ReactDOM.render( 31 | , 32 | document.getElementById('container') 33 | ); 34 | ``` 35 | 36 | # Docs 37 | See the [docs](https://github.com/leops/react-graph-editor/tree/master/docs) folder. 38 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leops/react-graph-editor/5b5c6ca5bd0da157cef865dbef4c17c055676bf9/docs/.nojekyll -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | React Graph Editor 2 | =============== 3 | 4 | Heavily based on [Draft.js](https://github.com/facebook/draft-js), React Graph Editor 5 | is a framework for building graph-based editors like the [Rasen 6 | Editor](https://github.com/leops/rasen-editor) or [Focus](https://github.com/leops/focus). 7 | 8 | # Example 9 | ```js 10 | import React from 'react'; 11 | import ReactDOM from 'react-dom'; 12 | import { 13 | Graph, 14 | GraphState, 15 | } from 'react-graph-editor'; 16 | 17 | class Editor extends React.Component { 18 | constructor(props) { 19 | super(props); 20 | this.state = {graph: GraphState.createEmpty()}; 21 | this.onChange = graph => this.setState({graph}); 22 | } 23 | 24 | render() { 25 | const {graph} = this.state; 26 | return ; 27 | } 28 | } 29 | 30 | ReactDOM.render( 31 | , 32 | document.getElementById('container') 33 | ); 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - [Home](/) 2 | - [Graph](/graph) 3 | - [GraphProps](/graph#graphprops) 4 | - [NodeProps](/graph#nodeprops) 5 | - [PinProps](/graph#pinprops) 6 | - [MenuProps](/graph#menuprops) 7 | - [GraphState](/graphstate) 8 | - [Construction](/graphstate#construction) 9 | - [Saving](/graphstate#saving) 10 | - [Nodes](/graphstate#nodes) 11 | - [Links](/graphstate#links) 12 | - [Menu](/graphstate#menu) 13 | - [Selection](/graphstate#selection) 14 | - [Undo](/graphstate#undo) 15 | - [Node](/node) 16 | - [Edge](/edge) 17 | - [Pin](/pin) 18 | - [MenuState](/menustate) 19 | -------------------------------------------------------------------------------- /docs/edge.md: -------------------------------------------------------------------------------- 1 | Edge 2 | =============== 3 | - `from: number`: 4 | The ID of the start node for this edge 5 | - `output: number`: 6 | The ID of the starting pin for this edge in `from` 7 | - `to: number`: 8 | The ID of the end node for this edge 9 | - `input: number`: 10 | The ID of the ending pin for this edge in `to` 11 | 12 | - `color: string`: 13 | A CSS string defining the color of this edge 14 | -------------------------------------------------------------------------------- /docs/graph.md: -------------------------------------------------------------------------------- 1 | Graph 2 | ============= 3 | 4 | `Graph` is the core component for the React Graph Editor module. It's properties 5 | are listed in the [GraphProps](/graph#graphprops) type. 6 | 7 | ## GraphProps 8 | - value: [GraphState](/graphstate): 9 | The current state of the editor 10 | - onChange: (nextState: [GraphState](/graphstate)) => void: 11 | A function called when user interacts with the editor, where `nextState` is 12 | the new state of the editor after said interaction 13 | 14 | - `className: string`: 15 | A CSS class applied to the root element of the graph 16 | 17 | - nodeClass: ReactClass<[NodeProps](/graph#nodeprops)>: 18 | The component class to be used for rendering the nodes 19 | - pinClass: ReactClass<[PinProps](/graph#pinprops)>: 20 | The component class to be used for rendering the pins 21 | - menuClass: ?ReactClass<[MenuProps](/graph#menuprops)>: 22 | If defined, the component class to be used for rendering the menu 23 | 24 | ## NodeProps 25 | - node: [Node](/node): 26 | The metadata of the node rendered by this component 27 | - `selected: boolean`: 28 | Whether this node is currently selected 29 | 30 | - `inputs: List`: 31 | A list of elements used to render the input pins of the node 32 | - `outputs: List`: 33 | A list of elements used to render the output pins of the node 34 | 35 | Note that the elements in the `inputs` and `outputs` arrays are not instances of 36 | your `pinClass`, but some internal react-graph-editor components instead. 37 | 38 | ## PinProps 39 | - pin: [Pin](/pin): 40 | The metadata of the node pin rendered by this component 41 | 42 | ## MenuProps 43 | - menu: [MenuState](/menustate): 44 | The state of this menu 45 | -------------------------------------------------------------------------------- /docs/graphstate.md: -------------------------------------------------------------------------------- 1 | GraphState 2 | ============= 3 | 4 | The `GraphState` holds the current "value" of the editor. This includes: 5 | - The state of the graph (nodes and edges) 6 | - The current selection 7 | - The undo / redo stack 8 | - The clipboard state 9 | 10 | ## Construction 11 | - `static createEmpty(): GraphState`: 12 | Create a new, empty editor 13 | - static fromGraph(nodes: Map<number, [Node](/node)>, edges: List<[Edge](/edge)>): GraphState: 14 | Create an editor from an existing graph (advanced) 15 | - `static restore(data: Object): GraphState`: 16 | Restore the editor from a previously serialized state 17 | 18 | ## Saving 19 | - `save(): Object`: 20 | Returns a POD, serializable version of the editor 21 | 22 | ## Nodes 23 | - `addNode(node: Object): GraphState`: 24 | Add a new node to the editor 25 | - `moveNode(id: number, x: number, y: number, pushUndo: ?boolean = false): GraphState`: 26 | Update a node's position 27 | - `mapNodes(fn: (node: Node) => Node): GraphState`: 28 | Runs a mapping function on all the nodes of the graph (advanced) 29 | 30 | ## Links 31 | - `addLink(from: number, output: number, to: number, input: number): GraphState`: 32 | Add a new link between two nodes 33 | 34 | ## Menu 35 | - `openMenu(x: number, y: number): GraphState`: 36 | Opens the menu 37 | - `closeMenu(): GraphState`: 38 | Closes the menu 39 | 40 | ## Selection 41 | - `selectNode(id: number, pushNode: boolean = false): GraphState`: 42 | Add a node to the current selection 43 | - get selectedNodes(): List<[Node](/node)>: 44 | Returns a list of the currently selected nodes 45 | - `isSelected(node: number): boolean`: 46 | Returns true if the node with id `node` is currently selected 47 | - `selectAll(): GraphState`: 48 | Add all the nodes in the graph to the current selection 49 | - `clearSelection(): GraphState`: 50 | Clear the current selection 51 | - `deleteSelection(): GraphState`: 52 | Delete all the currently selected nodes, and the edges connected to them 53 | - `cut(): GraphState`: 54 | Remove all the selected nodes and place them in the clipboard 55 | - `copy(): GraphState`: 56 | Place all the currently selected nodes and their connecting edges in the clipboard 57 | - `paste(): GraphState`: 58 | Add all the nodes and edges in the current clipboard to the graph, **automatically remapping all the conflicting node IDs** 59 | 60 | ## Undo 61 | - `undo(): GraphState`: 62 | Undo the last action, pushing it on the `redo` stack 63 | - `redo(): GraphState`: 64 | Redo the last action, pushing it on the `undo` stack 65 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-graph-editor-A React framework for building graph-based editors 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/menustate.md: -------------------------------------------------------------------------------- 1 | MenuState 2 | =============== 3 | - `open: boolean`: 4 | Whether the menu is open. 5 | You shouldn't normally have to read this value, as the Graph component will 6 | not render the Menu if it's closed. 7 | 8 | - `x: number`: 9 | The x coordinate of the Menu in the viewport 10 | - `y: number` 11 | The y coordinate of the Menu in the viewport 12 | 13 | - `maxWidth: number` 14 | The maximum width this Menu can expand to before overflowing the viewport 15 | - `maxHeight: number` 16 | The maximum height this Menu can expand to before overflowing the viewport 17 | -------------------------------------------------------------------------------- /docs/node.md: -------------------------------------------------------------------------------- 1 | Node 2 | =============== 3 | - `id: number`: 4 | A unique identifier for this node in the graph 5 | - `title: string`: 6 | The display name for this node 7 | 8 | - `data: Map`: 9 | An immutable Map you can use to store arbitrary metadata about this node 10 | 11 | - `x: number`: 12 | The x position of this node in the viewport 13 | - `y: number`: 14 | The y position of this node in the viewport 15 | - `width: number`: 16 | The measured width of this node element 17 | - `height: number`: 18 | The measured height of this node element 19 | 20 | - inputs: List<[Pin](/pin)>: 21 | The list of all the input pins of this node 22 | - outputs: List<[Pin](/pin)>: 23 | The list of all the output pins of this node 24 | -------------------------------------------------------------------------------- /docs/pin.md: -------------------------------------------------------------------------------- 1 | Pin 2 | =============== 3 | - `name: string`: 4 | The display name of this pin 5 | - `connected: boolean`: 6 | Whether this pin is connected to another one 7 | 8 | - `data: Map`: 9 | An immutable Map you can use to store arbitrary metadata about this pin 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-graph-editor", 3 | "version": "0.3.1", 4 | "description": "A React framework for building graph-based editors", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "dist/", 8 | "lib/" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/leops/react-graph-editor.git" 13 | }, 14 | "keywords": [ 15 | "graph", 16 | "react", 17 | "editor" 18 | ], 19 | "author": "leops", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/leops/react-graph-editor/issues" 23 | }, 24 | "homepage": "https://github.com/leops/react-graph-editor", 25 | "scripts": { 26 | "prepublish": "npm run build", 27 | "build": "gulp", 28 | "lint": "eslint .", 29 | "flow": "flow src" 30 | }, 31 | "devDependencies": { 32 | "babel-core": "^6.18.2", 33 | "babel-eslint": "^6.1.2", 34 | "babel-loader": "^6.2.7", 35 | "babel-preset-es2015": "^6.18.0", 36 | "babel-preset-react": "^6.16.0", 37 | "babel-preset-stage-0": "^6.16.0", 38 | "css-loader": "^0.25.0", 39 | "del": "^2.2.0", 40 | "envify": "^3.4.0", 41 | "es6-shim": "^0.34.4", 42 | "eslint": "^3.0.1", 43 | "eslint-config-airbnb": "^13.0.0", 44 | "eslint-plugin-babel": "^3.3.0", 45 | "eslint-plugin-flowtype": "^2.17.1", 46 | "eslint-plugin-flowtype-errors": "^1.5.0", 47 | "eslint-plugin-import": "^2.2.0", 48 | "eslint-plugin-jsx-a11y": "^2.2.3", 49 | "eslint-plugin-react": "^6.6.0", 50 | "flow-bin": "^0.32.0", 51 | "gulp": "^3.9.0", 52 | "gulp-babel": "^6.1.2", 53 | "gulp-browserify-thin": "^0.1.5", 54 | "gulp-clean-css": "^2.0.3", 55 | "gulp-derequire": "^2.1.0", 56 | "gulp-flatten": "^0.2.0", 57 | "gulp-uglify": "^1.2.0", 58 | "gulp-util": "^3.0.6", 59 | "jest": "^15.1.1", 60 | "postcss-loader": "^1.1.1", 61 | "react": "^15.4.0", 62 | "react-dom": "^15.4.0", 63 | "run-sequence": "^1.1.2", 64 | "style-loader": "^0.13.1", 65 | "through2": "^2.0.1", 66 | "vinyl-buffer": "^1.0.0", 67 | "webpack-stream": "^3.0.0" 68 | }, 69 | "dependencies": { 70 | "immutable": "^3.8.1", 71 | "react-measure": "^1.3.1" 72 | }, 73 | "peerDependencies": { 74 | "react": "^15.4.0", 75 | "react-dom": "^15.4.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/edge.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { 3 | Component, 4 | } from 'react'; 5 | 6 | import type { 7 | Node as NodeData, 8 | Edge as EdgeData, 9 | } from './state'; 10 | 11 | type EdgeProps = { 12 | edge: EdgeData, 13 | origin: NodeData, 14 | dest: NodeData, 15 | }; 16 | 17 | export default class Edge extends Component { 18 | shouldComponentUpdate(nextProps: EdgeProps) { 19 | return this.props.edge !== nextProps.edge || 20 | this.props.origin !== nextProps.origin || 21 | this.props.dest !== nextProps.dest; 22 | } 23 | 24 | props: EdgeProps; 25 | 26 | render() { 27 | const { output, input, color } = this.props.edge; 28 | const from = this.props.origin; 29 | const to = this.props.dest; 30 | 31 | if (from.minPin === Infinity || to.minPin === Infinity) { 32 | return null; 33 | } 34 | 35 | const start = { 36 | x: from.x + from.width, 37 | y: from.y + from.minPin + ((output + 0.5) * from.pinHeight), 38 | }; 39 | const end = { 40 | x: to.x, 41 | y: to.y + to.minPin + ((input + 0.5) * to.pinHeight), 42 | }; 43 | const delta = { 44 | x: end.x - start.x, 45 | y: end.y - start.y, 46 | }; 47 | 48 | const goingForward = delta.x >= 0.0; 49 | const tension = { 50 | x: Math.min(Math.abs(delta.x), goingForward ? 1000 : 200), 51 | y: Math.min(Math.abs(delta.y), goingForward ? 1000 : 200), 52 | }; 53 | 54 | const tangent = goingForward ? { 55 | x: (tension.x + tension.y) * 0.5, 56 | y: 0, 57 | } : { 58 | x: (tension.x * 1.5) + (tension.y * 0.75), 59 | y: 0, 60 | }; 61 | 62 | return ( 63 | 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/graph.css: -------------------------------------------------------------------------------- 1 | .graph { 2 | overflow: hidden; 3 | position: relative; 4 | } 5 | 6 | .graph > * { 7 | position: absolute; 8 | z-index: 2; 9 | left: 0; 10 | top: 0; 11 | } 12 | 13 | .graph > svg { 14 | z-index: 1; 15 | width: 100%; 16 | height: 100%; 17 | pointer-events: none; 18 | } 19 | -------------------------------------------------------------------------------- /src/graph.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { 3 | Component, 4 | } from 'react'; 5 | import { 6 | List, 7 | } from 'immutable'; 8 | import Measure from 'react-measure'; 9 | 10 | import Node from './node'; 11 | import Edge from './edge'; 12 | 13 | import { 14 | Node as NodeData, 15 | } from './state'; 16 | 17 | // eslint-disable-next-line no-duplicate-imports 18 | import type GraphState, { 19 | Pin as PinData, 20 | } from './state'; 21 | 22 | import { 23 | graph, 24 | scroll, 25 | } from './graph.css'; 26 | 27 | declare class SVGElement extends HTMLElement { 28 | getBBox: () => { 29 | x: number; 30 | y: number; 31 | width: number; 32 | height: number; 33 | }; 34 | } 35 | 36 | type Props = { 37 | className: string, 38 | style: Object, 39 | 40 | value: GraphState, 41 | onChange: (nextState: GraphState) => void, 42 | 43 | nodeClass: ReactClass, 44 | pinClass: ReactClass, 45 | menuClass: ?ReactClass 46 | }; 47 | 48 | type BatchedAction = { 49 | method: string, 50 | args: Array 51 | } 52 | 53 | function NOOP() {} 54 | 55 | const raf = ( 56 | window ? ( 57 | window.requestAnimationFrame || 58 | window.webkitRequestAnimationFrame || 59 | window.mozRequestAnimationFrame || 60 | window.msRequestAnimationFrame 61 | ) : null 62 | ) || (cb => setTimeout(cb, 16)); 63 | 64 | export default class Graph extends Component { 65 | constructor(props: Props) { 66 | super(props); 67 | 68 | this.__actionQueue = new List(); 69 | 70 | this.mouseDown = (evt: SyntheticMouseEvent) => { 71 | evt.preventDefault(); 72 | 73 | if (this.__graph) { 74 | let nextState = this.props.value; 75 | if (evt.target === this.__graph) { 76 | nextState = nextState.closeMenu(); 77 | } 78 | 79 | const { x, y } = this.getGraphCoords(evt); 80 | this.props.onChange( 81 | nextState._startMouse(evt.buttons, x, y), 82 | ); 83 | } 84 | }; 85 | 86 | this.mouseMove = (evt: SyntheticMouseEvent) => { 87 | if (this.__graph && this.props.value.mouseState.down) { 88 | evt.preventDefault(); 89 | evt.stopPropagation(); 90 | 91 | const { x, y } = this.getGraphCoords(evt); 92 | this.props.onChange( 93 | this.props.value._updateMouse(x, y), 94 | ); 95 | } 96 | }; 97 | 98 | this.mouseUp = (evt: SyntheticMouseEvent) => { 99 | if (this.props.value.mouseState.down) { 100 | evt.preventDefault(); 101 | evt.stopPropagation(); 102 | 103 | let nextState = this.props.value._endMouse(); 104 | if (this.props.value.mouseState.draggingEdge) { 105 | const { x, y } = this.getGraphCoords(evt); 106 | nextState = nextState.openMenu(x, y); 107 | } 108 | 109 | this.props.onChange(nextState); 110 | } 111 | }; 112 | 113 | this.contextMenu = (evt: SyntheticMouseEvent) => { 114 | if (process.env.NODE_ENV === 'development' && evt.shiftKey) { 115 | return; 116 | } 117 | 118 | evt.preventDefault(); 119 | 120 | const { x, y } = this.getGraphCoords(evt); 121 | this.props.onChange( 122 | this.props.value.openMenu(x, y), 123 | ); 124 | }; 125 | 126 | this.measureViewport = (rect: {width: number, height: number}) => { 127 | this.batchAction({ 128 | method: '_measureViewport', 129 | args: [rect.width, rect.height], 130 | }); 131 | }; 132 | 133 | this.measureNode = (id: number, width: number, height: number) => { 134 | this.batchAction({ 135 | method: '_measureNode', 136 | args: [id, width, height], 137 | }); 138 | }; 139 | 140 | this.measurePin = (id: number, y: number, height: number) => { 141 | this.batchAction({ 142 | method: '_measurePin', 143 | args: [id, y, height], 144 | }); 145 | }; 146 | 147 | this.clickNode = (id: number, evt: SyntheticMouseEvent) => { 148 | if (!this.isSelected(id)) { 149 | this.props.onChange( 150 | this.props.value 151 | .closeMenu() 152 | .selectNode(id, evt.ctrlKey), 153 | ); 154 | } 155 | }; 156 | 157 | this.moveNode = (id: number, x: number, y: number, final: ?boolean = false) => { 158 | this.props.onChange( 159 | this.props.value.moveNode(id, x, y, final), 160 | ); 161 | }; 162 | 163 | this.dragPin = (node: NodeData, pin: PinData, evt: SyntheticMouseEvent) => { 164 | if (this.__graph) { 165 | evt.preventDefault(); 166 | evt.stopPropagation(); 167 | 168 | const { x, y } = this.getGraphCoords(evt); 169 | this.props.onChange( 170 | this.props.value 171 | ._startMouse(evt.buttons, x, y) 172 | ._startConnection(node, pin), 173 | ); 174 | } 175 | }; 176 | 177 | this.dropPin = (node: NodeData, pin: PinData, evt: SyntheticMouseEvent) => { 178 | evt.preventDefault(); 179 | evt.stopPropagation(); 180 | 181 | this.props.onChange( 182 | this.props.value._endConnection(node, pin), 183 | ); 184 | }; 185 | } 186 | 187 | getGraphCoords(evt: SyntheticMouseEvent): {x: number, y: number} { 188 | return { 189 | x: evt.clientX - this.props.value.viewport.startX, 190 | y: evt.clientY - this.props.value.viewport.startY, 191 | }; 192 | } 193 | 194 | isSelected(node: NodeData) { 195 | return this.props.value.isSelected(node); 196 | } 197 | 198 | batchAction(action: BatchedAction) { 199 | if (this.__actionQueue.isEmpty()) { 200 | raf(() => { 201 | const currentQueue = this.__actionQueue; 202 | this.__actionQueue = this.__actionQueue.clear(); 203 | 204 | this.props.onChange( 205 | currentQueue.reduce((state, item) => 206 | state[item.method](...item.args) 207 | , this.props.value), 208 | ); 209 | }); 210 | } 211 | 212 | this.__actionQueue = this.__actionQueue.push(action); 213 | } 214 | 215 | props: Props; 216 | 217 | __graph: HTMLDivElement; 218 | __actionQueue: List; 219 | 220 | mouseDown: (evt: SyntheticMouseEvent) => void; 221 | mouseMove: (evt: SyntheticMouseEvent) => void; 222 | mouseUp: (evt: SyntheticMouseEvent) => void; 223 | contextMenu: (evt: SyntheticMouseEvent) => void; 224 | measureViewport: (rect: {width: number, height: number}) => void; 225 | measureNode: (id: number, width: number, height: number) => void; 226 | measurePin: (id: number, y: number, height: number) => void; 227 | clickNode: (id: number, evt: SyntheticMouseEvent) => void; 228 | moveNode: (id: number, x: number, y: number, final: ?boolean) => void; 229 | dragPin: (node: NodeData, pin: PinData, evt: SyntheticMouseEvent) => void; 230 | dropPin: (node: NodeData, pin: PinData, evt: SyntheticMouseEvent) => void; 231 | 232 | render() { 233 | const { 234 | nodeClass, pinClass, 235 | menuClass: MenuClass, 236 | className, style, 237 | } = this.props; 238 | const { 239 | editorState, 240 | mouseState, 241 | menuState, 242 | viewport 243 | } = this.props.value; 244 | const { 245 | nodes, edges, 246 | } = editorState; 247 | const { 248 | translateX, translateY, 249 | } = viewport; 250 | 251 | const dragLine = (() => { 252 | if (mouseState.down !== 1) { 253 | return null; 254 | } 255 | 256 | if (mouseState.draggingEdge) { 257 | return ( 258 | 267 | ); 268 | } 269 | 270 | const { 271 | minX, minY, 272 | maxX, maxY, 273 | } = mouseState.rect; 274 | 275 | return ( 276 | 280 | ); 281 | })(); 282 | 283 | return ( 284 | 285 |
{ 293 | this.__graph = elem; 294 | }}> 295 | 296 | 297 | 298 | {edges.map(edge => { 299 | const from = nodes.get(edge.from); 300 | const to = nodes.get(edge.to); 301 | 302 | return from && to && ( 303 | 307 | ); 308 | })} 309 | {dragLine} 310 | 311 | 312 | 313 |
316 | {nodes.map(node => ( 317 | 327 | )).toArray()} 328 | 329 | {MenuClass && menuState.open && } 330 |
331 |
332 |
333 | ); 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Graph from './graph'; 3 | import GraphState, { 4 | Pin, Node, 5 | Edge, MenuState, 6 | } from './state'; 7 | 8 | export { 9 | Graph, 10 | GraphState, 11 | Pin, Node, 12 | Edge, MenuState, 13 | }; 14 | -------------------------------------------------------------------------------- /src/node.css: -------------------------------------------------------------------------------- 1 | .node { 2 | display:flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /src/node.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { 3 | Component, 4 | } from 'react'; 5 | import Measure from 'react-measure'; 6 | 7 | import type { 8 | Node as NodeData, 9 | Pin as PinData, 10 | } from './state'; 11 | import Pin from './pin'; 12 | import styles from './node.css'; 13 | 14 | function NOOP() {} 15 | 16 | type NodeProps = { 17 | node: NodeData, 18 | nodeClass: ReactClass, 19 | pinClass: ReactClass, 20 | 21 | measureNode: (id: number, width: number, height: number) => void, 22 | measurePin: (id: number, y: number, height: number) => void, 23 | moveNode: (id: number, x: number, y: number, final: ?boolean) => void, 24 | mouseDown: (id: number, evt: SyntheticMouseEvent) => void, 25 | dragPin: (node: NodeData, pin: PinData, evt: SyntheticMouseEvent) => void, 26 | dropPin: (node: NodeData, pin: PinData, evt: SyntheticMouseEvent) => void, 27 | 28 | selected: boolean 29 | }; 30 | 31 | export default class Node extends Component { 32 | constructor(props: NodeProps) { 33 | super(props); 34 | 35 | this.mouseDown = (evt: SyntheticMouseEvent) => { 36 | evt.preventDefault(); 37 | evt.stopPropagation(); 38 | 39 | this.__nodeStartX = this.props.node.x; 40 | this.__nodeStartY = this.props.node.y; 41 | this.__mouseStartX = evt.clientX; 42 | this.__mouseStartY = evt.clientY; 43 | 44 | window.addEventListener('mousemove', this.mouseMove); 45 | window.addEventListener('mouseup', this.mouseUp); 46 | 47 | this.props.mouseDown(this.props.node.id, evt); 48 | }; 49 | 50 | this.mouseMove = (evt: SyntheticMouseEvent) => { 51 | evt.preventDefault(); 52 | 53 | this.props.moveNode( 54 | this.props.node.id, 55 | this.__nodeStartX + (evt.clientX - this.__mouseStartX), 56 | this.__nodeStartY + (evt.clientY - this.__mouseStartY), 57 | ); 58 | }; 59 | 60 | this.mouseUp = (evt: SyntheticMouseEvent) => { 61 | evt.preventDefault(); 62 | 63 | this.props.moveNode( 64 | this.props.node.id, 65 | this.__nodeStartX + (evt.clientX - this.__mouseStartX), 66 | this.__nodeStartY + (evt.clientY - this.__mouseStartY), 67 | true, 68 | ); 69 | 70 | window.removeEventListener('mousemove', this.mouseMove); 71 | window.removeEventListener('mouseup', this.mouseUp); 72 | }; 73 | 74 | this.measureNode = (rect: {width: number, height: number}) => { 75 | this.props.measureNode( 76 | this.props.node.id, 77 | rect.width, rect.height, 78 | ); 79 | }; 80 | 81 | this.measurePin = (y: number, height: number) => { 82 | this.props.measurePin( 83 | this.props.node.id, 84 | y, height, 85 | ); 86 | }; 87 | 88 | this.dragPin = (pin: PinData, evt: SyntheticMouseEvent) => { 89 | this.props.dragPin(this.props.node, pin, evt); 90 | }; 91 | 92 | this.dropPin = (pin: PinData, evt: SyntheticMouseEvent) => { 93 | this.props.dropPin(this.props.node, pin, evt); 94 | }; 95 | } 96 | 97 | shouldComponentUpdate(nextProps: NodeProps) { 98 | return this.props.node !== nextProps.node || 99 | this.props.dropPin !== nextProps.dropPin || 100 | this.props.selected !== nextProps.selected; 101 | } 102 | 103 | componentWillUnmount() { 104 | window.removeEventListener('mousemove', this.mouseMove); 105 | window.removeEventListener('mouseup', this.mouseUp); 106 | } 107 | 108 | props: NodeProps; 109 | 110 | mouseDown: (evt: SyntheticMouseEvent) => void; 111 | mouseMove: (evt: SyntheticMouseEvent) => void; 112 | mouseUp: (evt: SyntheticMouseEvent) => void; 113 | measureNode: (rect: {width: number, height: number}) => void; 114 | measurePin: (y: number, height: number) => void; 115 | dragPin: (pin: PinData, evt: SyntheticMouseEvent) => void; 116 | dropPin: (pin: PinData, evt: SyntheticMouseEvent) => void; 117 | 118 | __nodeStartX: number; 119 | __nodeStartY: number; 120 | __mouseStartX: number; 121 | __mouseStartY: number; 122 | 123 | render() { 124 | const NodeClass = this.props.nodeClass; 125 | 126 | return ( 127 | 128 |
132 | ( 136 | 0} 140 | onMeasure={this.measurePin} /> 141 | ))} 142 | outputs={this.props.node.outputs.map(pin => ( 143 | 0} 147 | onMeasure={this.measurePin} /> 148 | ))} /> 149 |
150 |
151 | ); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/pin.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { 3 | Component, 4 | } from 'react'; 5 | import Measure from 'react-measure'; 6 | 7 | import type { 8 | Pin as PinData, 9 | } from './state'; 10 | 11 | type PinProps = { 12 | pin: PinData, 13 | pinClass: ReactClass, 14 | 15 | shouldMeasure: bool, 16 | onMeasure: (y: number, height: number) => void, 17 | 18 | onDrag: (pin: PinData, evt: SyntheticMouseEvent) => void, 19 | onDrop: (pin: PinData, evt: SyntheticMouseEvent) => void 20 | }; 21 | 22 | export default class Pin extends Component { 23 | constructor(props: PinProps) { 24 | super(props); 25 | 26 | this.onMouseDown = (evt: SyntheticMouseEvent) => { 27 | this.props.onDrag(this.props.pin, evt); 28 | }; 29 | 30 | this.onMouseUp = (evt: SyntheticMouseEvent) => { 31 | this.props.onDrop(this.props.pin, evt); 32 | }; 33 | 34 | this.measure = (rect: {top: number, height: number}) => { 35 | this.props.onMeasure(rect.top, rect.height); 36 | }; 37 | } 38 | 39 | shouldComponentUpdate(nextProps: PinProps) { 40 | return this.props.pin !== nextProps.pin || 41 | this.props.shouldMeasure !== nextProps.shouldMeasure || 42 | this.props.onDrop !== nextProps.onDrop; 43 | } 44 | 45 | onMouseDown: (evt: SyntheticMouseEvent) => void; 46 | onMouseUp: (evt: SyntheticMouseEvent) => void; 47 | measure: (rect: {top: number, height: number}) => void; 48 | 49 | props: PinProps; 50 | 51 | render() { 52 | const PinClass = this.props.pinClass; 53 | 54 | return ( 55 | 56 |
59 | 60 |
61 |
62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/state.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { 3 | Iterable, fromJS, 4 | List, Stack, 5 | Map, Record, 6 | } from 'immutable'; 7 | 8 | 9 | type PinRecordType = { 10 | name: string, 11 | connected: boolean, 12 | data: Map, 13 | }; 14 | 15 | const defaultPin: PinRecordType = { 16 | name: '', 17 | connected: false, 18 | data: new Map(), 19 | }; 20 | 21 | export const Pin = Record(defaultPin); 22 | 23 | 24 | type NodeRecordType = { 25 | id: number, 26 | title: string, 27 | data: Map, 28 | 29 | x: number, 30 | y: number, 31 | width: number, 32 | height: number, 33 | 34 | minPin: number, 35 | pinHeight: number, 36 | 37 | inputs: List, 38 | outputs: List, 39 | }; 40 | 41 | const defaultNode: NodeRecordType = { 42 | id: 0, 43 | title: '', 44 | data: new Map(), 45 | 46 | x: 0, 47 | y: 0, 48 | width: 0, 49 | height: 0, 50 | 51 | minPin: Infinity, 52 | pinHeight: 0, 53 | 54 | inputs: new List(), 55 | outputs: new List(), 56 | }; 57 | 58 | export const Node = Record(defaultNode); 59 | 60 | 61 | type EdgeRecordType = { 62 | from: number, 63 | output: number, 64 | to: number, 65 | input: number, 66 | color: string, 67 | }; 68 | 69 | const defaultEdge: EdgeRecordType = { 70 | from: 0, 71 | output: 0, 72 | to: 0, 73 | input: 0, 74 | color: '#fff', 75 | }; 76 | 77 | export const Edge = Record(defaultEdge); 78 | 79 | 80 | type EditorStateRecordType = { 81 | nodeId: number, 82 | nodes: Map, 83 | edges: List, 84 | selection: List, 85 | }; 86 | 87 | const defaultEditorState: EditorStateRecordType = { 88 | nodeId: 0, 89 | nodes: new Map(), 90 | edges: new List(), 91 | selection: new List(), 92 | }; 93 | 94 | const EditorState = Record(defaultEditorState); 95 | 96 | 97 | type MouseStateRecordType = { 98 | down: number, 99 | x: number, 100 | y: number, 101 | startX: number, 102 | startY: number, 103 | 104 | node: ?Node, 105 | pin: ?Pin, 106 | }; 107 | 108 | const defaultMouseState: MouseStateRecordType = { 109 | down: 0, 110 | x: 0, 111 | y: 0, 112 | startX: 0, 113 | startY: 0, 114 | 115 | node: null, 116 | pin: null, 117 | }; 118 | 119 | type Rect = { 120 | minX: number, 121 | minY: number, 122 | maxX: number, 123 | maxY: number, 124 | }; 125 | 126 | class MouseState extends Record(defaultMouseState) { 127 | get draggingEdge(): boolean { 128 | return this.node !== null && this.pin !== null; 129 | } 130 | 131 | get rect(): Rect { 132 | return { 133 | minX: Math.min(this.startX, this.x), 134 | minY: Math.min(this.startY, this.y), 135 | maxX: Math.max(this.startX, this.x), 136 | maxY: Math.max(this.startY, this.y), 137 | }; 138 | } 139 | } 140 | 141 | 142 | type MenuStateRecordType = { 143 | open: boolean, 144 | 145 | x: number, 146 | y: number, 147 | 148 | maxWidth: number, 149 | maxHeight: number, 150 | }; 151 | 152 | const defaultMenuState: MenuStateRecordType = { 153 | open: false, 154 | 155 | x: 0, 156 | y: 0, 157 | 158 | maxWidth: 0, 159 | maxHeight: 0, 160 | }; 161 | 162 | export const MenuState = Record(defaultMenuState); 163 | 164 | 165 | type ClipboardRecordType = { 166 | nodes: Map, 167 | edges: List, 168 | }; 169 | 170 | const defaultClipboard: ClipboardRecordType = { 171 | nodes: new Map(), 172 | edges: new List(), 173 | }; 174 | 175 | export const Clipboard = Record(defaultClipboard); 176 | 177 | 178 | type ViewportRecordType = { 179 | height: number, 180 | width: number, 181 | 182 | startX: number, 183 | startY: number, 184 | 185 | translateX: number, 186 | translateY: number, 187 | }; 188 | 189 | const defaultViewport: ViewportRecordType = { 190 | height: 0, 191 | width: 0, 192 | 193 | startX: 0, 194 | startY: 0, 195 | 196 | translateX: 0, 197 | translateY: 0, 198 | }; 199 | 200 | export const Viewport = Record(defaultViewport); 201 | 202 | 203 | type GraphStateRecordType = { 204 | editorState: EditorState, 205 | 206 | undoStack: Stack, 207 | redoStack: Stack, 208 | 209 | mouseState: MouseState, 210 | menuState: MenuState, 211 | clipboard: Clipboard, 212 | viewport: Viewport, 213 | }; 214 | 215 | const defaultGraphState: GraphStateRecordType = { 216 | editorState: new EditorState(), 217 | 218 | undoStack: new Stack(), 219 | redoStack: new Stack(), 220 | 221 | mouseState: new MouseState(), 222 | menuState: new MenuState(), 223 | clipboard: new Clipboard(), 224 | viewport: new Viewport(), 225 | }; 226 | 227 | export default class GraphState extends Record(defaultGraphState) { 228 | static createEmpty(): GraphState { 229 | return new GraphState(); 230 | } 231 | 232 | static fromGraph(nodes: Map, edges: List): GraphState { 233 | return new GraphState({ 234 | editorState: new EditorState({ 235 | nodeId: nodes.keys().reduce((a, b) => Math.max(a, b), 0) + 1, 236 | nodes, edges, 237 | }), 238 | }); 239 | } 240 | 241 | static restore(data: Object): GraphState { 242 | return new GraphState({ 243 | editorState: fromJS(data, (key, value) => { 244 | switch (key) { 245 | case '': 246 | return new EditorState(value.toObject()); 247 | 248 | case 'selection': 249 | return value.toList(); 250 | 251 | case 'nodes': 252 | return value.toMap() 253 | .mapEntries(([k, v]) => ([ 254 | Number(k), 255 | new Node({ 256 | ...v.toObject(), 257 | width: defaultNode.width, 258 | height: defaultNode.height, 259 | minPin: defaultNode.minPin, 260 | pinHeight: defaultNode.pinHeight, 261 | }), 262 | ])); 263 | 264 | case 'edges': 265 | return value.toList() 266 | .map(edge => new Edge(edge.toObject())); 267 | 268 | case 'inputs': 269 | case 'outputs': 270 | return value.toList() 271 | .map(pin => new Pin(pin)); 272 | 273 | case 'data': 274 | return value.toMap(); 275 | 276 | default: { 277 | const isIndexed = Iterable.isIndexed(value); 278 | return isIndexed ? value.toList() : value.toMap(); 279 | } 280 | } 281 | }), 282 | }); 283 | } 284 | 285 | save(): Object { 286 | return this.editorState.toJS(); 287 | } 288 | 289 | addNode(node: Object): GraphState { 290 | const { x, y } = this.menuState; 291 | const id = this.editorState.nodeId; 292 | 293 | return this.__pushState( 294 | this.editorState.set( 295 | 'nodeId', id + 1, 296 | ).update( 297 | 'nodes', 298 | nodes => { 299 | const data = new Node({ // TODO: Deep convert 300 | ...node, 301 | id, x, y, 302 | }); 303 | 304 | return nodes.set(id, data); 305 | }, 306 | ), 307 | ); 308 | } 309 | 310 | _measureViewport(width: number, height: number): GraphState { 311 | return this.update( 312 | 'viewport', 313 | view => view 314 | .set('width', width) 315 | .set('height', height), 316 | ); 317 | } 318 | 319 | _measureNode(id: number, width: number, height: number): GraphState { 320 | return this.updateIn( 321 | ['editorState', 'nodes', id], 322 | node => node 323 | .set('width', width) 324 | .set('height', height), 325 | ); 326 | } 327 | 328 | _measurePin(id: number, y: number, height: number): GraphState { 329 | return this.updateIn( 330 | ['editorState', 'nodes', id], 331 | node => node 332 | .update('minPin', v => Math.min(v, y - node.y)) 333 | .set('pinHeight', height), 334 | ); 335 | } 336 | 337 | moveNode(id: number, x: number, y: number, pushUndo: ?boolean = false): GraphState { 338 | const nextState = this.isSelected(id) ? ( 339 | this.editorState.update( 340 | 'nodes', 341 | nodes => { 342 | const node = nodes.get(id); 343 | const deltaX = x - node.x; 344 | const deltaY = y - node.y; 345 | 346 | return nodes 347 | .map(n => { 348 | if (this.isSelected(n.id)) { 349 | return n 350 | .set('x', n.x + deltaX) 351 | .set('y', n.y + deltaY); 352 | } 353 | 354 | return n; 355 | }); 356 | }, 357 | ) 358 | ) : ( 359 | this.editorState 360 | .set('selection', new List([id])) 361 | .updateIn( 362 | ['nodes', id], 363 | node => node 364 | .set('x', x) 365 | .set('y', y), 366 | ) 367 | ); 368 | 369 | if (pushUndo) { 370 | return this.__pushState(nextState); 371 | } 372 | 373 | return this.set('editorState', nextState); 374 | } 375 | 376 | mapNodes(fn: (node: Node) => Node): GraphState { 377 | return this.updateIn( 378 | ['editorState', 'nodes'], 379 | nodes => nodes.map(fn), 380 | ); 381 | } 382 | 383 | _startConnection(node: Node, pin: Pin): GraphState { 384 | return this.update( 385 | 'mouseState', 386 | mouse => mouse 387 | .set('node', node.id) 388 | .set('pin', node.outputs.findKey(({ name }) => name === pin.name)), 389 | ); 390 | } 391 | 392 | _endConnection(node: Node, pin: Pin): GraphState { 393 | const from = this.mouseState.node; 394 | const output = this.mouseState.pin; 395 | 396 | const to = node.id; 397 | const input = node.inputs.findKey(({ name }) => name === pin.name); 398 | 399 | return this._endMouse() 400 | .addLink(from, output, to, input); 401 | } 402 | 403 | addLink(from: number, output: number, to: number, input: number): GraphState { 404 | return this.__pushState( 405 | this.editorState 406 | .update('nodes', nodes => 407 | nodes 408 | .updateIn( 409 | [from, 'outputs', output], 410 | pin => pin.set('connected', true), 411 | ) 412 | .updateIn( 413 | [to, 'inputs', input], 414 | pin => pin.set('connected', true), 415 | ), 416 | ) 417 | .update('edges', edges => 418 | edges 419 | .filter(edge => edge.to !== to || edge.input !== input) 420 | .push(new Edge({ 421 | id: edges.size, 422 | from, 423 | to, 424 | output, 425 | input, 426 | })), 427 | ), 428 | ); 429 | } 430 | 431 | openMenu(x: number, y: number): GraphState { 432 | return this.update( 433 | 'menuState', 434 | menu => menu 435 | .set('open', true) 436 | .set('x', x) 437 | .set('y', y) 438 | .set('maxWidth', this.viewport.width - (this.viewport.translateX + x)) 439 | .set('maxHeight', this.viewport.height - (this.viewport.translateY + y)), 440 | ); 441 | } 442 | 443 | closeMenu(): GraphState { 444 | return this.setIn( 445 | ['menuState', 'open'], 446 | false, 447 | ); 448 | } 449 | 450 | selectNode(id: number, pushNode: boolean = false): GraphState { 451 | return this.__pushState( 452 | this.editorState.update( 453 | 'selection', 454 | selection => { 455 | if (pushNode) { 456 | return selection.push(id); 457 | } 458 | 459 | return new List([id]); 460 | }, 461 | ), 462 | ); 463 | } 464 | 465 | get selectedNodes(): List { 466 | return this.editorState.selection.map(id => this.editorState.nodes.get(id)); 467 | } 468 | 469 | isSelected(node: number): boolean { 470 | return this.editorState.selection.find(id => id === node) !== undefined; 471 | } 472 | 473 | selectAll(): GraphState { 474 | return this.__pushState( 475 | this.editorState.set( 476 | 'selection', 477 | this.editorState.nodes.keySeq().toList(), 478 | ), 479 | ); 480 | } 481 | 482 | clearSelection(): GraphState { 483 | return this.editorState.selection.isEmpty() ? this : this.__pushState( 484 | this.editorState.update( 485 | 'selection', 486 | list => list.clear(), 487 | ), 488 | ); 489 | } 490 | 491 | deleteSelection(): GraphState { 492 | return this.__pushState( 493 | this.editorState.update( 494 | 'nodes', 495 | nodes => nodes.filter(node => !this.isSelected(node.id)), 496 | ).update( 497 | 'edges', 498 | edges => edges.filter(edge => this.editorState.selection.find( 499 | id => id === edge.from || id === edge.to, 500 | ) === undefined), 501 | ).update( 502 | 'selection', 503 | list => list.clear(), 504 | ), 505 | ); 506 | } 507 | 508 | cut(): GraphState { 509 | const nodes = this.editorState.nodes.reduce(({ clip, editor }, node, key) => { 510 | if (this.isSelected(node.id)) { 511 | return { 512 | clip: clip.set(key, node), 513 | editor, 514 | }; 515 | } 516 | 517 | return { 518 | clip, 519 | editor: editor.set(key, node), 520 | }; 521 | }, { 522 | clip: new Map(), 523 | editor: new Map(), 524 | }); 525 | 526 | const edges = this.editorState.nodes.reduce(({ clip, editor }, edge) => { 527 | const isSelected = this.editorState.selection.find( 528 | id => id === edge.from || id === edge.to, 529 | ) !== undefined; 530 | 531 | if (isSelected) { 532 | return { 533 | clip: clip.push(edge), 534 | editor, 535 | }; 536 | } 537 | 538 | return { 539 | clip, 540 | editor: editor.push(edge), 541 | }; 542 | }, { 543 | clip: new List(), 544 | editor: new List(), 545 | }); 546 | 547 | return this.__pushState( 548 | this.editorState.set( 549 | 'nodes', nodes.editor, 550 | ).set( 551 | 'edges', edges.editor, 552 | ).update( 553 | 'selection', 554 | list => list.clear(), 555 | ), 556 | ).update( 557 | 'clipboard', 558 | clip => clip.set( 559 | 'nodes', nodes.clip, 560 | ).set( 561 | 'edges', edges.clip, 562 | ), 563 | ); 564 | } 565 | 566 | copy(): GraphState { 567 | return this.update( 568 | 'clipboard', 569 | cb => cb.set( 570 | 'nodes', 571 | this.editorState.nodes.filter(node => 572 | this.isSelected(node.id), 573 | ), 574 | ).set( 575 | 'edges', 576 | this.editorState.edges.filter(edge => 577 | this.isSelected(edge.from) && this.isSelected(edge.to), 578 | ), 579 | ), 580 | ); 581 | } 582 | 583 | paste(): GraphState { 584 | const remapped = this.clipboard.nodes.reduce(({ nodes, mapping }, node) => { 585 | let id = node.id; 586 | while (nodes.has(id)) { 587 | id++; 588 | } 589 | 590 | return { 591 | nodes: nodes.set(id, node.set('id', id)), 592 | mapping: mapping.set(node.id, id), 593 | }; 594 | }, { 595 | nodes: this.editorState.nodes, 596 | mapping: new Map(), 597 | }); 598 | 599 | return this.__pushState( 600 | this.editorState 601 | .set('nodes', remapped.nodes) 602 | .update('edges', edges => 603 | this.clipboard.edges.reduce((list, edge) => 604 | list.push( 605 | edge.set( 606 | 'from', 607 | remapped.mapping.get(edge.from, edge.from), 608 | ).set( 609 | 'to', 610 | remapped.mapping.get(edge.to, edge.to), 611 | ), 612 | ) 613 | , edges), 614 | ), 615 | ); 616 | } 617 | 618 | _startMouse(button: number, x: number, y: number): GraphState { 619 | return this.update( 620 | 'mouseState', 621 | mouse => mouse 622 | .set('down', button) 623 | .set('startX', x) 624 | .set('startY', y), 625 | ).update( 626 | 'viewport', 627 | viewport => viewport 628 | .set('startX', viewport.translateX) 629 | .set('startY', viewport.translateY), 630 | )._updateMouse(x, y); 631 | } 632 | 633 | _updateMouse(x: number, y: number): GraphState { 634 | const nextState = this.update( 635 | 'mouseState', 636 | mouse => mouse.set('x', x).set('y', y), 637 | ).update( 638 | 'viewport', 639 | viewport => { 640 | if (this.mouseState.down !== 4) { 641 | return viewport; 642 | } 643 | 644 | return viewport 645 | .set('translateX', viewport.startX + (x - this.mouseState.startX)) 646 | .set('translateY', viewport.startY + (y - this.mouseState.startY)); 647 | }, 648 | ); 649 | 650 | const { 651 | node, pin, 652 | } = nextState.mouseState; 653 | 654 | if (node == null && pin == null) { 655 | const { 656 | minX, minY, 657 | maxX, maxY, 658 | } = nextState.mouseState.rect; 659 | 660 | return nextState.setIn( 661 | ['editorState', 'selection'], 662 | nextState.editorState.nodes 663 | .filter(n => 664 | n.x >= minX && 665 | (n.x + n.width) <= maxX && 666 | n.y >= minY && 667 | (n.y + n.height) <= maxY, 668 | ) 669 | .map(({ id }) => id), 670 | ); 671 | } 672 | 673 | return nextState; 674 | } 675 | 676 | _endMouse(): GraphState { 677 | let nextState = this; 678 | if (!this.mouseState.draggingEdge) { 679 | nextState = this.__pushState(this.editorState); 680 | } 681 | 682 | return nextState.update( 683 | 'mouseState', 684 | mouse => mouse 685 | .set('down', 0) 686 | .set('node', null) 687 | .set('pin', null), 688 | ).update( 689 | 'viewport', 690 | viewport => viewport 691 | .set('startX', viewport.translateX) 692 | .set('startY', viewport.translateY), 693 | ); 694 | } 695 | 696 | __pushState(nextState: EditorState): GraphState { 697 | return this.update( 698 | 'undoStack', 699 | stack => stack.push(this.editorState), 700 | ).set( 701 | 'editorState', 702 | nextState, 703 | ); 704 | } 705 | 706 | undo(): GraphState { 707 | if (this.undoStack.isEmpty()) { 708 | return this; 709 | } 710 | 711 | const prevState = this.undoStack.peek(); 712 | const currState = this.editorState; 713 | 714 | return this 715 | .set('editorState', prevState) 716 | .update('undoStack', stack => stack.pop()) 717 | .update('redoStack', stack => stack.push(currState)); 718 | } 719 | 720 | redo(): GraphState { 721 | if (this.redoStack.isEmpty()) { 722 | return this; 723 | } 724 | 725 | const currState = this.editorState; 726 | const nextState = this.redoStack.peek(); 727 | 728 | return this 729 | .set('editorState', nextState) 730 | .update('undoStack', stack => stack.push(currState)) 731 | .update('redoStack', stack => stack.pop()); 732 | } 733 | } 734 | --------------------------------------------------------------------------------