├── src ├── container │ ├── utils │ │ ├── validateLink.ts │ │ ├── mapValues.ts │ │ └── rotate.ts │ ├── index.ts │ ├── FlowChartWithState.tsx │ └── actions.ts ├── components │ ├── FlowChart │ │ ├── index.ts │ │ ├── utils │ │ │ └── grid.ts │ │ └── FlowChart.tsx │ ├── Ports │ │ ├── index.ts │ │ └── Ports.default.tsx │ ├── NodeInner │ │ ├── index.ts │ │ └── NodeInner.default.tsx │ ├── PortsGroup │ │ ├── index.ts │ │ └── PortsGroup.default.tsx │ ├── Node │ │ ├── index.ts │ │ ├── Node.default.tsx │ │ └── Node.wrapper.tsx │ ├── Port │ │ ├── index.ts │ │ ├── Port.default.tsx │ │ └── Port.wrapper.tsx │ ├── Link │ │ ├── variants │ │ │ ├── index.ts │ │ │ ├── RegularLink.tsx │ │ │ └── ArrowLink.tsx │ │ ├── utils │ │ │ ├── index.ts │ │ │ ├── getLinkPosition.ts │ │ │ └── generateCurvePath.ts │ │ ├── index.ts │ │ ├── Link.default.tsx │ │ └── Link.wrapper.tsx │ ├── Canvas │ │ ├── index.tsx │ │ ├── CanvasContext.ts │ │ ├── CanvasOuter.default.tsx │ │ ├── CanvasInner.default.tsx │ │ └── Canvas.wrapper.tsx │ └── index.ts ├── constants.ts ├── utils.ts ├── types │ ├── index.ts │ ├── generics.ts │ ├── config.ts │ ├── chart.ts │ └── functions.ts └── index.ts ├── docs ├── favicon.ico ├── main.0e8543e4e3ea607d4dde.bundle.js.map ├── runtime~main.0e8543e4e3ea607d4dde.bundle.js.map ├── vendors~main.0e8543e4e3ea607d4dde.bundle.js.map ├── main.63ee4b6a7d2174d2310e.bundle.js ├── index.html ├── runtime~main.e3851caad73f000648d3.bundle.js ├── runtime~main.0e8543e4e3ea607d4dde.bundle.js ├── iframe.html ├── vendors~main.0e8543e4e3ea607d4dde.bundle.js.LICENSE.txt └── sb_dll │ └── storybook_ui_dll.LICENCE ├── images ├── demo.gif └── demo.png ├── config └── storybook │ ├── addons.js │ ├── webpack.config.js │ └── config.js ├── typings.d.ts ├── stories ├── components │ ├── Message.tsx │ ├── index.ts │ ├── Content.tsx │ ├── Sidebar.tsx │ ├── Code.tsx │ ├── Page.tsx │ └── SidebarItem.tsx ├── InternalReactState.tsx ├── ReadonlyMode.tsx ├── SmartRouting.tsx ├── ConfigSnapToGrid.tsx ├── LinkWithArrowHead.tsx ├── SelectableMode.tsx ├── NodeReadonly.tsx ├── ExternalReactState.tsx ├── CustomCanvasOuter.tsx ├── ConfigValidateLink.tsx ├── utils │ └── throttleRender.tsx ├── CustomNode.tsx ├── CustomPort.tsx ├── Zoom.tsx ├── SelectedSidebar.tsx ├── CustomNodeInner.tsx ├── index.tsx ├── CustomLink.tsx ├── misc │ ├── exampleChartState.ts │ └── exampleReadonlyNodesChartState.ts ├── StressTest.tsx ├── LinkColors.tsx ├── CustomGraphTypes.tsx └── DragAndDropSidebar.tsx ├── tslint.json ├── .circleci └── config.yml ├── tsconfig.json ├── LICENSE ├── .gitignore ├── package.json ├── README.md └── CHANGELOG.md /src/container/utils/validateLink.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/FlowChart/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FlowChart' 2 | -------------------------------------------------------------------------------- /src/components/Ports/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Ports.default' 2 | -------------------------------------------------------------------------------- /src/components/NodeInner/index.ts: -------------------------------------------------------------------------------- 1 | export * from './NodeInner.default' 2 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const REACT_FLOW_CHART = 'react-flow-chart' 2 | -------------------------------------------------------------------------------- /src/components/PortsGroup/index.ts: -------------------------------------------------------------------------------- 1 | export * from './PortsGroup.default' 2 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrBlenny/react-flow-chart/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrBlenny/react-flow-chart/HEAD/images/demo.gif -------------------------------------------------------------------------------- /images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrBlenny/react-flow-chart/HEAD/images/demo.png -------------------------------------------------------------------------------- /src/components/Node/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Node.default' 2 | export * from './Node.wrapper' 3 | -------------------------------------------------------------------------------- /src/components/Port/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Port.default' 2 | export * from './Port.wrapper' 3 | -------------------------------------------------------------------------------- /src/components/Link/variants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RegularLink' 2 | export * from './ArrowLink' 3 | -------------------------------------------------------------------------------- /src/components/Link/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getLinkPosition' 2 | export * from './generateCurvePath' 3 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const noop = () => null 2 | 3 | export const identity = (val: T) => val 4 | -------------------------------------------------------------------------------- /config/storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-options/register' 2 | import '@storybook/addon-viewport/register' -------------------------------------------------------------------------------- /src/components/Link/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Link.default' 2 | export * from './Link.wrapper' 3 | export * from './utils' 4 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*'; 2 | 3 | declare module 'react-json-view' { 4 | const value: any; 5 | export = value; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './chart' 2 | export * from './config' 3 | export * from './functions' 4 | export * from './generics' 5 | -------------------------------------------------------------------------------- /src/container/index.ts: -------------------------------------------------------------------------------- 1 | import * as _actions from './actions' 2 | export const actions = _actions 3 | export * from './FlowChartWithState' 4 | -------------------------------------------------------------------------------- /src/components/Canvas/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './CanvasInner.default' 2 | export * from './CanvasOuter.default' 3 | export * from './Canvas.wrapper' 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components' 2 | export * from './container' 3 | export * from './types' 4 | export * from './constants' 5 | export * from './utils' 6 | -------------------------------------------------------------------------------- /docs/main.0e8543e4e3ea607d4dde.bundle.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"main.0e8543e4e3ea607d4dde.bundle.js","sources":["webpack:///main.0e8543e4e3ea607d4dde.bundle.js"],"mappings":"AAAA","sourceRoot":""} -------------------------------------------------------------------------------- /stories/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const Message = styled.div` 4 | margin: 10px 10px 0px; 5 | padding: 10px; 6 | line-height: 1.4em; 7 | ` 8 | -------------------------------------------------------------------------------- /stories/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Code' 2 | export * from './Content' 3 | export * from './Message' 4 | export * from './Page' 5 | export * from './Sidebar' 6 | export * from './SidebarItem' 7 | -------------------------------------------------------------------------------- /stories/components/Content.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const Content = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | flex: 1; 7 | overflow: hidden; 8 | ` 9 | -------------------------------------------------------------------------------- /docs/runtime~main.0e8543e4e3ea607d4dde.bundle.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"runtime~main.0e8543e4e3ea607d4dde.bundle.js","sources":["webpack:///runtime~main.0e8543e4e3ea607d4dde.bundle.js"],"mappings":"AAAA","sourceRoot":""} -------------------------------------------------------------------------------- /docs/vendors~main.0e8543e4e3ea607d4dde.bundle.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"vendors~main.0e8543e4e3ea607d4dde.bundle.js","sources":["webpack:///vendors~main.0e8543e4e3ea607d4dde.bundle.js"],"mappings":";AAAA","sourceRoot":""} -------------------------------------------------------------------------------- /docs/main.63ee4b6a7d2174d2310e.bundle.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[0],{1550:function(n,o,c){"use strict";c.r(o);c(1551),c(1554)},474:function(n,o,c){c(475),c(858),n.exports=c(1550)},614:function(n,o){}},[[474,1,2]]]); -------------------------------------------------------------------------------- /stories/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const Sidebar = styled.div` 4 | width: 300px; 5 | background: white; 6 | display: flex; 7 | flex-direction: column; 8 | flex-shrink: 0; 9 | ` 10 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Canvas' 2 | export * from './Node' 3 | export * from './NodeInner' 4 | export * from './Port' 5 | export * from './Ports' 6 | export * from './PortsGroup' 7 | export * from './Link' 8 | export * from './FlowChart' 9 | -------------------------------------------------------------------------------- /src/types/generics.ts: -------------------------------------------------------------------------------- 1 | export interface IPosition { 2 | x: number 3 | y: number 4 | } 5 | 6 | export interface ISize { 7 | width: number 8 | height: number 9 | } 10 | 11 | export interface IOffset { 12 | offsetLeft: number 13 | offsetTop: number 14 | } 15 | -------------------------------------------------------------------------------- /config/storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ config }) => { 2 | config.module.rules.push({ 3 | test: /\.(ts|tsx)$/, 4 | use: [ 5 | { 6 | loader: require.resolve('ts-loader'), 7 | }, 8 | ], 9 | }); 10 | config.resolve.extensions.push('.ts', '.tsx'); 11 | return config; 12 | }; -------------------------------------------------------------------------------- /src/components/Canvas/CanvasContext.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | // NB: always import CanvasContext directly from this file to prevent circular module imports 4 | // see https://github.com/facebook/react/issues/13969#issuecomment-433253469 5 | 6 | const CanvasContext = React.createContext({ offsetX: 0, offsetY: 0, zoomScale: 1 }) 7 | 8 | export default CanvasContext 9 | -------------------------------------------------------------------------------- /src/components/Link/utils/getLinkPosition.ts: -------------------------------------------------------------------------------- 1 | import { INode, IPosition } from '../../../' 2 | 3 | export const getLinkPosition = (node: INode, portId: string): IPosition => { 4 | const port = node.ports[portId] 5 | return { 6 | x: node.position.x + (port.position ? port.position.x : 0), 7 | y: node.position.y + (port.position ? port.position.y : 0), 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/container/utils/mapValues.ts: -------------------------------------------------------------------------------- 1 | export default function mapValues< 2 | Obj extends object, 3 | Res extends { [key in keyof Obj]: any } 4 | > (o: Obj, func: (value: Obj[keyof Obj]) => Res[keyof Obj]) { 5 | const res: Res = {} as any 6 | for (const key in o) { 7 | if (o.hasOwnProperty(key)) { 8 | res[key] = func(o[key]) 9 | } 10 | } 11 | return res 12 | } 13 | -------------------------------------------------------------------------------- /stories/InternalReactState.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FlowChartWithState } from '../src' 3 | import { Page } from './components' 4 | import { chartSimple } from './misc/exampleChartState' 5 | 6 | export const InternalReactState = () => { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /stories/ReadonlyMode.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FlowChartWithState } from '../src' 3 | import { Page } from './components' 4 | import { chartSimple } from './misc/exampleChartState' 5 | 6 | export const ReadonlyMode = () => { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /stories/SmartRouting.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FlowChartWithState } from "../src"; 3 | import { Page } from "./components"; 4 | import { chartSimple } from "./misc/exampleChartState"; 5 | 6 | export const SmartRouting = () => { 7 | return ( 8 | 9 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /stories/ConfigSnapToGrid.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FlowChartWithState } from '../src' 3 | import { Page } from './components' 4 | import { chartSimple } from './misc/exampleChartState' 5 | 6 | export const ConfigSnapToGridDemo = () => { 7 | return ( 8 | 9 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /config/storybook/config.js: -------------------------------------------------------------------------------- 1 | import { setOptions } from "@storybook/addon-options" 2 | import { configure } from "@storybook/react" 3 | 4 | setOptions({ 5 | hierarchySeparator: /\/|\./, 6 | hierarchyRootSeparator: /\|/, 7 | }) 8 | 9 | function requireAll(requireContext) { 10 | return requireContext.keys().map(requireContext); 11 | } 12 | 13 | function loadStories() { 14 | requireAll(require.context("../../stories", true, /\.tsx?$/)); 15 | } 16 | 17 | configure(loadStories, module); -------------------------------------------------------------------------------- /stories/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const Code = styled.pre` 4 | margin: 10px; 5 | padding: 10px; 6 | line-height: 1.4em; 7 | white-space: pre-wrap; 8 | font-family: Consolas, 'Andale Mono WT', 'Andale Mono', 'Lucida Console', 'Lucida Sans Typewriter', 'DejaVu Sans Mono', 9 | 'Bitstream Vera Sans Mono', 'Liberation Mono', 'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace; 10 | 11 | background-color: #eff0f1; 12 | overflow: auto; 13 | ` 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:latest", 4 | "tslint-config-standard", 5 | "tslint-react" 6 | ], 7 | "rules": { 8 | "semicolon": [true, "never"], 9 | "object-literal-sort-keys": false, 10 | "trailing-comma": [true, {"multiline": "always", "singleline": "never"}], 11 | "jsx-no-lambda": false, 12 | "jsx-no-multiline-js": false, 13 | "quotemark": [true, "single", "jsx-double"], 14 | "no-implicit-dependencies": [true, "dev"], 15 | "no-console": [false], 16 | "max-line-length": [true, 200] 17 | } 18 | } -------------------------------------------------------------------------------- /src/components/NodeInner/NodeInner.default.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { IConfig, INode } from '../../' 4 | 5 | export interface INodeInnerDefaultProps { 6 | className?: string 7 | config: IConfig 8 | node: INode 9 | } 10 | 11 | const Outer = styled.div` 12 | padding: 40px 30px; 13 | ` 14 | 15 | export const NodeInnerDefault = ({ node,className }: INodeInnerDefaultProps) => { 16 | return ( 17 | 18 |
Type: {node.type}
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:8.11.3 6 | working_directory: ~/repo 7 | 8 | steps: 9 | - checkout 10 | 11 | - restore_cache: 12 | keys: 13 | - v1-dependencies-{{ checksum "package-lock.json" }} 14 | - v1-dependencies- 15 | 16 | - run: npm i 17 | 18 | - save_cache: 19 | paths: 20 | - node_modules 21 | key: v1-dependencies-{{ checksum "package-lock.json" }} 22 | 23 | - run: npm run lint 24 | 25 | - run: npm run build:storybook 26 | -------------------------------------------------------------------------------- /src/container/utils/rotate.ts: -------------------------------------------------------------------------------- 1 | import { IPosition } from '../../../' 2 | 3 | // center = rotation center 4 | // current = current position 5 | // x, y = rotated positions 6 | // angle = angle of rotation 7 | export const rotate = (center: IPosition, current: IPosition, angle: number): IPosition => { 8 | const radians = (Math.PI / 180) * angle 9 | const cos = Math.cos(radians) 10 | const sin = Math.sin(radians) 11 | const x = (cos * (current.x - center.x)) + (sin * (current.y - center.y)) + center.x 12 | const y = (cos * (current.y - center.y)) - (sin * (current.x - center.x)) + center.y 13 | return { x, y } 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Canvas/CanvasOuter.default.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { IConfig } from '../../types' 3 | 4 | export interface ICanvasOuterDefaultProps { 5 | className?: string 6 | config: IConfig 7 | children: any 8 | ref?: React.Ref 9 | } 10 | 11 | export const CanvasOuterDefault = styled.div` 12 | position: relative; 13 | background-size: 20px 20px; 14 | background-color: rgba(0,0,0,0.08); 15 | background-image: 16 | linear-gradient(90deg,hsla(0,0%,100%,.2) 1px,transparent 0), 17 | linear-gradient(180deg,hsla(0,0%,100%,.2) 1px,transparent 0); 18 | width: 100%; 19 | overflow: hidden; 20 | cursor: not-allowed; 21 | ` as any 22 | -------------------------------------------------------------------------------- /src/components/Canvas/CanvasInner.default.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { IConfig, IOnCanvasClick } from '../../' 3 | 4 | export interface ICanvasInnerDefaultProps { 5 | className?: string 6 | config: IConfig 7 | children: any 8 | onClick: IOnCanvasClick 9 | tabIndex: number 10 | onKeyDown: (e: React.KeyboardEvent) => void 11 | onDrop: (e: React.DragEvent) => void 12 | onDragOver: (e: React.DragEvent) => void 13 | } 14 | 15 | export const CanvasInnerDefault = styled.div` 16 | position: relative; 17 | outline: 1px dashed rgba(0,0,0,0.1); 18 | width: 10000px; 19 | height: 10000px; 20 | cursor: move; 21 | ` as any 22 | -------------------------------------------------------------------------------- /stories/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled, { createGlobalStyle } from 'styled-components' 3 | 4 | const GlobalStyle = createGlobalStyle` 5 | body { 6 | margin: 0px; 7 | max-width: 100vw; 8 | max-height: 100vh; 9 | overflow: hidden; 10 | box-sizing: border-box; 11 | font-family: sans-serif; 12 | } 13 | 14 | *, :after, :before { 15 | box-sizing: inherit; 16 | } 17 | ` 18 | 19 | const PageContent = styled.div` 20 | display: flex; 21 | flex-direction: row; 22 | flex: 1; 23 | max-width: 100vw; 24 | max-height: 100vh; 25 | ` 26 | 27 | export const Page = ({ children }: { children: any}) => ( 28 | 29 | {children} 30 | 31 | 32 | ) 33 | -------------------------------------------------------------------------------- /stories/components/SidebarItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { INode, REACT_FLOW_CHART } from '../../src' 4 | 5 | const Outer = styled.div` 6 | padding: 20px 30px; 7 | font-size: 14px; 8 | background: white; 9 | cursor: move; 10 | ` 11 | 12 | export interface ISidebarItemProps { 13 | type: string, 14 | ports: INode['ports'], 15 | properties?: any, 16 | } 17 | 18 | export const SidebarItem = ({ type, ports, properties }: ISidebarItemProps) => { 19 | return ( 20 | { 23 | event.dataTransfer.setData(REACT_FLOW_CHART, JSON.stringify({ type, ports, properties })) 24 | } } 25 | > 26 | {type} 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist/src", 4 | "module": "commonjs", 5 | "target": "es5", 6 | "lib": ["es5", "es6", "es7", "es2017", "dom"], 7 | "sourceMap": true, 8 | "jsx": "react", 9 | "moduleResolution": "node", 10 | "rootDirs": ["src", "stories"], 11 | "baseUrl": "src", 12 | "forceConsistentCasingInFileNames": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "suppressImplicitAnyIndexErrors": true, 18 | "noUnusedLocals": true, 19 | "allowSyntheticDefaultImports": true, 20 | "experimentalDecorators": true, 21 | "allowJs": false, 22 | "declaration": true, 23 | "types": ["react"] 24 | }, 25 | "include": ["src/index.ts"], 26 | "exclude": ["node_modules", "build", "scripts", "config"] 27 | } -------------------------------------------------------------------------------- /stories/LinkWithArrowHead.tsx: -------------------------------------------------------------------------------- 1 | import { cloneDeep, mapValues } from 'lodash' 2 | import * as React from 'react' 3 | import { FlowChart } from '../src' 4 | import * as actions from '../src/container/actions' 5 | import { Page } from './components' 6 | import { chartSimple } from './misc/exampleChartState' 7 | 8 | export class LinkWithArrowHead extends React.Component { 9 | public state = cloneDeep(chartSimple) 10 | public render () { 11 | const chart = this.state 12 | const stateActions = mapValues(actions, (func: any) => 13 | (...args: any) => this.setState(func(...args))) as typeof actions 14 | 15 | return ( 16 | 17 | 24 | 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Node/Node.default.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components' 2 | import { IConfig, INode } from '../../' 3 | 4 | export interface INodeDefaultProps { 5 | className?: string 6 | config: IConfig 7 | node: INode 8 | children: any 9 | isSelected: boolean 10 | onClick: (e: React.MouseEvent) => void 11 | onDoubleClick: (e: React.MouseEvent) => void 12 | onMouseEnter: (e: React.MouseEvent) => void 13 | onMouseLeave: (e: React.MouseEvent) => void 14 | style?: object 15 | ref?: React.Ref 16 | } 17 | 18 | export const NodeDefault = styled.div` 19 | position: absolute; 20 | transition: 0.3s ease box-shadow, 0.3s ease margin-top; 21 | background: white; 22 | border-radius: 4px; 23 | min-width: 200px; 24 | ${(props) => props.isSelected && css` 25 | box-shadow: 0 10px 20px rgba(0,0,0,.1); 26 | margin-top: -2px 27 | ` 28 | } 29 | ` as any 30 | -------------------------------------------------------------------------------- /stories/SelectableMode.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FlowChartWithState } from '../src' 3 | import { Code, Content, Message, Page, Sidebar } from './components' 4 | import { chartSimple } from './misc/exampleChartState' 5 | 6 | export const SelectableMode = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Selectable mode allows you to select a node or link when the chart is in read only mode. 16 | 17 | 18 | 19 | You just need to pass selectable property to chart config. 20 | 21 | config = {JSON.stringify({ readonly: true, selectable: true }, null, 2)} 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /stories/NodeReadonly.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FlowChartWithState } from '../src' 3 | import { Code, Content, Message, Page, Sidebar } from './components' 4 | import { chartSimple } from './misc/exampleReadonlyNodesChartState' 5 | 6 | export const NodeReadonly = () => { 7 | const code = { 8 | id: 'node1', 9 | type: 'read-only', 10 | readonly: true, 11 | } 12 | 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | Read-only nodes cannot be moved or removed, but can be selected and linked to other nodes. 22 | 23 | 24 | 25 | You just need to add readonly property to node. 26 | 27 | {JSON.stringify(code, null, 2)} 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /stories/ExternalReactState.tsx: -------------------------------------------------------------------------------- 1 | import { cloneDeep, mapValues } from 'lodash' 2 | import * as React from 'react' 3 | import { FlowChart } from '../src' 4 | import * as actions from '../src/container/actions' 5 | import { Page } from './components' 6 | import { chartSimple } from './misc/exampleChartState' 7 | 8 | /** 9 | * State is external to the Element 10 | * 11 | * You could easily move this state to Redux or similar by creating your own callback actions. 12 | */ 13 | export class ExternalReactState extends React.Component { 14 | public state = cloneDeep(chartSimple) 15 | public render () { 16 | const chart = this.state 17 | const stateActions = mapValues(actions, (func: any) => 18 | (...args: any) => this.setState(func(...args))) as typeof actions 19 | 20 | return ( 21 | 22 | 26 | 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/types/config.ts: -------------------------------------------------------------------------------- 1 | import { IChart } from './chart' 2 | import { IOnLinkCompleteInput } from './functions' 3 | 4 | export interface IConfig { 5 | readonly?: boolean 6 | selectable?: boolean 7 | snapToGrid?: boolean 8 | smartRouting?: boolean 9 | showArrowHead?: boolean 10 | gridSize?: number 11 | validateLink?: (props: IOnLinkCompleteInput & { chart: IChart }) => boolean 12 | nodeProps?: any 13 | zoom?: IZoomConfig 14 | [key: string]: any 15 | } 16 | 17 | export interface IZoomConfig { 18 | transformEnabled?: boolean 19 | minScale?: number 20 | maxScale?: number 21 | pan?: { 22 | disabled?: boolean 23 | touchPadEnabled?: boolean, 24 | } 25 | wheel?: { 26 | disabled?: boolean 27 | step?: number 28 | wheelEnabled?: boolean 29 | touchPadEnabled?: boolean, 30 | } 31 | zoomIn?: { 32 | disabled?: boolean 33 | step?: number, 34 | } 35 | zoomOut?: { 36 | disabled?: boolean 37 | step?: number, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /stories/CustomCanvasOuter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { FlowChartWithState, ICanvasOuterDefaultProps } from '../src' 4 | import { Page } from './components' 5 | import { chartSimple } from './misc/exampleChartState' 6 | 7 | const CanvasOuterCustom = styled.div` 8 | position: relative; 9 | background-size: 10px 10px; 10 | background-color: #4f6791; 11 | background-image: 12 | linear-gradient(90deg,hsla(0,0%,100%,.1) 1px,transparent 0), 13 | linear-gradient(180deg,hsla(0,0%,100%,.1) 1px,transparent 0); 14 | width: 100%; 15 | height: 100%; 16 | overflow: hidden; 17 | cursor: not-allowed; 18 | ` as any 19 | 20 | export const CustomCanvasOuterDemo = () => { 21 | return ( 22 | 23 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/container/FlowChartWithState.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FlowChart, IChart, IConfig, IFlowChartComponents } from '../' 3 | import * as actions from './actions' 4 | import mapValues from './utils/mapValues' 5 | 6 | export interface IFlowChartWithStateProps { 7 | initialValue: IChart 8 | Components?: IFlowChartComponents 9 | config?: IConfig 10 | } 11 | 12 | /** 13 | * Flow Chart With State 14 | */ 15 | export class FlowChartWithState extends React.Component { 16 | public state: IChart 17 | private stateActions = mapValues(actions, (func: any) => 18 | (...args: any) => this.setState(func(...args))) 19 | 20 | constructor (props: IFlowChartWithStateProps) { 21 | super(props) 22 | this.state = props.initialValue 23 | } 24 | public render () { 25 | const { Components, config } = this.props 26 | 27 | return ( 28 | 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /stories/ConfigValidateLink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { FlowChartWithState } from '../src' 4 | import { Page } from './components' 5 | import { chartSimple } from './misc/exampleChartState' 6 | 7 | const Note = styled.div` 8 | position: absolute; 9 | left: 30px; 10 | top: 30px; 11 | padding: 20px; 12 | background: white; 13 | border-radius: 10px; 14 | border: 2px solid red; 15 | ` 16 | 17 | export const ConfigValidateLinkDemo = () => { 18 | return ( 19 | 20 | { 24 | // no links between same type nodes 25 | if (chart.nodes[fromNodeId].type === chart.nodes[toNodeId].type) return false 26 | return true 27 | }, 28 | } } 29 | /> 30 | Customise link validation. For example, only allow links between different Node Types 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Storybook
-------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 David Revay 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Storybook 9 | storybook-static 10 | build 11 | dist 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | -------------------------------------------------------------------------------- /stories/utils/throttleRender.tsx: -------------------------------------------------------------------------------- 1 | import { throttle } from 'lodash' 2 | import * as React from 'react' 3 | 4 | /** A little HOC to throttle component renders */ 5 | export const throttleRender = (wait: number, options?: any) => { 6 | return (ComponentToThrottle: any) => { 7 | return class Throttle extends React.Component { 8 | public throttledSetState: any 9 | constructor (props: any, context: any) { 10 | super(props, context) 11 | this.state = {} 12 | this.throttledSetState = throttle((nextState: any) => this.setState(nextState), wait, options) 13 | } 14 | public shouldComponentUpdate (nextProps: any, nextState: any) { 15 | return this.state !== nextState 16 | } 17 | public componentWillMount () { 18 | this.throttledSetState({ props: this.props }) 19 | } 20 | public componentWillReceiveProps (nextProps: any) { 21 | this.throttledSetState({ props: nextProps }) 22 | } 23 | public componentWillUnmount () { 24 | this.throttledSetState.cancel() 25 | } 26 | public render () { 27 | return 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Port/Port.default.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { IConfig, IPort } from '../../' 4 | 5 | export interface IPortDefaultProps { 6 | className?: string 7 | config: IConfig 8 | port: IPort 9 | isSelected: boolean 10 | isHovered: boolean 11 | isLinkSelected: boolean 12 | isLinkHovered: boolean 13 | } 14 | 15 | const PortDefaultOuter = styled.div` 16 | width: 24px; 17 | height: 24px; 18 | border-radius: 50%; 19 | background: white; 20 | cursor: pointer; 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | &:hover > div { 25 | background: cornflowerblue; 26 | } 27 | ` 28 | 29 | const PortDefaultInner = styled.div<{ active: boolean }>` 30 | width: 12px; 31 | height: 12px; 32 | border-radius: 50%; 33 | background: ${(props) => props.active ? 'cornflowerblue' : 'grey' }; 34 | cursor: pointer; 35 | ` 36 | 37 | export const PortDefault = ({ isLinkSelected, isLinkHovered, config, className }: IPortDefaultProps) => ( 38 | 39 | 42 | 43 | ) 44 | -------------------------------------------------------------------------------- /src/components/Link/Link.default.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { generateCurvePath, generateRightAnglePath, generateSmartPath, IConfig, ILink, IOnLinkClick, IOnLinkMouseEnter, IOnLinkMouseLeave, IPort, IPosition } from '../../' 3 | import { ArrowLink, RegularLink } from './variants' 4 | 5 | export interface ILinkDefaultProps { 6 | className?: string 7 | config: IConfig 8 | link: ILink 9 | startPos: IPosition 10 | endPos: IPosition 11 | fromPort: IPort 12 | toPort?: IPort 13 | onLinkMouseEnter: IOnLinkMouseEnter 14 | onLinkMouseLeave: IOnLinkMouseLeave 15 | onLinkClick: IOnLinkClick 16 | isHovered: boolean 17 | isSelected: boolean 18 | matrix?: number[][] 19 | } 20 | 21 | export const LinkDefault = (props: ILinkDefaultProps) => { 22 | const { config, startPos, endPos, fromPort, toPort, matrix } = props 23 | const points = config.smartRouting 24 | ? !!toPort && !!matrix 25 | ? generateSmartPath(matrix, startPos, endPos, fromPort, toPort) 26 | : generateRightAnglePath(startPos, endPos) 27 | : generateCurvePath(startPos, endPos) 28 | 29 | const linkColor: string = 30 | (fromPort.properties && fromPort.properties.linkColor) || 'cornflowerblue' 31 | 32 | const linkProps = { 33 | config, 34 | points, 35 | linkColor, 36 | startPos, 37 | endPos, 38 | ...props, 39 | } 40 | 41 | return config.showArrowHead 42 | ? 43 | : 44 | } 45 | -------------------------------------------------------------------------------- /src/components/PortsGroup/PortsGroup.default.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components' 2 | import { IConfig } from '../../' 3 | 4 | export interface IPortsGroupDefaultProps { 5 | className?: string 6 | config: IConfig 7 | side: 'top' | 'bottom' | 'left' | 'right' 8 | } 9 | 10 | export const PortsGroupDefault = styled.div` 11 | position: absolute; 12 | display: flex; 13 | justify-content: center; 14 | 15 | ${(props) => { 16 | if (props.side === 'top') { 17 | return css` 18 | min-width: 100%; 19 | left: 0; 20 | top: -12px; 21 | flex-direction: row; 22 | > div { 23 | margin: 0 3px; 24 | } 25 | ` 26 | } else if (props.side === 'bottom') { 27 | return css` 28 | min-width: 100%; 29 | left: 0; 30 | bottom: -12px; 31 | flex-direction: row; 32 | > div { 33 | margin: 0 3px; 34 | } 35 | ` 36 | } else if (props.side === 'left') { 37 | return css` 38 | min-height: 100%; 39 | top: 0; 40 | left: -12px; 41 | flex-direction: column; 42 | > div { 43 | margin: 3px 0; 44 | } 45 | ` 46 | } else { 47 | return css` 48 | min-height: 100%; 49 | top: 0; 50 | right: -12px; 51 | flex-direction: column; 52 | > div { 53 | margin: 3px 0; 54 | } 55 | ` 56 | } 57 | }} 58 | ` 59 | -------------------------------------------------------------------------------- /docs/runtime~main.e3851caad73f000648d3.bundle.js: -------------------------------------------------------------------------------- 1 | !function(e){function r(r){for(var n,l,f=r[0],i=r[1],a=r[2],c=0,s=[];c) => { 36 | if (node.type === 'output-only') { 37 | return ( 38 | 39 | {children} 40 | 41 | ) 42 | } else { 43 | return ( 44 | 45 | {children} 46 | 47 | ) 48 | } 49 | }) 50 | 51 | export const CustomNodeDemo = () => { 52 | return ( 53 | 54 | 60 | 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /stories/CustomPort.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { FlowChartWithState, IPortDefaultProps } from '../src' 4 | import { Page } from './components' 5 | import { chartSimple } from './misc/exampleChartState' 6 | 7 | const PortDefaultOuter = styled.div` 8 | width: 24px; 9 | height: 24px; 10 | background: cornflowerblue; 11 | cursor: pointer; 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | ` 16 | 17 | const PortCustom = (props: IPortDefaultProps) => ( 18 | 19 | { props.port.properties && props.port.properties.value === 'yes' && ( 20 | 21 | 22 | 23 | )} 24 | { props.port.properties && props.port.properties.value === 'no' && ( 25 | 26 | 27 | 28 | )} 29 | { !props.port.properties && ( 30 | 31 | 32 | 33 | )} 34 | 35 | ) 36 | 37 | export const CustomPortDemo = () => { 38 | return ( 39 | 40 | 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/types/chart.ts: -------------------------------------------------------------------------------- 1 | import { IPosition, ISize } from './generics' 2 | 3 | export type IChart< 4 | ChartProps = undefined, 5 | NodeProps = undefined, 6 | LinkProps = undefined, 7 | PortProps = undefined 8 | > = { 9 | offset: IPosition 10 | nodes: { 11 | [id: string]: INode; 12 | } 13 | links: { 14 | [id: string]: ILink; 15 | } 16 | scale: number 17 | /** System Temp */ 18 | selected: ISelectedOrHovered 19 | hovered: ISelectedOrHovered, 20 | } & (ChartProps extends undefined ? { 21 | properties?: any, 22 | } : { 23 | properties: ChartProps, 24 | }) 25 | 26 | export interface ISelectedOrHovered { 27 | type?: 'link' | 'node' | 'port' 28 | id?: string 29 | } 30 | 31 | export type INode = { 32 | id: string 33 | type: string 34 | position: IPosition 35 | orientation?: number 36 | readonly?: boolean 37 | ports: { 38 | [id: string]: IPort; 39 | } 40 | /** System Temp */ 41 | size?: ISize, 42 | } & (NodeProps extends undefined ? { 43 | properties?: any, 44 | } : { 45 | properties: NodeProps, 46 | }) 47 | 48 | export type IPort = { 49 | id: string 50 | type: string 51 | value?: string 52 | /** System Temp */ 53 | position?: IPosition, 54 | } & (PortProps extends undefined ? { 55 | properties?: any, 56 | } : { 57 | properties: PortProps, 58 | }) 59 | 60 | export type ILink = { 61 | id: string 62 | from: { 63 | nodeId: string 64 | portId: string, 65 | } 66 | to: { 67 | nodeId?: string; 68 | portId?: string; 69 | /** System Temp */ 70 | position?: IPosition; 71 | }, 72 | } & (LinkProps extends undefined ? { 73 | properties?: any, 74 | } : { 75 | properties: LinkProps, 76 | }) 77 | -------------------------------------------------------------------------------- /src/components/Link/variants/RegularLink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { IConfig, ILink, IOnLinkClick, IOnLinkMouseEnter, IOnLinkMouseLeave, IPosition } from '../../../' 3 | 4 | export interface IRegularLinkProps { 5 | className?: string 6 | points: string 7 | linkColor: string 8 | config: IConfig 9 | link: ILink 10 | startPos: IPosition 11 | endPos: IPosition 12 | onLinkMouseEnter: IOnLinkMouseEnter 13 | onLinkMouseLeave: IOnLinkMouseLeave 14 | onLinkClick: IOnLinkClick 15 | isHovered: boolean 16 | isSelected: boolean 17 | } 18 | 19 | export const RegularLink = ({ 20 | className, 21 | points, 22 | linkColor, 23 | config, 24 | link, 25 | startPos, 26 | endPos, 27 | onLinkMouseEnter, 28 | onLinkMouseLeave, 29 | onLinkClick, 30 | isHovered, 31 | isSelected, 32 | }: IRegularLinkProps) => { 33 | return ( 34 | 44 | 45 | {/* Main line */} 46 | 47 | {/* Thick line to make selection easier */} 48 | onLinkMouseEnter({ config, linkId: link.id })} 56 | onMouseLeave={() => onLinkMouseLeave({ config, linkId: link.id })} 57 | onClick={(e) => { 58 | onLinkClick({ config, linkId: link.id }) 59 | e.stopPropagation() 60 | }} 61 | /> 62 | 63 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /stories/Zoom.tsx: -------------------------------------------------------------------------------- 1 | import { cloneDeep, mapValues } from 'lodash' 2 | import * as React from 'react' 3 | import styled from 'styled-components' 4 | import { FlowChart } from '../src' 5 | import * as actions from '../src/container/actions' 6 | import { Content, Page, Sidebar } from './components' 7 | import { chartSimple } from './misc/exampleChartState' 8 | 9 | export const Message = styled.div` 10 | margin: 10px; 11 | padding: 10px; 12 | line-height: 1.4em; 13 | ` 14 | 15 | export const Button = styled.div` 16 | padding: 10px; 17 | background: cornflowerblue; 18 | color: white; 19 | border-radius: 3px; 20 | text-align: center; 21 | transition: 0.3s ease all; 22 | margin: 10px; 23 | cursor: pointer; 24 | &:hover { 25 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); 26 | } 27 | &:active { 28 | background: #5682d2; 29 | } 30 | ` 31 | 32 | export class Zoom extends React.Component { 33 | public state = cloneDeep(chartSimple) 34 | public render () { 35 | const chart = this.state 36 | const stateActions = mapValues(actions, (func: any) => (...args: any) => 37 | this.setState(func(...args)), 38 | ) as typeof actions 39 | 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | Current zoom: 48 | {chart.scale} 49 | 50 | 51 | 60 | 61 | 70 | 71 | 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /stories/SelectedSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { cloneDeep, mapValues } from 'lodash' 2 | import * as React from 'react' 3 | import styled from 'styled-components' 4 | import { FlowChart } from '../src' 5 | import * as actions from '../src/container/actions' 6 | import { Content, Page, Sidebar } from './components' 7 | import { chartSimple } from './misc/exampleChartState' 8 | 9 | const Message = styled.div` 10 | margin: 10px; 11 | padding: 10px; 12 | line-height: 1.4em; 13 | ` 14 | 15 | const Button = styled.div` 16 | padding: 10px 15px; 17 | background: cornflowerblue; 18 | color: white; 19 | border-radius: 3px; 20 | text-align: center; 21 | transition: 0.3s ease all; 22 | cursor: pointer; 23 | &:hover { 24 | box-shadow: 0 10px 20px rgba(0,0,0,.1); 25 | } 26 | &:active { 27 | background: #5682d2; 28 | } 29 | ` 30 | 31 | export class SelectedSidebar extends React.Component { 32 | public state = cloneDeep(chartSimple) 33 | public render () { 34 | const chart = this.state 35 | const stateActions = mapValues(actions, (func: any) => 36 | (...args: any) => this.setState(func(...args))) as typeof actions 37 | 38 | return ( 39 | 40 | 41 | 45 | 46 | 47 | { chart.selected.type 48 | ? 49 |
Type: {chart.selected.type}
50 |
ID: {chart.selected.id}
51 |
52 | {/* 53 | We can re-use the onDeleteKey action. This will delete whatever is selected. 54 | Otherwise, we have access to the state here so we can do whatever we want. 55 | */} 56 | 57 |
58 | : Click on a Node, Port or Link } 59 |
60 |
61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /stories/CustomNodeInner.tsx: -------------------------------------------------------------------------------- 1 | import { cloneDeep, mapValues } from 'lodash' 2 | import * as React from 'react' 3 | import styled from 'styled-components' 4 | import { FlowChart, INodeInnerDefaultProps } from '../src' 5 | import * as actions from '../src/container/actions' 6 | import { Page } from './components' 7 | import { chartSimple } from './misc/exampleChartState' 8 | 9 | const Outer = styled.div` 10 | padding: 30px; 11 | ` 12 | 13 | const Input = styled.input` 14 | padding: 10px; 15 | border: 1px solid cornflowerblue; 16 | width: 100%; 17 | ` 18 | 19 | /** 20 | * Create the custom component, 21 | * Make sure it has the same prop signature 22 | */ 23 | const NodeInnerCustom = ({ node, config }: INodeInnerDefaultProps) => { 24 | if (node.type === 'output-only') { 25 | return ( 26 | 27 |

Use Node inner to customise the content of the node

28 |
29 | ) 30 | } else { 31 | return ( 32 | 33 |

Add custom displays for each node.type

34 |

You may need to stop event propagation so your forms work.

35 |
36 | console.log(e)} 40 | onClick={(e) => e.stopPropagation()} 41 | onMouseUp={(e) => e.stopPropagation()} 42 | onMouseDown={(e) => e.stopPropagation()} 43 | /> 44 |
45 | ) 46 | } 47 | } 48 | 49 | export class CustomNodeInnerDemo extends React.Component { 50 | public state = cloneDeep(chartSimple) 51 | public render () { 52 | const chart = this.state 53 | const stateActions = mapValues(actions, (func: any) => 54 | (...args: any) => this.setState(func(...args))) as typeof actions 55 | 56 | return ( 57 | 58 | 65 | 66 | ) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mrblenny/react-flow-chart", 3 | "version": "0.0.14", 4 | "description": "A flexible, stateless flow chart library for react.", 5 | "main": "src/index.js", 6 | "repository": "git@github.com:MrBlenny/react-flow-chart.git", 7 | "author": "David Revay ", 8 | "license": "MIT", 9 | "devDependencies": { 10 | "@babel/core": "^7.1.2", 11 | "@storybook/addon-centered": "^5.3.18", 12 | "@storybook/addon-options": "5.3.18", 13 | "@storybook/addon-viewport": "5.3.18", 14 | "@storybook/react": "5.3.18", 15 | "@types/lodash": "^4.14.118", 16 | "@types/node": "10.12.0", 17 | "@types/pathfinding": "0.0.4", 18 | "@types/react": "^16.8.8", 19 | "@types/react-dom": "^16.8.4", 20 | "@types/styled-components": "^4.1.19", 21 | "@types/uuid": "^3.4.4", 22 | "@types/webpack": "4.4.17", 23 | "babel-loader": "^8.0.4", 24 | "lodash": "^4.17.15", 25 | "np": "^3.0.4", 26 | "react": "^16.8.4", 27 | "react-dom": "^16.8.5", 28 | "react-json-view": "^1.19.1", 29 | "styled-components": "^5.1.0", 30 | "ts-loader": "^5.2.2", 31 | "ts-node": "^7.0.1", 32 | "tslint": "^5.11.0", 33 | "tslint-config-standard": "^8.0.1", 34 | "tslint-react": "^3.6.0", 35 | "typescript": "^3.4.1", 36 | "webpack": "^4.21.0", 37 | "webpack-cli": "^3.1.2" 38 | }, 39 | "dependencies": { 40 | "pathfinding": "^0.4.18", 41 | "react-draggable": "^4.4.3", 42 | "react-resize-observer": "^1.1.1", 43 | "react-zoom-pan-pinch": "^1.6.1", 44 | "uuid": "^3.3.2" 45 | }, 46 | "peerDependencies": { 47 | "react": "^16.8.4", 48 | "react-dom": "^16.8.4", 49 | "styled-components": "^5.1.0" 50 | }, 51 | "scripts": { 52 | "start:storybook": "start-storybook -p 6006 -c config/storybook", 53 | "build:storybook": "build-storybook -c config/storybook -o docs", 54 | "build": "tsc && cp package.json dist/ && cp README.md ./dist", 55 | "test": "npm run lint", 56 | "lint": "tslint -p tsconfig.json -c tslint.json" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/Link/Link.wrapper.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { IConfig, ILink, INode, IOnLinkClick, IOnLinkMouseEnter, IOnLinkMouseLeave } from '../../' 3 | import { noop } from '../../utils' 4 | import { ILinkDefaultProps, LinkDefault } from './Link.default' 5 | import { getLinkPosition } from './utils' 6 | 7 | export interface ILinkWrapperProps { 8 | config: IConfig 9 | link: ILink 10 | isSelected: boolean 11 | isHovered: boolean 12 | fromNode: INode 13 | toNode: INode | undefined 14 | onLinkMouseEnter: IOnLinkMouseEnter 15 | onLinkMouseLeave: IOnLinkMouseLeave 16 | onLinkClick: IOnLinkClick 17 | Component?: React.FunctionComponent 18 | matrix?: number[][] 19 | } 20 | 21 | export const LinkWrapper = React.memo( 22 | ({ 23 | config, 24 | Component = LinkDefault, 25 | link, 26 | onLinkMouseEnter, 27 | onLinkMouseLeave, 28 | onLinkClick, 29 | isSelected, 30 | isHovered, 31 | fromNode, 32 | toNode, 33 | matrix, 34 | }: ILinkWrapperProps) => { 35 | const startPos = getLinkPosition(fromNode, link.from.portId) 36 | const fromPort = fromNode.ports[link.from.portId] 37 | 38 | const endPos = toNode && link.to.portId ? getLinkPosition(toNode, link.to.portId) : link.to.position 39 | const toPort = toNode && link.to.portId ? toNode.ports[link.to.portId] : undefined 40 | 41 | // Don't render the link yet if there is no end pos 42 | // This will occur if the link was just created 43 | if (!endPos) { 44 | return null 45 | } 46 | 47 | return ( 48 | 62 | ) 63 | }, 64 | ) 65 | -------------------------------------------------------------------------------- /src/components/Ports/Ports.default.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ResizeObserver from 'react-resize-observer' 3 | import { IConfig, INode, ISize, PortsGroupDefault } from '../../' 4 | 5 | export interface IPortsDefaultProps { 6 | className?: string 7 | config: IConfig 8 | node: INode 9 | children: Array> 10 | onResize: (size: ISize) => void 11 | } 12 | 13 | export const PortsDefault = ({ children, config, onResize, className }: IPortsDefaultProps) => { 14 | const [ top, setTop ] = React.useState(0) 15 | const [ bottom, setBottom ] = React.useState(0) 16 | const [ right, setRight ] = React.useState(0) 17 | const [ left, setLeft ] = React.useState(0) 18 | const [ width, setWidth ] = React.useState(0) 19 | const [ height, setHeight ] = React.useState(0) 20 | 21 | React.useEffect(() => { 22 | setWidth(Math.max(top, bottom)) 23 | }, [ top, bottom ]) 24 | 25 | React.useEffect(() => { 26 | setHeight(Math.max(left, right)) 27 | }, [ left, right ]) 28 | 29 | React.useEffect(() => { 30 | onResize({ width, height }) 31 | }, [ width, height, onResize ]) 32 | 33 | return ( 34 |
35 | 36 | { setTop(rect.width) }} /> 37 | {children.filter((child) => ['input', 'top'].includes(child.props.port.type))} 38 | 39 | 40 | { setBottom(rect.width) }} /> 41 | {children.filter((child) => ['output', 'bottom'].includes(child.props.port.type))} 42 | 43 | 44 | { setRight(rect.height) }} /> 45 | {children.filter((child) => ['right'].includes(child.props.port.type))} 46 | 47 | 48 | { setLeft(rect.height) }} /> 49 | {children.filter((child) => ['left'].includes(child.props.port.type))} 50 | 51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /stories/index.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react' 2 | import * as React from 'react' 3 | import { ConfigSnapToGridDemo } from './ConfigSnapToGrid' 4 | import { ConfigValidateLinkDemo } from './ConfigValidateLink' 5 | import { CustomCanvasOuterDemo } from './CustomCanvasOuter' 6 | import { CustomGraphTypes } from './CustomGraphTypes' 7 | import { CustomLinkDemo } from './CustomLink' 8 | import { CustomNodeDemo } from './CustomNode' 9 | import { CustomNodeInnerDemo } from './CustomNodeInner' 10 | import { CustomPortDemo } from './CustomPort' 11 | import { DragAndDropSidebar } from './DragAndDropSidebar' 12 | import { ExternalReactState } from './ExternalReactState' 13 | import { InternalReactState } from './InternalReactState' 14 | import { LinkColors } from './LinkColors' 15 | import { NodeReadonly } from './NodeReadonly' 16 | import { LinkWithArrowHead } from './LinkWithArrowHead' 17 | import { ReadonlyMode } from './ReadonlyMode' 18 | import { SelectableMode } from './SelectableMode' 19 | import { SelectedSidebar } from './SelectedSidebar' 20 | import { SmartRouting } from './SmartRouting' 21 | import { StressTestDemo } from './StressTest' 22 | import { Zoom } from './Zoom' 23 | 24 | storiesOf('State', module) 25 | .add('Internal React State', InternalReactState) 26 | .add('External React State', () => ) 27 | 28 | storiesOf('Custom Components', module) 29 | .add('Node Inner', () => ) 30 | .add('Node', CustomNodeDemo) 31 | .add('Port', CustomPortDemo) 32 | .add('Canvas Outer', CustomCanvasOuterDemo) 33 | .add('Canvas Link', () => ) 34 | .add('Link Colors', () => ) 35 | 36 | storiesOf('Stress Testing', module).add('default', StressTestDemo) 37 | 38 | storiesOf('Sidebar', module) 39 | .add('Drag and Drop', DragAndDropSidebar) 40 | .add('Selected Sidebar', () => ) 41 | 42 | storiesOf('Other Config', module) 43 | .add('Snap To Grid', ConfigSnapToGridDemo) 44 | .add('Link validation function', ConfigValidateLinkDemo) 45 | .add('Read only mode', ReadonlyMode) 46 | .add('Node read only', NodeReadonly) 47 | .add('Selectable Mode', SelectableMode) 48 | .add('Smart link routing', SmartRouting) 49 | .add('Zoom', () => ) 50 | .add('Type-safe properties', CustomGraphTypes) 51 | .add('Link arrow heads',() => ) 52 | -------------------------------------------------------------------------------- /src/components/FlowChart/utils/grid.ts: -------------------------------------------------------------------------------- 1 | const SCALE_FACTOR = 5 2 | export const MATRIX_PADDING = 5 3 | export const NODE_PADDING = 3 4 | 5 | const getEmptyMatrix = (width: number, height: number): number[][] => { 6 | 7 | const adjustedWidth = Math.ceil(width / (SCALE_FACTOR - 1)) + MATRIX_PADDING 8 | const adjustedHeight = Math.ceil(height / (SCALE_FACTOR - 1)) + MATRIX_PADDING 9 | 10 | const matrix = [] 11 | 12 | for (let i = 0; i < adjustedHeight; i++) { 13 | matrix.push(new Array(adjustedWidth).fill(0)) 14 | } 15 | 16 | return matrix 17 | } 18 | 19 | const getMatrixDimensions = (offset: { x: number, y: number }, nodeDimensions: any[]): { width: number, height: number } => { 20 | const defaultNodeSize = { width: 500, height: 500 } 21 | const dimensions = { width: 0, height: 0 } 22 | 23 | const offsetX = Math.max(offset.x, 0) 24 | const offsetY = Math.max(offset.y, 0) 25 | 26 | nodeDimensions.forEach((node) => { 27 | 28 | const size = node.size || defaultNodeSize 29 | 30 | const x = node.position.x + offsetX + size.width 31 | const y = node.position.y + offsetY + size.height 32 | 33 | if (x > dimensions.width) dimensions.width = x 34 | if (y > dimensions.height) dimensions.height = y 35 | 36 | }) 37 | 38 | return dimensions 39 | } 40 | 41 | export const getMatrix = (offset: { x: number, y: number }, nodeDimensions: any[]): number[][] => { 42 | const { width, height } = getMatrixDimensions(offset, nodeDimensions) 43 | const matrix = getEmptyMatrix(width, height) 44 | 45 | nodeDimensions.forEach((dimension) => { 46 | const { position } = dimension 47 | const defaultNodeSize = { width: 500, height: 500 } 48 | const size = dimension.size || defaultNodeSize 49 | 50 | const scaledSize = { 51 | width: Math.ceil(size.width / SCALE_FACTOR) + NODE_PADDING, 52 | height: Math.ceil(size.height / SCALE_FACTOR) + NODE_PADDING, 53 | } 54 | 55 | const scaledX = Math.ceil(position.x / SCALE_FACTOR) 56 | const scaledY = Math.ceil(position.y / SCALE_FACTOR) 57 | 58 | for (let x = Math.max(scaledX - NODE_PADDING, 0); x <= scaledX + scaledSize.width; x++) { 59 | for (let y = Math.max(scaledY - NODE_PADDING, 0); y <= scaledY + scaledSize.height; y++) { 60 | matrix[y][x] = 1 61 | } 62 | } 63 | }) 64 | 65 | return matrix 66 | } 67 | -------------------------------------------------------------------------------- /src/components/Link/variants/ArrowLink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { IConfig, ILink, IOnLinkClick, IOnLinkMouseEnter, IOnLinkMouseLeave, IPosition } from '../../../' 3 | import { getDirectional } from '../utils' 4 | 5 | export interface IArrowLinkProps { 6 | className?: string 7 | link: ILink 8 | config: IConfig 9 | linkColor: string 10 | points: string 11 | isHovered: boolean 12 | isSelected: boolean 13 | startPos: IPosition 14 | endPos: IPosition 15 | onLinkMouseEnter: IOnLinkMouseEnter 16 | onLinkMouseLeave: IOnLinkMouseLeave 17 | onLinkClick: IOnLinkClick 18 | } 19 | 20 | export const ArrowLink = ({ 21 | className, 22 | link, 23 | config, 24 | linkColor, 25 | points, 26 | isHovered, 27 | isSelected, 28 | startPos, 29 | endPos, 30 | onLinkMouseEnter, 31 | onLinkMouseLeave, 32 | onLinkClick, 33 | }: IArrowLinkProps) => { 34 | const { leftToRight, topToBottom, isHorizontal } = getDirectional( 35 | startPos, 36 | endPos, 37 | ) 38 | 39 | let markerKey = '' 40 | if ((leftToRight && isHorizontal) || (topToBottom && !isHorizontal)) { 41 | markerKey = 'markerEnd' 42 | } else if ((!leftToRight && isHorizontal) || !isHorizontal) { 43 | markerKey = 'markerStart' 44 | } 45 | 46 | const marker = { [markerKey]: `url(#arrowHead-${linkColor})` } 47 | 48 | return ( 49 | 59 | 60 | 68 | 69 | 70 | 71 | {/* Main line */} 72 | 79 | {/* Thick line to make selection easier */} 80 | onLinkMouseEnter({ config, linkId: link.id })} 88 | onMouseLeave={() => onLinkMouseLeave({ config, linkId: link.id })} 89 | onClick={(e) => { 90 | onLinkClick({ config, linkId: link.id }) 91 | e.stopPropagation() 92 | }} 93 | /> 94 | 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /stories/CustomLink.tsx: -------------------------------------------------------------------------------- 1 | import { cloneDeep, mapValues } from 'lodash' 2 | import * as React from 'react' 3 | import styled from 'styled-components' 4 | import { FlowChart, LinkDefault } from '../src' 5 | import * as actions from '../src/container/actions' 6 | import { Page } from './components' 7 | import { chartSimple } from './misc/exampleChartState' 8 | 9 | const Label = styled.div` 10 | position: absolute; 11 | ` 12 | 13 | const Button = styled.div` 14 | position: absolute; 15 | top: 0px; 16 | right: 0px; 17 | padding: 5px; 18 | height: 15px; 19 | width: 15px; 20 | transform: translate(50%, -50%); 21 | background: red; 22 | color: white; 23 | border-radius: 50%; 24 | transition: 0.3s ease all; 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | font-size: 10px; 29 | cursor: pointer; 30 | &:hover { 31 | box-shadow: 0 10px 20px rgba(0,0,0,.1); 32 | } 33 | ` 34 | 35 | const LabelContent = styled.div` 36 | padding: 5px 10px; 37 | background: cornflowerblue; 38 | color: white; 39 | border-radius: 5px; 40 | display: flex; 41 | align-items: center; 42 | justify-content: center; 43 | font-size: 10px; 44 | cursor: pointer; 45 | ` 46 | 47 | export class CustomLinkDemo extends React.Component { 48 | public state = cloneDeep(chartSimple) 49 | public render () { 50 | const chart = this.state 51 | const stateActions = mapValues(actions, (func: any) => 52 | (...args: any) => this.setState(func(...args))) as typeof actions 53 | 54 | return ( 55 | 56 | { 61 | const { startPos, endPos, onLinkClick, link } = props 62 | const centerX = startPos.x + (endPos.x - startPos.x) / 2 63 | const centerY = startPos.y + (endPos.y - startPos.y) / 2 64 | return ( 65 | <> 66 | 67 | 81 | 82 | ) 83 | }, 84 | }} 85 | /> 86 | 87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /stories/misc/exampleChartState.ts: -------------------------------------------------------------------------------- 1 | import { IChart } from '../../src' 2 | 3 | export const chartSimple: IChart = { 4 | offset: { 5 | x: 0, 6 | y: 0, 7 | }, 8 | scale: 1, 9 | nodes: { 10 | node1: { 11 | id: 'node1', 12 | type: 'output-only', 13 | position: { 14 | x: 300, 15 | y: 100, 16 | }, 17 | ports: { 18 | port1: { 19 | id: 'port1', 20 | type: 'output', 21 | properties: { 22 | value: 'yes', 23 | }, 24 | }, 25 | port2: { 26 | id: 'port2', 27 | type: 'output', 28 | properties: { 29 | value: 'no', 30 | }, 31 | }, 32 | }, 33 | }, 34 | node2: { 35 | id: 'node2', 36 | type: 'input-output', 37 | position: { 38 | x: 300, 39 | y: 300, 40 | }, 41 | ports: { 42 | port1: { 43 | id: 'port1', 44 | type: 'input', 45 | }, 46 | port2: { 47 | id: 'port2', 48 | type: 'output', 49 | }, 50 | }, 51 | }, 52 | node3: { 53 | id: 'node3', 54 | type: 'input-output', 55 | position: { 56 | x: 100, 57 | y: 600, 58 | }, 59 | ports: { 60 | port1: { 61 | id: 'port1', 62 | type: 'input', 63 | }, 64 | port2: { 65 | id: 'port2', 66 | type: 'output', 67 | }, 68 | }, 69 | }, 70 | node4: { 71 | id: 'node4', 72 | type: 'input-output', 73 | position: { 74 | x: 500, 75 | y: 600, 76 | }, 77 | ports: { 78 | port1: { 79 | id: 'port1', 80 | type: 'input', 81 | }, 82 | port2: { 83 | id: 'port2', 84 | type: 'output', 85 | }, 86 | }, 87 | }, 88 | }, 89 | links: { 90 | link1: { 91 | id: 'link1', 92 | from: { 93 | nodeId: 'node1', 94 | portId: 'port2', 95 | }, 96 | to: { 97 | nodeId: 'node2', 98 | portId: 'port1', 99 | }, 100 | properties: { 101 | label: 'example link label', 102 | }, 103 | }, 104 | link2: { 105 | id: 'link2', 106 | from: { 107 | nodeId: 'node2', 108 | portId: 'port2', 109 | }, 110 | to: { 111 | nodeId: 'node3', 112 | portId: 'port1', 113 | }, 114 | properties: { 115 | label: 'another example link label', 116 | }, 117 | }, 118 | link3: { 119 | id: 'link3', 120 | from: { 121 | nodeId: 'node2', 122 | portId: 'port2', 123 | }, 124 | to: { 125 | nodeId: 'node4', 126 | portId: 'port1', 127 | }, 128 | }, 129 | }, 130 | selected: {}, 131 | hovered: {}, 132 | } 133 | -------------------------------------------------------------------------------- /docs/runtime~main.0e8543e4e3ea607d4dde.bundle.js: -------------------------------------------------------------------------------- 1 | !function(modules){function webpackJsonpCallback(data){for(var moduleId,chunkId,chunkIds=data[0],moreModules=data[1],executeModules=data[2],i=0,resolves=[];i { 8 | const xyGrid = flatten(range(0, cols * 300, 300).map((x) => range(0, rows * 150, 150).map((y) => ({ x, y })))) 9 | 10 | return { 11 | offset: { 12 | x: 0, 13 | y: 0, 14 | }, 15 | scale: 1, 16 | nodes: keyBy(xyGrid.map(({ x, y }) => ({ 17 | id: `node-${x}-${y}`, 18 | type: 'default', 19 | position: { x: x + 100, y: y + 100 }, 20 | ports: { 21 | port1: { 22 | id: 'port1', 23 | type: 'top', 24 | }, 25 | port2: { 26 | id: 'port2', 27 | type: 'bottom', 28 | }, 29 | port3: { 30 | id: 'port3', 31 | type: 'right', 32 | }, 33 | port4: { 34 | id: 'port4', 35 | type: 'left', 36 | }, 37 | }, 38 | })), 'id'), 39 | links: keyBy(compact(flatMap(xyGrid, ({ x, y }, idx) => { 40 | const next = xyGrid[idx + 1] 41 | if (next) { 42 | return [{ 43 | id: `link-${x}-${y}-a`, 44 | from: { 45 | nodeId: `node-${x}-${y}`, 46 | portId: 'port2', 47 | }, 48 | to: { 49 | nodeId: `node-${next.x}-${next.y}`, 50 | portId: 'port3', 51 | }, 52 | }, { 53 | id: `link-${x}-${y}-b`, 54 | from: { 55 | nodeId: `node-${x}-${y}`, 56 | portId: 'port2', 57 | }, 58 | to: { 59 | nodeId: `node-${next.x}-${next.y}`, 60 | portId: 'port4', 61 | }, 62 | }] 63 | } 64 | return undefined 65 | })), 'id') as any, 66 | selected: {}, 67 | hovered: {}, 68 | } 69 | } 70 | 71 | const StressTestWithState = () => { 72 | const [rows, setRows] = React.useState(100) 73 | const [cols, setCols] = React.useState(100) 74 | 75 | const chart = React.useMemo(() => getChart(rows, cols), [rows, cols]) 76 | 77 | return ( 78 | <> 79 | 80 | 81 | setCols(parseInt(e.target.value, 10))} /> 82 | 83 | setRows(parseInt(e.target.value, 10))} /> 84 | 85 | 86 | 87 | 88 | 89 | ) 90 | } 91 | 92 | export const StressTestDemo = () => { 93 | return 94 | } 95 | 96 | const Input = styled.input` 97 | padding: 5px 5px 5px 10px; 98 | width: 50px; 99 | ` 100 | 101 | const Label = styled.label` 102 | padding: 0 10px; 103 | font-size: 14px; 104 | ` 105 | 106 | const Controls = styled.div` 107 | padding: 10px; 108 | ` 109 | -------------------------------------------------------------------------------- /stories/LinkColors.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { FlowChartWithState, IChart } from '../src' 4 | import { Page } from './components' 5 | 6 | const chartSimpleWithLinkColors: IChart = { 7 | offset: { 8 | x: 0, 9 | y: 0, 10 | }, 11 | scale: 1, 12 | nodes: { 13 | node1: { 14 | id: 'node1', 15 | type: 'output-only', 16 | position: { 17 | x: 300, 18 | y: 100, 19 | }, 20 | ports: { 21 | port1: { 22 | id: 'port1', 23 | type: 'output', 24 | properties: { 25 | value: 'no', 26 | linkColor: '#FFCC00', 27 | }, 28 | }, 29 | }, 30 | }, 31 | node2: { 32 | id: 'node2', 33 | type: 'input-output', 34 | position: { 35 | x: 300, 36 | y: 300, 37 | }, 38 | ports: { 39 | port1: { 40 | id: 'port1', 41 | type: 'input', 42 | }, 43 | port2: { 44 | id: 'port2', 45 | type: 'output', 46 | properties: { 47 | linkColor: '#63D471', 48 | }, 49 | }, 50 | port3: { 51 | id: 'port3', 52 | type: 'output', 53 | properties: { 54 | linkColor: '#F8333C', 55 | }, 56 | }, 57 | }, 58 | }, 59 | node3: { 60 | id: 'node3', 61 | type: 'input-output', 62 | position: { 63 | x: 100, 64 | y: 600, 65 | }, 66 | ports: { 67 | port1: { 68 | id: 'port1', 69 | type: 'input', 70 | }, 71 | port2: { 72 | id: 'port2', 73 | type: 'output', 74 | }, 75 | }, 76 | }, 77 | node4: { 78 | id: 'node4', 79 | type: 'input-output', 80 | position: { 81 | x: 500, 82 | y: 600, 83 | }, 84 | ports: { 85 | port1: { 86 | id: 'port1', 87 | type: 'input', 88 | }, 89 | port2: { 90 | id: 'port2', 91 | type: 'output', 92 | }, 93 | }, 94 | }, 95 | }, 96 | links: { 97 | link1: { 98 | id: 'link1', 99 | from: { 100 | nodeId: 'node1', 101 | portId: 'port1', 102 | }, 103 | to: { 104 | nodeId: 'node2', 105 | portId: 'port1', 106 | }, 107 | }, 108 | link2: { 109 | id: 'link2', 110 | from: { 111 | nodeId: 'node2', 112 | portId: 'port2', 113 | }, 114 | to: { 115 | nodeId: 'node3', 116 | portId: 'port1', 117 | }, 118 | }, 119 | link3: { 120 | id: 'link3', 121 | from: { 122 | nodeId: 'node2', 123 | portId: 'port3', 124 | }, 125 | to: { 126 | nodeId: 'node4', 127 | portId: 'port1', 128 | }, 129 | }, 130 | }, 131 | selected: {}, 132 | hovered: {}, 133 | } 134 | 135 | export const LinkColors = () => { 136 | return ( 137 | 138 | 139 | 140 | ) 141 | } 142 | -------------------------------------------------------------------------------- /docs/iframe.html: -------------------------------------------------------------------------------- 1 | Storybook

No Preview

Sorry, but you either have no stories or none are selected somehow.

  • Please check the Storybook config.
  • Try reloading the page.

If the problem persists, check the browser console, or the terminal you've run Storybook from.

-------------------------------------------------------------------------------- /docs/vendors~main.0e8543e4e3ea607d4dde.bundle.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! 8 | Copyright (c) 2017 Jed Watson. 9 | Licensed under the MIT License (MIT), see 10 | http://jedwatson.github.io/classnames 11 | */ 12 | 13 | /*! 14 | * https://github.com/es-shims/es5-shim 15 | * @license es5-shim Copyright 2009-2020 by contributors, MIT License 16 | * see https://github.com/es-shims/es5-shim/blob/master/LICENSE 17 | */ 18 | 19 | /*! 20 | * https://github.com/paulmillr/es6-shim 21 | * @license es6-shim Copyright 2013-2016 by Paul Miller (http://paulmillr.com) 22 | * and contributors, MIT License 23 | * es6-shim: v0.35.4 24 | * see https://github.com/paulmillr/es6-shim/blob/0.35.3/LICENSE 25 | * Details and documentation: 26 | * https://github.com/paulmillr/es6-shim/ 27 | */ 28 | 29 | /*! 30 | * is-plain-object 31 | * 32 | * Copyright (c) 2014-2017, Jon Schlinkert. 33 | * Released under the MIT License. 34 | */ 35 | 36 | /*! 37 | * isobject 38 | * 39 | * Copyright (c) 2014-2017, Jon Schlinkert. 40 | * Released under the MIT License. 41 | */ 42 | 43 | /** 44 | * @license 45 | * Lodash 46 | * Copyright OpenJS Foundation and other contributors 47 | * Released under MIT license 48 | * Based on Underscore.js 1.8.3 49 | * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 50 | */ 51 | 52 | /** @license React v0.13.5 53 | * scheduler.production.min.js 54 | * 55 | * Copyright (c) Facebook, Inc. and its affiliates. 56 | * 57 | * This source code is licensed under the MIT license found in the 58 | * LICENSE file in the root directory of this source tree. 59 | */ 60 | 61 | /** @license React v16.13.1 62 | * react-is.production.min.js 63 | * 64 | * Copyright (c) Facebook, Inc. and its affiliates. 65 | * 66 | * This source code is licensed under the MIT license found in the 67 | * LICENSE file in the root directory of this source tree. 68 | */ 69 | 70 | /** @license React v16.8.4 71 | * react.production.min.js 72 | * 73 | * Copyright (c) Facebook, Inc. and its affiliates. 74 | * 75 | * This source code is licensed under the MIT license found in the 76 | * LICENSE file in the root directory of this source tree. 77 | */ 78 | 79 | /** @license React v16.8.5 80 | * react-dom.production.min.js 81 | * 82 | * Copyright (c) Facebook, Inc. and its affiliates. 83 | * 84 | * This source code is licensed under the MIT license found in the 85 | * LICENSE file in the root directory of this source tree. 86 | */ 87 | 88 | /** @license React v16.8.6 89 | * react-is.production.min.js 90 | * 91 | * Copyright (c) Facebook, Inc. and its affiliates. 92 | * 93 | * This source code is licensed under the MIT license found in the 94 | * LICENSE file in the root directory of this source tree. 95 | */ 96 | 97 | //! stable.js 0.1.8, https://github.com/Two-Screen/stable 98 | 99 | //! © 2018 Angry Bytes and contributors. MIT licensed. 100 | -------------------------------------------------------------------------------- /src/types/functions.ts: -------------------------------------------------------------------------------- 1 | import { DraggableData, DraggableEvent } from 'react-draggable' 2 | import { IChart, INode, IPort } from './chart' 3 | import { IConfig } from './config' 4 | import { IOffset, IPosition, ISize } from './generics' 5 | 6 | /** Callback functions will be evaluated inside of a setState so they can always manipulate the chart state */ 7 | export type IStateCallback any> = (...params: Parameters) => (chart: IChart) => IChart 8 | 9 | export interface IOnDragNodeInput { 10 | config?: IConfig 11 | event: DraggableEvent 12 | data: DraggableData 13 | id: string 14 | } 15 | 16 | export type IOnDragNode = (input: IOnDragNodeInput) => void 17 | 18 | export interface IOnDragCanvasInput { 19 | config?: IConfig 20 | data: any 21 | } 22 | 23 | export type IOnDragCanvas = (input: IOnDragCanvasInput) => void 24 | 25 | export interface IOnDragNodeStopInput { 26 | config?: IConfig 27 | event: MouseEvent 28 | data: DraggableData 29 | id: string 30 | } 31 | 32 | export type IOnDragNodeStop = (input: IOnDragNodeStopInput) => void 33 | 34 | export interface IOnDragCanvasStopInput { 35 | config?: IConfig 36 | data: any 37 | } 38 | 39 | export type IOnDragCanvasStop = (input: IOnDragCanvasStopInput) => void 40 | 41 | export interface IOnPortPositionChangeInput { 42 | config?: IConfig 43 | node: INode 44 | port: IPort 45 | el: HTMLDivElement 46 | nodesEl: HTMLDivElement | IOffset 47 | } 48 | 49 | export type IOnPortPositionChange = (input: IOnPortPositionChangeInput) => void 50 | 51 | export interface ILinkBaseInput { 52 | config?: IConfig 53 | linkId: string 54 | } 55 | 56 | export interface IOnLinkBaseEvent extends ILinkBaseInput { 57 | startEvent: React.MouseEvent 58 | fromNodeId: string 59 | fromPortId: string 60 | } 61 | 62 | export type IOnLinkStart = (input: IOnLinkBaseEvent) => void 63 | 64 | export interface IOnLinkMoveInput extends IOnLinkBaseEvent { 65 | toPosition: { 66 | x: number 67 | y: number, 68 | } 69 | } 70 | export type IOnLinkMove = (input: IOnLinkMoveInput) => void 71 | 72 | export type IOnLinkCancel = (input: IOnLinkBaseEvent) => void 73 | 74 | export interface IOnLinkCompleteInput extends IOnLinkBaseEvent { 75 | toNodeId: string 76 | toPortId: string 77 | } 78 | export type IOnLinkComplete = (input: IOnLinkCompleteInput) => void 79 | 80 | export type IOnLinkMouseEnter = (input: ILinkBaseInput) => void 81 | 82 | export type IOnLinkMouseLeave = (input: ILinkBaseInput) => void 83 | 84 | export type IOnLinkClick = (input: ILinkBaseInput) => void 85 | 86 | export type IOnCanvasClick = (input: { config?: IConfig }) => void 87 | 88 | export type IOnDeleteKey = (input: { config?: IConfig }) => void 89 | 90 | export interface INodeBaseInput { 91 | config?: IConfig 92 | nodeId: string 93 | } 94 | 95 | export type IOnNodeClick = (input: INodeBaseInput) => void 96 | 97 | export type IOnNodeDoubleClick = (input: INodeBaseInput) => void 98 | 99 | export interface IOnNodeSizeChangeInput extends INodeBaseInput { 100 | size: ISize 101 | } 102 | 103 | export type IOnNodeSizeChange = (input: IOnNodeSizeChangeInput) => void 104 | 105 | export type IOnNodeMouseEnter = (input: INodeBaseInput) => void 106 | 107 | export type IOnNodeMouseLeave = (input: INodeBaseInput) => void 108 | 109 | export interface IOnCanvasDropInput { 110 | config?: IConfig 111 | data: any 112 | position: IPosition 113 | id: string 114 | } 115 | 116 | export type IOnCanvasDrop = (input: IOnCanvasDropInput) => void 117 | 118 | export type IOnZoomCanvas = (input: { config?: IConfig; data: any }) => void 119 | -------------------------------------------------------------------------------- /stories/CustomGraphTypes.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FlowChartWithState, IChart } from '../src' 3 | import { Page } from './components' 4 | 5 | export const CustomGraphTypes = () => { 6 | const chart: IChart = { 7 | properties: 5, 8 | offset: { 9 | x: 0, 10 | y: 0, 11 | }, 12 | scale: 1, 13 | nodes: { 14 | node1: { 15 | id: 'node1', 16 | type: 'output-only', 17 | position: { 18 | x: 300, 19 | y: 100, 20 | }, 21 | properties: false, 22 | ports: { 23 | port1: { 24 | id: 'port1', 25 | type: 'output', 26 | properties: 1, 27 | }, 28 | port2: { 29 | id: 'port2', 30 | type: 'output', 31 | properties: 2, 32 | }, 33 | }, 34 | }, 35 | node2: { 36 | id: 'node2', 37 | type: 'input-output', 38 | position: { 39 | x: 300, 40 | y: 300, 41 | }, 42 | properties: true, 43 | ports: { 44 | port1: { 45 | id: 'port1', 46 | type: 'input', 47 | properties: 4, 48 | }, 49 | port2: { 50 | id: 'port2', 51 | type: 'output', 52 | properties: 3, 53 | }, 54 | }, 55 | }, 56 | node3: { 57 | id: 'node3', 58 | type: 'input-output', 59 | position: { 60 | x: 100, 61 | y: 600, 62 | }, 63 | properties: true, 64 | ports: { 65 | port1: { 66 | id: 'port1', 67 | type: 'input', 68 | properties: 2, 69 | }, 70 | port2: { 71 | id: 'port2', 72 | type: 'output', 73 | properties: 1, 74 | }, 75 | }, 76 | }, 77 | node4: { 78 | id: 'node4', 79 | type: 'input-output', 80 | position: { 81 | x: 500, 82 | y: 600, 83 | }, 84 | properties: false, 85 | ports: { 86 | port1: { 87 | id: 'port1', 88 | type: 'input', 89 | properties: 0, 90 | }, 91 | port2: { 92 | id: 'port2', 93 | type: 'output', 94 | properties: -1, 95 | }, 96 | }, 97 | }, 98 | }, 99 | links: { 100 | link1: { 101 | id: 'link1', 102 | from: { 103 | nodeId: 'node1', 104 | portId: 'port2', 105 | }, 106 | to: { 107 | nodeId: 'node2', 108 | portId: 'port1', 109 | }, 110 | properties: 'hello world', 111 | }, 112 | link2: { 113 | id: 'link2', 114 | from: { 115 | nodeId: 'node2', 116 | portId: 'port2', 117 | }, 118 | to: { 119 | nodeId: 'node3', 120 | portId: 'port1', 121 | }, 122 | properties: 'hi!', 123 | }, 124 | link3: { 125 | id: 'link3', 126 | from: { 127 | nodeId: 'node2', 128 | portId: 'port2', 129 | }, 130 | to: { 131 | nodeId: 'node4', 132 | portId: 'port1', 133 | }, 134 | properties: 'this is a string field', 135 | }, 136 | }, 137 | selected: {}, 138 | hovered: {}, 139 | } 140 | 141 | return ( 142 | 143 | 146 | 147 | ) 148 | } 149 | -------------------------------------------------------------------------------- /stories/DragAndDropSidebar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { FlowChartWithState } from '../src' 4 | import { Content, Page, Sidebar, SidebarItem } from './components' 5 | import { chartSimple } from './misc/exampleChartState' 6 | 7 | const Message = styled.div` 8 | margin: 10px; 9 | padding: 10px; 10 | background: rgba(0,0,0,0.05); 11 | ` 12 | 13 | export const DragAndDropSidebar = () => ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | Drag and drop these items onto the canvas. 21 | 22 | 44 | 56 | 75 | 97 | 151 | 152 | 153 | ) 154 | -------------------------------------------------------------------------------- /docs/sb_dll/storybook_ui_dll.LICENCE: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! 8 | Copyright (c) 2017 Jed Watson. 9 | Licensed under the MIT License (MIT), see 10 | http://jedwatson.github.io/classnames 11 | */ 12 | 13 | /*! 14 | * Fuse.js v3.4.6 - Lightweight fuzzy-search (http://fusejs.io) 15 | * 16 | * Copyright (c) 2012-2017 Kirollos Risk (http://kiro.me) 17 | * All Rights Reserved. Apache Software License 2.0 18 | * 19 | * http://www.apache.org/licenses/LICENSE-2.0 20 | */ 21 | 22 | /*! 23 | * https://github.com/es-shims/es5-shim 24 | * @license es5-shim Copyright 2009-2015 by contributors, MIT License 25 | * see https://github.com/es-shims/es5-shim/blob/master/LICENSE 26 | */ 27 | 28 | /*! 29 | * https://github.com/paulmillr/es6-shim 30 | * @license es6-shim Copyright 2013-2016 by Paul Miller (http://paulmillr.com) 31 | * and contributors, MIT License 32 | * es6-shim: v0.35.4 33 | * see https://github.com/paulmillr/es6-shim/blob/0.35.3/LICENSE 34 | * Details and documentation: 35 | * https://github.com/paulmillr/es6-shim/ 36 | */ 37 | 38 | /*! 39 | * isobject 40 | * 41 | * Copyright (c) 2014-2017, Jon Schlinkert. 42 | * Released under the MIT License. 43 | */ 44 | 45 | /** 46 | * @license 47 | * Lodash 48 | * Copyright OpenJS Foundation and other contributors 49 | * Released under MIT license 50 | * Based on Underscore.js 1.8.3 51 | * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 52 | */ 53 | 54 | /** @license React v0.18.0 55 | * scheduler.production.min.js 56 | * 57 | * Copyright (c) Facebook, Inc. and its affiliates. 58 | * 59 | * This source code is licensed under the MIT license found in the 60 | * LICENSE file in the root directory of this source tree. 61 | */ 62 | 63 | /** @license React v16.12.0 64 | * react-dom.production.min.js 65 | * 66 | * Copyright (c) Facebook, Inc. and its affiliates. 67 | * 68 | * This source code is licensed under the MIT license found in the 69 | * LICENSE file in the root directory of this source tree. 70 | */ 71 | 72 | /** @license React v16.12.0 73 | * react-is.production.min.js 74 | * 75 | * Copyright (c) Facebook, Inc. and its affiliates. 76 | * 77 | * This source code is licensed under the MIT license found in the 78 | * LICENSE file in the root directory of this source tree. 79 | */ 80 | 81 | /** @license React v16.12.0 82 | * react.production.min.js 83 | * 84 | * Copyright (c) Facebook, Inc. and its affiliates. 85 | * 86 | * This source code is licensed under the MIT license found in the 87 | * LICENSE file in the root directory of this source tree. 88 | */ 89 | 90 | /**! 91 | * @fileOverview Kickass library to create and place poppers near their reference elements. 92 | * @version 1.16.1 93 | * @license 94 | * Copyright (c) 2016 Federico Zivolo and contributors 95 | * 96 | * Permission is hereby granted, free of charge, to any person obtaining a copy 97 | * of this software and associated documentation files (the "Software"), to deal 98 | * in the Software without restriction, including without limitation the rights 99 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 100 | * copies of the Software, and to permit persons to whom the Software is 101 | * furnished to do so, subject to the following conditions: 102 | * 103 | * The above copyright notice and this permission notice shall be included in all 104 | * copies or substantial portions of the Software. 105 | * 106 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 107 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 108 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 109 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 110 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 111 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 112 | * SOFTWARE. 113 | */ 114 | -------------------------------------------------------------------------------- /src/components/Link/utils/generateCurvePath.ts: -------------------------------------------------------------------------------- 1 | import * as PF from 'pathfinding' 2 | import { IPort, IPosition } from '../../../' 3 | import { MATRIX_PADDING } from '../../FlowChart/utils/grid' 4 | 5 | export const getDirectional = (startPos: IPosition, endPos: IPosition) => { 6 | const width = Math.abs(startPos.x - endPos.x) 7 | const height = Math.abs(startPos.y - endPos.y) 8 | const leftToRight = startPos.x < endPos.x 9 | const topToBottom = startPos.y < endPos.y 10 | const isHorizontal = width > height 11 | 12 | return { width, height,leftToRight,topToBottom,isHorizontal } 13 | } 14 | 15 | export const generateCurvePath = (startPos: IPosition, endPos: IPosition): string => { 16 | const { width, height,leftToRight,topToBottom,isHorizontal } = getDirectional(startPos,endPos) 17 | 18 | let start: IPosition 19 | let end: IPosition 20 | if (isHorizontal) { 21 | start = leftToRight ? startPos : endPos 22 | end = leftToRight ? endPos : startPos 23 | } else { 24 | start = topToBottom ? startPos : endPos 25 | end = topToBottom ? endPos : startPos 26 | } 27 | 28 | const curve = isHorizontal ? width / 3 : height / 3 29 | const curveX = isHorizontal ? curve : 0 30 | const curveY = isHorizontal ? 0 : curve 31 | 32 | return `M${start.x},${start.y} C ${start.x + curveX},${start.y + curveY} ${end.x - curveX},${end.y - curveY} ${end.x},${end.y}` 33 | } 34 | 35 | const finder = PF.JumpPointFinder({ 36 | heuristic: PF.Heuristic.manhattan, 37 | diagonalMovement: PF.DiagonalMovement.Never, 38 | }) 39 | 40 | export const generateRightAnglePath = (startPos: IPosition, endPos: IPosition) => { 41 | const { leftToRight,topToBottom,isHorizontal } = getDirectional(startPos,endPos) 42 | 43 | let start: IPosition 44 | let end: IPosition 45 | if (isHorizontal) { 46 | start = leftToRight ? startPos : endPos 47 | end = leftToRight ? endPos : startPos 48 | } else { 49 | start = topToBottom ? startPos : endPos 50 | end = topToBottom ? endPos : startPos 51 | } 52 | 53 | const vertex = isHorizontal ? `${start.x},${end.y}` : `${end.x},${start.y}` 54 | 55 | return `M${start.x},${start.y} L ${vertex} ${end.x},${end.y}` 56 | } 57 | 58 | const setWalkableAtPorts = (start: { pos: IPosition, port: IPort }, end: { pos: IPosition, port: IPort }, grid: PF.Grid) => { 59 | ([start, end]).forEach((point) => { 60 | if (['input', 'top'].includes(point.port.type)) { 61 | for (let i = point.pos.y; i >= Math.max(point.pos.y - MATRIX_PADDING, 0); i--) { 62 | grid.setWalkableAt(point.pos.x, i, true) 63 | } 64 | } else if (['output', 'bottom'].includes(point.port.type)) { 65 | for (let i = point.pos.y; i <= Math.min(point.pos.y + MATRIX_PADDING, grid.height); i++) { 66 | grid.setWalkableAt(point.pos.x, i, true) 67 | } 68 | } else if (['right'].includes(point.port.type)) { 69 | for (let i = point.pos.x; i <= Math.max(point.pos.x + MATRIX_PADDING, grid.width); i++) { 70 | grid.setWalkableAt(i, point.pos.y, true) 71 | } 72 | } else if (['left'].includes(point.port.type)) { 73 | for (let i = point.pos.x; i >= Math.max(point.pos.x - MATRIX_PADDING, 0); i--) { 74 | grid.setWalkableAt(i, point.pos.y, true) 75 | } 76 | } 77 | }) 78 | } 79 | 80 | export const generateSmartPath = (matrix: number[][], startPos: IPosition, endPos: IPosition, fromPort: IPort, toPort: IPort): string => { 81 | const grid = new PF.Grid(matrix) 82 | 83 | const startPosScaled = { x: Math.ceil(startPos.x / 5), y: Math.ceil(startPos.y / 5) } 84 | const endPosScaled = { x: Math.ceil(endPos.x / 5), y: Math.ceil(endPos.y / 5) } 85 | 86 | try { 87 | // try to find a smart path. use right angle path as a fallback 88 | setWalkableAtPorts({ pos : startPosScaled, port: fromPort }, { pos : endPosScaled, port: toPort }, grid) 89 | 90 | const path = PF.Util.compressPath( 91 | finder.findPath( 92 | startPosScaled.x, 93 | startPosScaled.y, 94 | endPosScaled.x, 95 | endPosScaled.y, 96 | grid, 97 | ), 98 | ) 99 | 100 | if (!path.length) return generateRightAnglePath(startPos, endPos) 101 | const [first, ...rest] = path 102 | let d = `M${first[0] * 5} ${first[1] * 5}` 103 | rest.forEach(([x, y]) => { 104 | d += ` L${x * 5} ${y * 5}` 105 | }) 106 | return d 107 | } catch (e) { 108 | return generateRightAnglePath(startPos, endPos) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Flow Chart 2 | 3 | [![CircleCI](https://circleci.com/gh/MrBlenny/react-flow-chart.svg?style=svg)](https://circleci.com/gh/MrBlenny/react-flow-chart) 4 | 5 | - [X] Dragabble Nodes and Canvas 6 | - [x] Create curved links between ports 7 | - [x] Custom components for Canvas, Links, Ports, Nodes 8 | - [X] React state container 9 | - [X] Update state on Select/Hover nodes, ports and links 10 | - [x] Base functionality complete 11 | - [X] Stable NPM version 12 | - [X] Scroll/Pinch canvas to zoom 13 | - [ ] Ctrl+z/Ctrl+y history 14 | - [X] Read-only mode 15 | - [ ] Redux state container 16 | - [ ] Arrow heads on links 17 | - [ ] Docs 18 | 19 | ### [Storybook Demo](https://mrblenny.github.io/react-flow-chart/index.html?selectedKind=With%20Sidebar&selectedStory=default&full=0&addons=1&stories=1&panelRight=0&addonPanel=storybook-addon-viewport%2Faddon-panel) 20 | 21 | ### [CodeSandbox Demo](https://codesandbox.io/s/4w46wv71o7) 22 | 23 | This project aims to build a highly customisable, declarative flow chart library. Critically, you control the state. Pick from Redux, MobX, React or any other state managment library - simply pass in the current state and hook up the callbacks. 24 | 25 | For example: 26 | 27 | ![demo](./images/demo.gif) 28 | 29 | ## Data Stucture 30 | 31 | The flow chart is designed as a collection of Nodes, Ports and Links. You can specify your own custom properties, making this format quite flexible. See [types/chart.ts](./src/types/chart.ts). Note, nodes, ports and links should have a unique id. 32 | 33 | #### Example 34 | 35 | ```ts 36 | 37 | export const chart: IChart = { 38 | offset: { 39 | x: 0, 40 | y: 0, 41 | }, 42 | scale: 1, 43 | nodes: { 44 | node1: { 45 | id: 'node1', 46 | type: 'output-only', 47 | position: { 48 | x: 300, 49 | y: 100, 50 | }, 51 | ports: { 52 | port1: { 53 | id: 'port1', 54 | type: 'output', 55 | properties: { 56 | value: 'yes', 57 | }, 58 | }, 59 | port2: { 60 | id: 'port2', 61 | type: 'output', 62 | properties: { 63 | value: 'no', 64 | }, 65 | }, 66 | }, 67 | }, 68 | node2: { 69 | id: 'node2', 70 | type: 'input-output', 71 | position: { 72 | x: 300, 73 | y: 300, 74 | }, 75 | ports: { 76 | port1: { 77 | id: 'port1', 78 | type: 'input', 79 | }, 80 | port2: { 81 | id: 'port2', 82 | type: 'output', 83 | }, 84 | }, 85 | }, 86 | }, 87 | links: { 88 | link1: { 89 | id: 'link1', 90 | from: { 91 | nodeId: 'node1', 92 | portId: 'port2', 93 | }, 94 | to: { 95 | nodeId: 'node2', 96 | portId: 'port1', 97 | }, 98 | }, 99 | }, 100 | selected: {}, 101 | hovered: {}, 102 | } 103 | 104 | ``` 105 | 106 | This will produce a simple 2 noded chart which looks like: 107 | 108 | ![Demo](./images/demo.png) 109 | 110 | ## Basic Usage 111 | 112 | ```bash 113 | npm i @mrblenny/react-flow-chart 114 | ``` 115 | 116 | Most components/types are available as a root level export. Check the storybook demo for more examples. 117 | 118 | ```tsx 119 | import { FlowChartWithState } from "@mrblenny/react-flow-chart"; 120 | 121 | const chartSimple = { 122 | offset: { 123 | x: 0, 124 | y: 0 125 | }, 126 | nodes: { 127 | node1: { 128 | id: "node1", 129 | type: "output-only", 130 | position: { 131 | x: 300, 132 | y: 100 133 | }, 134 | ports: { 135 | port1: { 136 | id: "port1", 137 | type: "output", 138 | properties: { 139 | value: "yes" 140 | } 141 | }, 142 | port2: { 143 | id: "port2", 144 | type: "output", 145 | properties: { 146 | value: "no" 147 | } 148 | } 149 | } 150 | }, 151 | node2: { 152 | id: "node2", 153 | type: "input-output", 154 | position: { 155 | x: 300, 156 | y: 300 157 | }, 158 | ports: { 159 | port1: { 160 | id: "port1", 161 | type: "input" 162 | }, 163 | port2: { 164 | id: "port2", 165 | type: "output" 166 | } 167 | } 168 | }, 169 | }, 170 | links: { 171 | link1: { 172 | id: "link1", 173 | from: { 174 | nodeId: "node1", 175 | portId: "port2" 176 | }, 177 | to: { 178 | nodeId: "node2", 179 | portId: "port1" 180 | }, 181 | }, 182 | }, 183 | selected: {}, 184 | hovered: {} 185 | }; 186 | 187 | const Example = ( 188 | 189 | ); 190 | ``` 191 | 192 | ### With Internal State 193 | [stories/InternalReactState.tsx](./stories/InternalReactState.tsx) 194 | 195 | ### With External State 196 | [stories/ExternalReactState.tsx](./stories/ExternalReactState.tsx) 197 | 198 | ### Readonly Mode 199 | [stories/ReadonlyMode.tsx](./stories/ReadonlyMode.tsx) 200 | 201 | ### Other Demos 202 | [stories/ExternalReactState.tsx](./stories) 203 | 204 | 205 | ## Contributing 206 | 207 | If you're interested in helping out, let me know. 208 | 209 | In particular, would be great to get a hand with docs and redux / mobx integrations. 210 | 211 | 212 | ## Development 213 | 214 | ```bash 215 | npm install 216 | npm run start:storybook 217 | ``` 218 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ## [0.0.14] - 2020-06-28 6 | 7 | ## Fixed 8 | 9 | - Drag and drop nodes now reflects the correct position when zoomed [ajuhos](https://github.com/MrBlenny/react-flow-chart/pull/152) 10 | - Fixed some link positioning errors [ajuhos](https://github.com/MrBlenny/react-flow-chart/pull/162/) 11 | - Fix canvas drop creating 2 nodes [IdealSystemsMCP](https://github.com/MrBlenny/react-flow-chart/pull/169/) 12 | - Remove depricated findDOMNode method [IdealSystemsMCP](https://github.com/MrBlenny/react-flow-chart/pull/170/) 13 | 14 | ## Added 15 | 16 | - Class names to all default components [davidanitoiu](https://github.com/MrBlenny/react-flow-chart/pull/144) 17 | - Arrow heads to links [davidanitoiu](https://github.com/MrBlenny/react-flow-chart/pull/145) 18 | - Add generics to main chart types [andrewadams32](https://github.com/MrBlenny/react-flow-chart/pull/151) 19 | - Add readonly mode to nodes [ielijose](https://github.com/MrBlenny/react-flow-chart/pull/155) 20 | - Add selectable mode [ielijose](https://github.com/MrBlenny/react-flow-chart/pull/156) 21 | 22 | ## [0.0.13] - 2020-05-09 23 | 24 | ## Fixed 25 | 26 | - Nodes is view calculation was wrong if the chart was zoomed leading to the nodes not displaying. 27 | - Moved `styled-components` to a peer dependency 28 | - Do not send click even when drag finishes [crsven](https://github.com/MrBlenny/react-flow-chart/pull/132) 29 | 30 | ## [0.0.12] - 2020-04-27 31 | 32 | ## Fixed 33 | 34 | - Fix a bad type annotation for `onLinkClick` [lukewarlow](https://github.com/MrBlenny/react-flow-chart/pull/107) 35 | - Pass config to `onCanvasDrop` [LeonZamel](https://github.com/MrBlenny/react-flow-chart/pull/111) 36 | 37 | ## Added 38 | 39 | - Use the data.id if it exists on the drag and drop data transfer object [NoyTse](https://github.com/MrBlenny/react-flow-chart/pull/96) 40 | - Add an onNodeDoubkeClick handler [jetmar](https://github.com/MrBlenny/react-flow-chart/pull/99) 41 | - Add properties.linkColor support to the default link component [ielijose](https://github.com/MrBlenny/react-flow-chart/pull/103) 42 | - Zoom support! [ielijose](https://github.com/MrBlenny/react-flow-chart/pull/125) 43 | 44 | ## Breaking 45 | 46 | - Readonly mode will no longer disable canvas drag [parasg1999](https://github.com/MrBlenny/react-flow-chart/pull/112) 47 | - Updated styled components to `^5.1.0` [ophirg](https://github.com/MrBlenny/react-flow-chart/pull/118) 48 | - Zoom is enabled by default 49 | - Chart state must have now have a scale property 50 | 51 | ## [0.0.11] - 2020-03-02 52 | 53 | ## Fixed 54 | 55 | - Fixed an issue with onDrag [errors](https://github.com/MrBlenny/react-flow-chart/pull/88#issuecomment-593213248) 56 | 57 | ## [0.0.10] - 2020-03-02 58 | 59 | ## Added 60 | 61 | - `smartRouting` mode [dmitrygalperin](https://github.com/MrBlenny/react-flow-chart/pull/89) 62 | - Pass node into ports to enable customisation [fenech](https://github.com/MrBlenny/react-flow-chart/pull/85) 63 | - Add `nodeMouseEnter` and `nodeMouseLeave` callbacks [fenech](https://github.com/MrBlenny/react-flow-chart/pull/84) 64 | - Add `onDragCanvasStop` and `onDragNodeStop` callbacks [lordi](https://github.com/MrBlenny/react-flow-chart/pull/88) 65 | 66 | ## [0.0.9] - 2020-01-18 67 | 68 | ## Fixed 69 | 70 | - The `onNodeClick` action will no longer be called when dragging [fenech](https://github.com/MrBlenny/react-flow-chart/pull/78/files) 71 | - Fix data consistency when `snapToGrid` is on/off [sinan](https://github.com/MrBlenny/react-flow-chart/pull/72) 72 | - Update node size when size changes in the DOM [zetavg](https://github.com/MrBlenny/react-flow-chart/pull/71) 73 | - Prevent links and ports displaying as active when in readonly mode. 74 | 75 | ## Breaking 76 | 77 | - Updated styled components to `^5.0.0` [yuyokk](https://github.com/MrBlenny/react-flow-chart/pull/76/files) 78 | 79 | ## [0.0.8] - 2019-10-20 80 | 81 | ## Fixed 82 | 83 | - Readonly mode should prevent link edits [loonyuni](https://github.com/MrBlenny/react-flow-chart/pull/45) 84 | - Only call `onCanvasDrop` if data exists in drop event [loonyuni](https://github.com/MrBlenny/react-flow-chart/pull/51) 85 | - Improve CustomNode storybook example [timbrunette](https://github.com/MrBlenny/react-flow-chart/pulls) 86 | - Fixed an error that was being thown when creating links in readonly mode 87 | 88 | ## [0.0.7] - 2019-08-22 89 | 90 | ## Added 91 | 92 | - Readonly mode [yukai-w](https://github.com/MrBlenny/react-flow-chart/pull/39) 93 | - Offset position to dropped item position [phlickey](https://github.com/MrBlenny/react-flow-chart/pull/34) 94 | - `snapToGrid` and `gridSize` config options [msteinmn](https://github.com/MrBlenny/react-flow-chart/pull/23) 95 | - `validateLink` config function [msteinmn](https://github.com/MrBlenny/react-flow-chart/pull/23) 96 | - misc other fixes [msteinmn](https://github.com/MrBlenny/react-flow-chart/pull/23) 97 | - Config object that is accessible by all components and actions 98 | 99 | ## Breaking 100 | 101 | - Changed the callback type signatures so they are always objects rather than functions with params. If you use custom callbacks, these will need to be updated. 102 | 103 | 104 | ## [0.0.6] - 2019-04-30 105 | 106 | ## Added 107 | 108 | - Upgrade Typescript and Storybook. 109 | - Prevent re-rendering for nodes and links that are not in use. [alexkuz PR7](https://github.com/MrBlenny/react-flow-chart/pull/7) 110 | - Render only nodes currently visible for user. [alexkuz PR7](https://github.com/MrBlenny/react-flow-chart/pull/7) 111 | - Fix calculating link position when canvas is not positioned in top left corner. [alexkuz PR7](https://github.com/MrBlenny/react-flow-chart/pull/7) 112 | 113 | ## Breaking 114 | 115 | - Added a new [onNodeSizeChange](https://github.com/MrBlenny/react-flow-chart/pull/7/files#diff-5a121158d13f502e78c5c29411f54269R141) action that is required for calculating which nodes are visible. If you are using external state, this will need to be implemented. 116 | 117 | ## [0.0.5] - 2019-04-02 118 | 119 | ### Added 120 | 121 | - Fixed a bug where links would not work on firefox [tantayou999](https://github.com/MrBlenny/react-flow-chart/issues/12) 122 | 123 | ## [0.0.4] - 2019-03-24 124 | 125 | ### Added 126 | 127 | - Start keeping a changelog 128 | - Remove storybook and lodash from from dist [alexcuz PR5](https://github.com/MrBlenny/react-flow-chart/pull/5) 129 | -------------------------------------------------------------------------------- /src/components/Canvas/Canvas.wrapper.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch' 3 | import { v4 } from 'uuid' 4 | import { IConfig, IOnCanvasClick, IOnCanvasDrop, IOnDeleteKey, IOnDragCanvas, IOnDragCanvasStop, IOnZoomCanvas, REACT_FLOW_CHART } from '../../' 5 | import CanvasContext from './CanvasContext' 6 | import { ICanvasInnerDefaultProps } from './CanvasInner.default' 7 | import { ICanvasOuterDefaultProps } from './CanvasOuter.default' 8 | 9 | export interface ICanvasWrapperProps { 10 | config: IConfig 11 | position: { 12 | x: number 13 | y: number, 14 | } 15 | scale: number 16 | onZoomCanvas: IOnZoomCanvas 17 | onDragCanvas: IOnDragCanvas 18 | onDragCanvasStop: IOnDragCanvasStop 19 | onDeleteKey: IOnDeleteKey 20 | onCanvasClick: IOnCanvasClick 21 | onCanvasDrop: IOnCanvasDrop 22 | ComponentInner: React.FunctionComponent 23 | ComponentOuter: React.FunctionComponent 24 | onSizeChange: (x: number, y: number) => void 25 | children: any 26 | } 27 | 28 | interface IState { 29 | width: number 30 | height: number 31 | offsetX: number 32 | offsetY: number 33 | } 34 | 35 | export class CanvasWrapper extends React.Component { 36 | public state = { 37 | width: 0, 38 | height: 0, 39 | offsetX: 0, 40 | offsetY: 0, 41 | } 42 | 43 | private ref = React.createRef() 44 | 45 | public componentDidMount () { 46 | this.updateSize() 47 | 48 | if (this.ref.current) { 49 | if ((window as any).ResizeObserver) { 50 | const ro = new (window as any).ResizeObserver(this.updateSize) 51 | ro.observe(this.ref.current) 52 | } else { 53 | window.addEventListener('resize', this.updateSize) 54 | } 55 | window.addEventListener('scroll', this.updateSize) 56 | } 57 | } 58 | 59 | public componentDidUpdate () { 60 | this.updateSize() 61 | } 62 | 63 | public componentWillUnmount () { 64 | window.removeEventListener('resize', this.updateSize) 65 | window.removeEventListener('scroll', this.updateSize) 66 | } 67 | 68 | public render () { 69 | const { 70 | config, 71 | scale, 72 | ComponentInner, 73 | ComponentOuter, 74 | position, 75 | onDragCanvas, 76 | onDragCanvasStop, 77 | children, 78 | onCanvasClick, 79 | onDeleteKey, 80 | onCanvasDrop, 81 | onZoomCanvas, 82 | } = this.props 83 | const { offsetX, offsetY } = this.state 84 | const { zoom } = config 85 | 86 | const options = { 87 | transformEnabled: zoom && zoom.transformEnabled ? zoom.transformEnabled : true, 88 | minScale: zoom && zoom.minScale ? zoom.minScale : 0.25, 89 | maxScale: zoom && zoom.maxScale ? zoom.maxScale : 2, 90 | limitToBounds: false, 91 | limitToWrapper: false, 92 | centerContent: false, 93 | } 94 | 95 | const doubleClickMode = config.readonly ? 'zoomOut' : 'zoomIn' 96 | 97 | return ( 98 | 105 | 106 | onZoomCanvas({ config, data })} 120 | onWheelStop={(data: any) => onZoomCanvas({ config, data })} 121 | onPanning={(data: any) => onDragCanvas({ config, data })} 122 | onPanningStop={(data: any) => onDragCanvasStop({ config, data })} 123 | > 124 | 125 | { 131 | // delete or backspace keys 132 | if (e.keyCode === 46 || e.keyCode === 8) { 133 | onDeleteKey({ config }) 134 | } 135 | }} 136 | onDrop={(e) => { 137 | const data = JSON.parse( 138 | e.dataTransfer.getData(REACT_FLOW_CHART), 139 | ) 140 | if (data) { 141 | const relativeClientX = e.clientX - offsetX 142 | const relativeClientY = e.clientY - offsetY 143 | onCanvasDrop({ 144 | config, 145 | data, 146 | position: { 147 | x: relativeClientX / scale - position.x / scale, 148 | y: relativeClientY / scale - position.y / scale, 149 | }, 150 | id: data.id || v4(), 151 | }) 152 | } 153 | }} 154 | onDragOver={(e) => e.preventDefault()} 155 | /> 156 | 157 | 158 | 159 | 160 | ) 161 | } 162 | 163 | private updateSize = () => { 164 | const el = this.ref.current 165 | 166 | if (el) { 167 | const rect = el.getBoundingClientRect() 168 | 169 | if (el.offsetWidth !== this.state.width || el.offsetHeight !== this.state.height) { 170 | this.setState({ width: el.offsetWidth, height: el.offsetHeight }) 171 | this.props.onSizeChange(el.offsetWidth, el.offsetHeight) 172 | } 173 | 174 | if (rect.left !== this.state.offsetX || rect.top !== this.state.offsetY) { 175 | this.setState({ offsetX: rect.left, offsetY: rect.top }) 176 | } 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/components/Node/Node.wrapper.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Draggable, { DraggableData } from 'react-draggable' 3 | import ResizeObserver from 'react-resize-observer' 4 | import { 5 | IConfig, 6 | ILink, 7 | INode, 8 | INodeInnerDefaultProps, 9 | IOnDragNode, 10 | IOnDragNodeStop, 11 | IOnLinkCancel, 12 | IOnLinkComplete, 13 | IOnLinkMove, 14 | IOnLinkStart, 15 | IOnNodeClick, 16 | IOnNodeDoubleClick, 17 | IOnNodeMouseEnter, 18 | IOnNodeMouseLeave, 19 | IOnNodeSizeChange, 20 | IOnPortPositionChange, 21 | IPortDefaultProps, 22 | IPortsDefaultProps, 23 | IPosition, 24 | ISelectedOrHovered, 25 | ISize, 26 | PortWrapper, 27 | } from '../../' 28 | import { noop } from '../../utils' 29 | import CanvasContext from '../Canvas/CanvasContext' 30 | import { INodeDefaultProps, NodeDefault } from './Node.default' 31 | 32 | export interface INodeWrapperProps { 33 | config: IConfig 34 | node: INode 35 | Component: React.FunctionComponent 36 | offset: IPosition 37 | selected: ISelectedOrHovered | undefined 38 | hovered: ISelectedOrHovered | undefined 39 | selectedLink: ILink | undefined 40 | hoveredLink: ILink | undefined 41 | isSelected: boolean 42 | NodeInner: React.FunctionComponent 43 | Ports: React.FunctionComponent 44 | Port: React.FunctionComponent 45 | onPortPositionChange: IOnPortPositionChange 46 | onLinkStart: IOnLinkStart 47 | onLinkMove: IOnLinkMove 48 | onLinkComplete: IOnLinkComplete 49 | onLinkCancel: IOnLinkCancel 50 | onDragNode: IOnDragNode 51 | onDragNodeStop: IOnDragNodeStop 52 | onNodeClick: IOnNodeClick 53 | onNodeDoubleClick: IOnNodeDoubleClick 54 | onNodeSizeChange: IOnNodeSizeChange 55 | onNodeMouseEnter: IOnNodeMouseEnter 56 | onNodeMouseLeave: IOnNodeMouseLeave 57 | } 58 | 59 | export const NodeWrapper = ({ 60 | config, 61 | node, 62 | onDragNode, 63 | onDragNodeStop, 64 | onNodeClick, 65 | onNodeDoubleClick, 66 | isSelected, 67 | Component = NodeDefault, 68 | onNodeSizeChange, 69 | onNodeMouseEnter, 70 | onNodeMouseLeave, 71 | NodeInner, 72 | Ports, 73 | Port, 74 | offset, 75 | selected, 76 | selectedLink, 77 | hovered, 78 | hoveredLink, 79 | onPortPositionChange, 80 | onLinkStart, 81 | onLinkMove, 82 | onLinkComplete, 83 | onLinkCancel, 84 | }: INodeWrapperProps) => { 85 | const { zoomScale } = React.useContext(CanvasContext) 86 | const [size, setSize] = React.useState({ width: 0, height: 0 }) 87 | const [portsSize, setPortsSize] = React.useState({ width: 0, height: 0 }) 88 | 89 | const isDragging = React.useRef(false) 90 | 91 | const readonly = config.readonly || node.readonly || false 92 | 93 | const onStart = React.useCallback((e: MouseEvent) => { 94 | // Stop propagation so the canvas does not move 95 | e.stopPropagation() 96 | isDragging.current = false 97 | }, []) 98 | 99 | const onDrag = React.useCallback( 100 | (event: MouseEvent, data: DraggableData) => { 101 | isDragging.current = true 102 | onDragNode({ config, event, data, id: node.id }) 103 | }, 104 | [onDragNode, config, node.id], 105 | ) 106 | 107 | const onStop = React.useCallback( 108 | (event: MouseEvent, data: DraggableData) => { 109 | onDragNodeStop({ config, event, data, id: node.id }) 110 | }, 111 | [onDragNodeStop, config, node.id], 112 | ) 113 | 114 | const onClick = React.useCallback( 115 | (e: React.MouseEvent) => { 116 | if (!config.readonly || config.selectable) { 117 | e.stopPropagation() 118 | if (!isDragging.current) { 119 | onNodeClick({ config, nodeId: node.id }) 120 | } 121 | } 122 | }, 123 | [config, node.id], 124 | ) 125 | 126 | const onDoubleClick = React.useCallback( 127 | (e: React.MouseEvent) => { 128 | if (!config.readonly) { 129 | e.stopPropagation() 130 | if (!isDragging.current) { 131 | onNodeDoubleClick({ config, nodeId: node.id }) 132 | } 133 | } 134 | }, 135 | [config, node.id], 136 | ) 137 | 138 | const onMouseEnter = React.useCallback(() => { 139 | onNodeMouseEnter({ config, nodeId: node.id }) 140 | }, [config, node.id]) 141 | 142 | const onMouseLeave = React.useCallback(() => { 143 | onNodeMouseLeave({ config, nodeId: node.id }) 144 | }, [config, node.id]) 145 | 146 | const compRef = React.useRef(null) 147 | 148 | // TODO: probably should add an observer to track node component size changes 149 | React.useLayoutEffect(() => { 150 | const el = compRef.current as HTMLInputElement 151 | if (el) { 152 | if ((node.size && node.size.width) !== el.offsetWidth || (node.size && node.size.height) !== el.offsetHeight) { 153 | const newSize = { width: el.offsetWidth, height: el.offsetHeight } 154 | setSize(newSize) 155 | onNodeSizeChange({ config, nodeId: node.id, size: newSize }) 156 | } 157 | } 158 | }, [node, compRef.current, size.width, size.height]) 159 | 160 | const children = ( 161 |
162 | { 164 | const newSize = { width: rect.width, height: rect.height } 165 | setSize(newSize) 166 | }} 167 | /> 168 | 169 | 170 | 171 | {Object.keys(node.ports).map((portId) => ( 172 | 190 | ))} 191 | 192 |
193 | ) 194 | 195 | return ( 196 | 208 | 219 | 220 | ) 221 | } 222 | -------------------------------------------------------------------------------- /src/components/Port/Port.wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { isEqual } from 'lodash' 2 | import * as React from 'react' 3 | import { v4 } from 'uuid' 4 | import { IConfig, ILink, INode, IOnLinkCancel, IOnLinkComplete, IOnLinkMove, IOnLinkStart, IOnPortPositionChange, IPort, IPosition, ISelectedOrHovered, ISize } from '../../' 5 | import CanvasContext from '../Canvas/CanvasContext' 6 | import { IPortDefaultProps, PortDefault } from './Port.default' 7 | 8 | /** Construct the composed path by traversing parentElements */ 9 | const composedPath = (el: HTMLElement | null) => { 10 | const path: HTMLElement[] = [] 11 | while (el) { 12 | path.push(el) 13 | el = el.parentElement 14 | } 15 | return path 16 | } 17 | 18 | export interface IPortWrapperProps { 19 | config: IConfig 20 | style?: object 21 | offset: IPosition 22 | selected: ISelectedOrHovered | undefined 23 | hovered: ISelectedOrHovered | undefined 24 | selectedLink: ILink | undefined 25 | hoveredLink: ILink | undefined 26 | port: IPort 27 | node: INode 28 | portsSize: ISize 29 | onPortPositionChange: IOnPortPositionChange 30 | Component: React.FunctionComponent 31 | 32 | // Link handlers 33 | onLinkStart: IOnLinkStart 34 | onLinkMove: IOnLinkMove 35 | onLinkCancel: IOnLinkCancel 36 | onLinkComplete: IOnLinkComplete 37 | } 38 | 39 | export class PortWrapper extends React.Component { 40 | public static contextType = CanvasContext 41 | public context!: React.ContextType 42 | 43 | private nodeRef = React.createRef() 44 | 45 | public componentDidMount () { 46 | this.updatePortPosition() 47 | } 48 | 49 | public componentDidUpdate (prevProps: IPortWrapperProps) { 50 | // Update port position after a re-render if there are more ports on the same side 51 | // or if node.size has changed 52 | if (this.portsOfType(this.props) !== this.portsOfType(prevProps) 53 | || !isEqual(this.props.node.size, prevProps.node.size) 54 | || !isEqual(this.props.portsSize, prevProps.portsSize)) { 55 | this.updatePortPosition() 56 | } 57 | } 58 | 59 | public onMouseDown = (startEvent: React.MouseEvent) => { 60 | const { offset, node, port, onLinkStart, onLinkCancel, onLinkComplete, onLinkMove, config } = this.props 61 | const linkId = v4() 62 | const fromNodeId = node.id 63 | const fromPortId = port.id 64 | 65 | // Create the move handler 66 | // This will update the position as the mouse moves 67 | const mouseMoveHandler = (e: MouseEvent) => { 68 | const { offsetX, offsetY, zoomScale } = this.context 69 | 70 | onLinkMove({ 71 | config, 72 | linkId, 73 | startEvent, 74 | fromNodeId, 75 | fromPortId, 76 | toPosition: { 77 | x: (e.clientX - offsetX - offset.x) / zoomScale, 78 | y: (e.clientY - offsetY - offset.y) / zoomScale, 79 | }, 80 | }) 81 | } 82 | 83 | // Create and bind the mouse up handler 84 | // This is used to check if the link is complete or cancelled 85 | const mouseUpHandler = (e: MouseEvent) => { 86 | // We traverse up the event path until we find an element with 'data-port-id' and data-node-id' 87 | // e.toElement cannot be used because it may be a child element of the port 88 | const path = composedPath(e.target as HTMLElement) 89 | const portEl = path.find((el) => { 90 | const toPortId = el.getAttribute && el.getAttribute('data-port-id') 91 | const toNodeId = el.getAttribute && el.getAttribute('data-node-id') 92 | return !!(toPortId && toNodeId) 93 | }) 94 | 95 | // If both node-id and port-id are defined as data attributes, we are mouse-upping 96 | // on another port. Run the success handler 97 | if (portEl) { 98 | const toPortId = portEl.getAttribute('data-port-id') as string 99 | const toNodeId = portEl.getAttribute('data-node-id') as string 100 | onLinkComplete({ config, linkId, startEvent, fromNodeId, fromPortId, toNodeId, toPortId }) 101 | } else { 102 | onLinkCancel({ config, linkId, startEvent, fromNodeId, fromPortId }) 103 | } 104 | 105 | // Remove the listeners if the link is complete or canceled 106 | window.removeEventListener('mouseup', mouseUpHandler, false) 107 | window.removeEventListener('mousemove', mouseMoveHandler, false) 108 | } 109 | 110 | // Add listeners 111 | window.addEventListener('mouseup', mouseUpHandler, false) 112 | window.addEventListener('mousemove', mouseMoveHandler, false) 113 | 114 | // Notify state of link start 115 | onLinkStart({ config, linkId, startEvent, fromNodeId, fromPortId }) 116 | 117 | // Prevent default and stop propagation to prevent text selection 118 | startEvent.preventDefault() 119 | startEvent.stopPropagation() 120 | } 121 | public render () { 122 | const { 123 | selected, 124 | selectedLink, 125 | hovered, 126 | hoveredLink, 127 | style, 128 | port, 129 | node, 130 | Component = PortDefault, 131 | config, 132 | } = this.props 133 | 134 | return ( 135 |
142 | 161 |
162 | ) 163 | } 164 | 165 | private updatePortPosition () { 166 | const el = this.nodeRef.current as HTMLInputElement 167 | if (el) { 168 | // Ports component should be positions absolute 169 | // Factor this in so we get position relative to the node 170 | const nodesEl = el.parentElement 171 | ? el.parentElement 172 | : { offsetLeft: 0, offsetTop: 0 } 173 | // update port position after node size has been determined 174 | this.props.onPortPositionChange({ config: this.props.config, node: this.props.node, port: this.props.port, el, nodesEl }) 175 | } 176 | } 177 | 178 | private portsOfType (props: IPortWrapperProps) { 179 | const { port: { type }, node: { ports } } = props 180 | return Object.values(ports).reduce((count, port) => port.type === type ? count + 1 : count, 0) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/components/FlowChart/FlowChart.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | CanvasInnerDefault, CanvasOuterDefault, CanvasWrapper, ICanvasInnerDefaultProps, ICanvasOuterDefaultProps, IChart, IConfig, ILink, 4 | ILinkDefaultProps, INodeDefaultProps, INodeInnerDefaultProps, IOnCanvasClick, IOnCanvasDrop, IOnDeleteKey, IOnDragCanvas, 5 | IOnDragCanvasStop, IOnDragNode, IOnDragNodeStop, IOnLinkCancel, IOnLinkClick, IOnLinkComplete, IOnLinkMouseEnter, 6 | IOnLinkMouseLeave, IOnLinkMove, IOnLinkStart, IOnNodeClick, IOnNodeDoubleClick, IOnNodeMouseEnter, IOnNodeMouseLeave, IOnNodeSizeChange, 7 | IOnPortPositionChange, IOnZoomCanvas, IPortDefaultProps, IPortsDefaultProps, ISelectedOrHovered, LinkDefault, LinkWrapper, NodeDefault, NodeInnerDefault, NodeWrapper, PortDefault, PortsDefault, 8 | } from '../../' 9 | import { getMatrix } from './utils/grid' 10 | 11 | export interface IFlowChartCallbacks { 12 | onDragNode: IOnDragNode 13 | onDragNodeStop: IOnDragNodeStop 14 | onDragCanvas: IOnDragCanvas 15 | onCanvasDrop: IOnCanvasDrop 16 | onDragCanvasStop: IOnDragCanvasStop 17 | onLinkStart: IOnLinkStart 18 | onLinkMove: IOnLinkMove 19 | onLinkComplete: IOnLinkComplete 20 | onLinkCancel: IOnLinkCancel 21 | onPortPositionChange: IOnPortPositionChange 22 | onLinkMouseEnter: IOnLinkMouseEnter 23 | onLinkMouseLeave: IOnLinkMouseLeave 24 | onLinkClick: IOnLinkClick 25 | onCanvasClick: IOnCanvasClick 26 | onDeleteKey: IOnDeleteKey 27 | onNodeClick: IOnNodeClick 28 | onNodeDoubleClick: IOnNodeDoubleClick 29 | onNodeMouseEnter: IOnNodeMouseEnter 30 | onNodeMouseLeave: IOnNodeMouseLeave 31 | onNodeSizeChange: IOnNodeSizeChange 32 | onZoomCanvas: IOnZoomCanvas 33 | } 34 | 35 | export interface IFlowChartComponents { 36 | CanvasOuter?: React.FunctionComponent 37 | CanvasInner?: React.FunctionComponent 38 | NodeInner?: React.FunctionComponent 39 | Ports?: React.FunctionComponent 40 | Port?: React.FunctionComponent 41 | Node?: React.FunctionComponent 42 | Link?: React.FunctionComponent 43 | } 44 | 45 | export interface IFlowChartProps { 46 | /** 47 | * The current chart state 48 | */ 49 | chart: IChart 50 | /** 51 | * Callbacks for updating chart state. 52 | * See container/actions.ts for example state mutations 53 | */ 54 | callbacks: IFlowChartCallbacks 55 | /** 56 | * Custom components 57 | */ 58 | Components?: IFlowChartComponents 59 | /** 60 | * Other config. This will be passed into all components and actions. 61 | * Don't store state here as it may trigger re-renders 62 | */ 63 | config?: IConfig 64 | } 65 | 66 | export const FlowChart = (props: IFlowChartProps) => { 67 | const [ canvasSize, setCanvasSize ] = React.useState<{ width: number, height: number }>({ width: 0, height: 0 }) 68 | 69 | const { 70 | chart, 71 | callbacks: { 72 | onDragNode, 73 | onDragNodeStop, 74 | onDragCanvas, 75 | onDragCanvasStop, 76 | onCanvasDrop, 77 | onLinkStart, 78 | onLinkMove, 79 | onLinkComplete, 80 | onLinkCancel, 81 | onPortPositionChange, 82 | onLinkMouseEnter, 83 | onLinkMouseLeave, 84 | onLinkClick, 85 | onCanvasClick, 86 | onDeleteKey, 87 | onNodeClick, 88 | onNodeDoubleClick, 89 | onNodeMouseEnter, 90 | onNodeMouseLeave, 91 | onNodeSizeChange, 92 | onZoomCanvas, 93 | }, 94 | Components: { 95 | CanvasOuter = CanvasOuterDefault, 96 | CanvasInner = CanvasInnerDefault, 97 | NodeInner = NodeInnerDefault, 98 | Ports = PortsDefault, 99 | Port = PortDefault, 100 | Node = NodeDefault, 101 | Link = LinkDefault, 102 | } = {}, 103 | config = {}, 104 | } = props 105 | const { links, nodes, selected, hovered, offset, scale } = chart 106 | 107 | const canvasCallbacks = { onDragCanvas, onDragCanvasStop, onCanvasClick, onDeleteKey, onCanvasDrop, onZoomCanvas } 108 | const linkCallbacks = { onLinkMouseEnter, onLinkMouseLeave, onLinkClick } 109 | const nodeCallbacks = { onDragNode, onNodeClick, onDragNodeStop, onNodeMouseEnter, onNodeMouseLeave, onNodeSizeChange,onNodeDoubleClick } 110 | const portCallbacks = { onPortPositionChange, onLinkStart, onLinkMove, onLinkComplete, onLinkCancel } 111 | 112 | const nodesInView = Object.keys(nodes).filter((nodeId) => { 113 | const defaultNodeSize = { width: 500, height: 500 } 114 | 115 | const { x, y } = nodes[nodeId].position 116 | const size = nodes[nodeId].size || defaultNodeSize 117 | 118 | const isTooFarLeft = scale * x + offset.x + scale * size.width < 0 119 | const isTooFarRight = scale * x + offset.x > canvasSize.width 120 | const isTooFarUp = scale * y + offset.y + scale * size.height < 0 121 | const isTooFarDown = scale * y + offset.y > canvasSize.height 122 | return !(isTooFarLeft || isTooFarRight || isTooFarUp || isTooFarDown) 123 | }) 124 | 125 | const matrix = config.smartRouting ? getMatrix(chart.offset, Object.values(nodesInView.map((nodeId) => nodes[nodeId]))) : undefined 126 | 127 | const linksInView = Object.keys(links).filter((linkId) => { 128 | const from = links[linkId].from 129 | const to = links[linkId].to 130 | 131 | return ( 132 | !to.nodeId || 133 | nodesInView.indexOf(from.nodeId) !== -1 || 134 | nodesInView.indexOf(to.nodeId) !== -1 135 | ) 136 | }) 137 | 138 | return ( 139 | setCanvasSize({ width, height })} 146 | {...canvasCallbacks} 147 | > 148 | { linksInView.map((linkId) => { 149 | const isSelected = !config.readonly && selected.type === 'link' && selected.id === linkId 150 | const isHovered = !config.readonly && hovered.type === 'link' && hovered.id === linkId 151 | const fromNodeId = links[linkId].from.nodeId 152 | const toNodeId = links[linkId].to.nodeId 153 | 154 | return ( 155 | 167 | ) 168 | })} 169 | { nodesInView.map((nodeId) => { 170 | const isSelected = selected.type === 'node' && selected.id === nodeId 171 | const selectedLink = getSelectedLinkForNode(selected, nodeId, links) 172 | const hoveredLink = getSelectedLinkForNode(hovered, nodeId, links) 173 | 174 | return ( 175 | 192 | ) 193 | }) 194 | } 195 | 196 | ) 197 | } 198 | 199 | const getSelectedLinkForNode = ( 200 | selected: ISelectedOrHovered, 201 | nodeId: string, 202 | links: IChart['links'], 203 | ): ILink | undefined => { 204 | const link = selected.type === 'link' && selected.id ? links[selected.id] : undefined 205 | 206 | if (link && (link.from.nodeId === nodeId || link.to.nodeId === nodeId)) { 207 | return link 208 | } 209 | 210 | return undefined 211 | } 212 | -------------------------------------------------------------------------------- /src/container/actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IChart, 3 | IConfig, 4 | identity, 5 | IOnCanvasClick, 6 | IOnCanvasDrop, 7 | IOnDeleteKey, 8 | IOnDragCanvas, 9 | IOnDragCanvasStop, 10 | IOnDragNode, 11 | IOnDragNodeStop, 12 | IOnLinkCancel, 13 | IOnLinkClick, 14 | IOnLinkComplete, 15 | IOnLinkMouseEnter, 16 | IOnLinkMouseLeave, 17 | IOnLinkMove, 18 | IOnLinkStart, 19 | IOnNodeClick, 20 | IOnNodeDoubleClick, 21 | IOnNodeMouseEnter, 22 | IOnNodeMouseLeave, 23 | IOnNodeSizeChange, 24 | IOnPortPositionChange, 25 | IOnZoomCanvas, 26 | IStateCallback, 27 | } from '../' 28 | import { rotate } from './utils/rotate' 29 | 30 | function getOffset (config: any, data: any, zoom?: number) { 31 | let offset = { x: data.x, y: data.y } 32 | if (config && config.snapToGrid) { 33 | offset = { 34 | x: Math.round(data.x / 20) * 20, 35 | y: Math.round(data.y / 20) * 20, 36 | } 37 | } 38 | if (zoom) { 39 | offset.x = offset.x / zoom 40 | offset.y = offset.y / zoom 41 | } 42 | return offset 43 | } 44 | 45 | /** 46 | * This file contains actions for updating state after each of the required callbacks 47 | */ 48 | 49 | export const onDragNode: IStateCallback = ({ config, event, data, id }) => (chart: IChart) => { 50 | const nodechart = chart.nodes[id] 51 | 52 | if (nodechart) { 53 | const delta = { 54 | x: data.deltaX, 55 | y: data.deltaY, 56 | } 57 | chart.nodes[id] = { 58 | ...nodechart, 59 | position: { 60 | x: nodechart.position.x + delta.x, 61 | y: nodechart.position.y + delta.y, 62 | }, 63 | } 64 | } 65 | 66 | return chart 67 | } 68 | 69 | export const onDragNodeStop: IStateCallback = () => identity 70 | 71 | export const onDragCanvas: IOnDragCanvas = ({ config, data }) => (chart: IChart): IChart => { 72 | chart.offset = getOffset(config, { x: data.positionX, y: data.positionY }) 73 | return chart 74 | } 75 | 76 | export const onDragCanvasStop: IStateCallback = () => identity 77 | 78 | export const onLinkStart: IStateCallback = ({ linkId, fromNodeId, fromPortId }) => ( 79 | chart: IChart, 80 | ): IChart => { 81 | chart.links[linkId] = { 82 | id: linkId, 83 | from: { 84 | nodeId: fromNodeId, 85 | portId: fromPortId, 86 | }, 87 | to: {}, 88 | } 89 | return chart 90 | } 91 | 92 | export const onLinkMove: IStateCallback = ({ linkId, toPosition }) => (chart: IChart): IChart => { 93 | const link = chart.links[linkId] 94 | link.to.position = toPosition 95 | chart.links[linkId] = { ...link } 96 | return chart 97 | } 98 | 99 | export const onLinkComplete: IStateCallback = (props) => { 100 | const { linkId, fromNodeId, fromPortId, toNodeId, toPortId, config = {} } = props 101 | 102 | return (chart: IChart): IChart => { 103 | if ( 104 | !config.readonly && 105 | (config.validateLink ? config.validateLink({ ...props, chart }) : true) && 106 | [fromNodeId, fromPortId].join() !== [toNodeId, toPortId].join() 107 | ) { 108 | chart.links[linkId].to = { 109 | nodeId: toNodeId, 110 | portId: toPortId, 111 | } 112 | } else { 113 | delete chart.links[linkId] 114 | } 115 | return chart 116 | } 117 | } 118 | 119 | export const onLinkCancel: IStateCallback = ({ linkId }) => (chart: IChart) => { 120 | delete chart.links[linkId] 121 | return chart 122 | } 123 | 124 | export const onLinkMouseEnter: IStateCallback = ({ linkId }) => (chart: IChart) => { 125 | // Set the link to hover 126 | const link = chart.links[linkId] 127 | // Set the connected ports to hover 128 | if (link.to.nodeId && link.to.portId) { 129 | if (chart.hovered.type !== 'link' || chart.hovered.id !== linkId) { 130 | chart.hovered = { 131 | type: 'link', 132 | id: linkId, 133 | } 134 | } 135 | } 136 | return chart 137 | } 138 | 139 | export const onLinkMouseLeave: IStateCallback = ({ linkId }) => (chart: IChart) => { 140 | const link = chart.links[linkId] 141 | // Set the connected ports to hover 142 | if (link.to.nodeId && link.to.portId) { 143 | chart.hovered = {} 144 | } 145 | return chart 146 | } 147 | 148 | export const onLinkClick: IStateCallback = ({ linkId }) => (chart: IChart) => { 149 | if (chart.selected.id !== linkId || chart.selected.type !== 'link') { 150 | chart.selected = { 151 | type: 'link', 152 | id: linkId, 153 | } 154 | } 155 | return chart 156 | } 157 | 158 | export const onCanvasClick: IStateCallback = () => (chart: IChart) => { 159 | if (chart.selected.id) { 160 | chart.selected = {} 161 | } 162 | return chart 163 | } 164 | 165 | export const onNodeMouseEnter: IStateCallback = ({ nodeId }) => (chart: IChart) => { 166 | return { 167 | ...chart, 168 | hovered: { 169 | type: 'node', 170 | id: nodeId, 171 | }, 172 | } 173 | } 174 | 175 | export const onNodeMouseLeave: IStateCallback = ({ nodeId }) => (chart: IChart) => { 176 | if (chart.hovered.type === 'node' && chart.hovered.id === nodeId) { 177 | return { ...chart, hovered: {} } 178 | } 179 | return chart 180 | } 181 | 182 | export const onDeleteKey: IStateCallback = ({ config }: IConfig) => (chart: IChart) => { 183 | if (config.readonly) { 184 | return chart 185 | } 186 | if (chart.selected.type === 'node' && chart.selected.id) { 187 | const node = chart.nodes[chart.selected.id] 188 | if (node.readonly) { 189 | return chart 190 | } 191 | // Delete the connected links 192 | Object.keys(chart.links).forEach((linkId) => { 193 | const link = chart.links[linkId] 194 | if (link.from.nodeId === node.id || link.to.nodeId === node.id) { 195 | delete chart.links[link.id] 196 | } 197 | }) 198 | // Delete the node 199 | delete chart.nodes[chart.selected.id] 200 | } else if (chart.selected.type === 'link' && chart.selected.id) { 201 | delete chart.links[chart.selected.id] 202 | } 203 | if (chart.selected) { 204 | chart.selected = {} 205 | } 206 | return chart 207 | } 208 | 209 | export const onNodeClick: IStateCallback = ({ nodeId }) => (chart: IChart) => { 210 | if (chart.selected.id !== nodeId || chart.selected.type !== 'node') { 211 | chart.selected = { 212 | type: 'node', 213 | id: nodeId, 214 | } 215 | } 216 | return chart 217 | } 218 | 219 | export const onNodeDoubleClick: IStateCallback = ({ nodeId }) => (chart: IChart) => { 220 | if (chart.selected.id !== nodeId || chart.selected.type !== 'node') { 221 | chart.selected = { 222 | type: 'node', 223 | id: nodeId, 224 | } 225 | } 226 | return chart 227 | } 228 | 229 | export const onNodeSizeChange: IStateCallback = ({ nodeId, size }) => (chart: IChart) => { 230 | chart.nodes[nodeId] = { 231 | ...chart.nodes[nodeId], 232 | size, 233 | } 234 | return chart 235 | } 236 | 237 | export const onPortPositionChange: IStateCallback = ({ 238 | node: nodeToUpdate, 239 | port, 240 | el, 241 | nodesEl, 242 | }) => (chart: IChart): IChart => { 243 | if (nodeToUpdate.size) { 244 | // rotate the port's position based on the node's orientation prop (angle) 245 | const center = { 246 | x: nodeToUpdate.size.width / 2, 247 | y: nodeToUpdate.size.height / 2, 248 | } 249 | const current = { 250 | x: el.offsetLeft + nodesEl.offsetLeft + el.offsetWidth / 2, 251 | y: el.offsetTop + nodesEl.offsetTop + el.offsetHeight / 2, 252 | } 253 | const angle = nodeToUpdate.orientation || 0 254 | const position = rotate(center, current, angle) 255 | 256 | const node = chart.nodes[nodeToUpdate.id] 257 | node.ports[port.id].position = { 258 | x: position.x, 259 | y: position.y, 260 | } 261 | 262 | chart.nodes[nodeToUpdate.id] = { ...node } 263 | } 264 | 265 | return chart 266 | } 267 | 268 | export const onCanvasDrop: IStateCallback = ({ 269 | config, 270 | data, 271 | position, 272 | id, 273 | }) => (chart: IChart): IChart => { 274 | chart.nodes[id] = { 275 | id, 276 | position: 277 | config && config.snapToGrid 278 | ? { 279 | x: Math.round(position.x / 20) * 20, 280 | y: Math.round(position.y / 20) * 20, 281 | } 282 | : { x: position.x, y: position.y }, 283 | orientation: data.orientation || 0, 284 | type: data.type, 285 | ports: data.ports, 286 | properties: data.properties, 287 | } 288 | return chart 289 | } 290 | 291 | export const onZoomCanvas: IOnZoomCanvas = ({ config, data }) => (chart: IChart): IChart => { 292 | chart.offset = getOffset(config, { x: data.positionX, y: data.positionY }) 293 | chart.scale = data.scale 294 | return chart 295 | } 296 | --------------------------------------------------------------------------------