├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── LICENSE ├── README.md ├── changelog.md ├── dist ├── Connection │ ├── Connection.d.ts │ ├── constant.d.ts │ └── index.d.ts ├── GuideLine.d.ts ├── Marker.d.ts ├── Node │ ├── Circle.d.ts │ ├── Controller.d.ts │ ├── DecisionNode.d.ts │ ├── G.d.ts │ ├── OperationNode.d.ts │ ├── Resizer.d.ts │ ├── StartEndNode.d.ts │ ├── Text.d.ts │ ├── index.d.ts │ └── schema.d.ts ├── PendingConnection.d.ts ├── constant.d.ts ├── index.d.ts ├── index.js ├── package.json ├── schema.d.ts └── util.d.ts ├── package.json ├── pnpm-lock.yaml ├── rollup.config.js ├── src ├── Connection │ ├── constant.ts │ └── index.tsx ├── GuideLine.tsx ├── Marker.tsx ├── Node │ ├── Circle.tsx │ ├── Controller.tsx │ ├── DecisionNode.tsx │ ├── G.tsx │ ├── OperationNode.tsx │ ├── StartEndNode.tsx │ ├── Text.tsx │ ├── index.tsx │ └── schema.ts ├── PendingConnection.tsx ├── constant.tsx ├── index.css ├── index.css.map ├── index.less ├── index.tsx ├── input.css ├── output.css ├── schema.ts └── util.ts ├── tailwind.config.js └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | .snapshots/ 5 | *.min.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | }, 5 | extends: [ 6 | "eslint:recommended", 7 | "plugin:react/recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | ], 10 | parser: "@typescript-eslint/parser", 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | ecmaVersion: 12, 16 | sourceType: "module", 17 | }, 18 | plugins: ["react", "@typescript-eslint", "react-hooks"], 19 | rules: { 20 | "react/prop-types": 0, 21 | "react/display-name": 0, 22 | "react-hooks/rules-of-hooks": "error", 23 | "react-hooks/exhaustive-deps": "error", 24 | "no-eval": 0, 25 | "@typescript-eslint/no-non-null-assertion": 0, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '28 13 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | .rpt2_cache 9 | 10 | # misc 11 | .DS_Store 12 | .env 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | .idea/ 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Joyce 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flowchart React 2 | 3 | > Lightweight flowchart & flowchart designer for React.js 4 | 5 | [![NPM](https://img.shields.io/npm/v/flowchart-react.svg)](https://www.npmjs.com/package/flowchart-react) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 6 | 7 | English | [中文](https://www.joyceworks.com/2022/02/26/flowchart-react-readme-cn/) 8 | 9 | image 10 | 11 | ## Install 12 | 13 | ```bash 14 | npm install --save flowchart-react 15 | # or 16 | yarn add flowchart-react 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```tsx 22 | import React, { useState } from "react"; 23 | import Flowchart from "flowchart-react"; 24 | import { ConnectionData, NodeData } from "flowchart-react/schema"; 25 | 26 | const App = () => { 27 | const [nodes, setNodes] = useState([ 28 | { 29 | type: "start", 30 | title: "Start", 31 | x: 150, 32 | y: 190, 33 | id: 1, 34 | }, 35 | { 36 | type: "end", 37 | title: "End", 38 | x: 500, 39 | y: 190, 40 | id: 2, 41 | }, 42 | { 43 | x: 330, 44 | y: 190, 45 | id: 3, 46 | title: "Joyce", 47 | }, 48 | { 49 | x: 330, 50 | y: 300, 51 | id: 4, 52 | title: () => { 53 | return "No approver"; 54 | }, 55 | }, 56 | ]); 57 | const [conns, setConns] = useState([ 58 | { 59 | source: { id: 1, position: "right" }, 60 | destination: { id: 3, position: "left" }, 61 | }, 62 | { 63 | source: { id: 3, position: "right" }, 64 | destination: { id: 2, position: "left" }, 65 | }, 66 | { 67 | source: { id: 1, position: "bottom" }, 68 | destination: { id: 4, position: "left" }, 69 | }, 70 | { 71 | source: { id: 4, position: "right" }, 72 | destination: { id: 2, position: "bottom" }, 73 | }, 74 | ]); 75 | 76 | return ( 77 | { 79 | setNodes(nodes); 80 | setConns(connections); 81 | }} 82 | style={{ width: 800, height: 600 }} 83 | nodes={nodes} 84 | connections={conns} 85 | /> 86 | ); 87 | }; 88 | 89 | export default App; 90 | ``` 91 | 92 | ## Demo 93 | 94 | - [CodeSandbox](https://codesandbox.io/s/stoic-borg-w626tt) 95 | 96 | ## API 97 | 98 | Flowchart use nodes and connections to describe a flowchart. 99 | 100 | ### Props 101 | 102 | #### nodes: `NodeData[]` 103 | 104 | Array of nodes. 105 | 106 | ##### NodeData 107 | 108 | | Props | Description | Type | Default | Required | 109 | |--------------------|---------------------|:----------------------------------------------------|-------------|----------| 110 | | id | Identity | number | | true | 111 | | title | Title of node | string, `(node: NodeData) => string`, JSX.Element | | true | 112 | | type | Type of node | `start`, `end`, `operation`, `decision` | `operation` | false | 113 | | x | X axis | number | | true | 114 | | y | Y axis | number | | true | 115 | | payload | Custom data | `{[key: string]: unknown}` | | false | 116 | | width | Node width | number | `120` | false | 117 | | height | Node height | number | `60` | false | 118 | | connectionPosition | Connection position | `top`, `bottom` | `top` | false | 119 | | containerProps | | SupportedSVGShapeProps | | false | 120 | | textProps | | SupportedSVGTextProps | | false | 121 | 122 | ##### SupportedSVGShapeProps 123 | 124 | Node shape props, only `fill` and `stroke` are supported, for more information, please refer to [MDN](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute). 125 | 126 | | Props | Description | Type | Default | Required | 127 | |--------|-------------|:-------|---------|----------| 128 | | fill | | string | | false | 129 | | stroke | | string | | false | 130 | 131 | ##### SupportedSVGTextProps 132 | 133 | Node text props, only `fill` is supported, for more information, please refer to [MDN](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute). 134 | 135 | Works when `title` is a string. 136 | 137 | | Props | Description | Type | Default | Required | 138 | |--------|-------------|:-------|---------|----------| 139 | | fill | | string | | false | 140 | 141 | #### connections: `ConnectionData[]` 142 | 143 | Connections between nodes. 144 | 145 | ##### ConnectionData 146 | 147 | Use `type` to describe the type of connection, `success` will draw a green line, `fail` will draw a red line. 148 | 149 | | Props | Description | Type | Default | Required | 150 | |-------------|-----------------------------------------|:-----------------------------------------------------------|-----------|----------| 151 | | type | Type of connection | `success`, `fail` | `success` | false | 152 | | source | Source info | `{id: number, position: 'left', 'right', 'top', 'bottom'}` | | true | 153 | | destination | Destination info | `{id: number, position: 'left', 'right', 'top', 'bottom'}` | | true | 154 | | title | Title of connection | string | | false | 155 | | color | Specify a color for the connection line | string | | false | 156 | 157 | #### readonly: `boolean | undefined` 158 | 159 | Prop to disabled drag, connect and delete nodes. 160 | 161 | #### style: `React.CSSProperties` 162 | 163 | Style of container. 164 | 165 | #### defaultNodeSize: `{width: number, height: number} | undefined` 166 | 167 | Global node size, works when `width` or `height` of node is not set. 168 | 169 | Default: `{ width: 120, height: 60 }`. 170 | 171 | #### showToolbar: `boolean | undefined | ("start-end" | "operation" | "decision")[]` 172 | 173 | `false` to hide toolbar. 174 | 175 | ### Events 176 | 177 | #### onChange: `(nodes: NodeData[], connections: ConnectionData[]) => void` 178 | 179 | Triggered when a node is deleted(click a node and press `delete`), moved, disconnected(click a connection and press `delete`) or connected. 180 | 181 | #### onNodeDoubleClick: `(node: NodeData) => void` 182 | 183 | Triggered when a node is double-clicked. 184 | 185 | > Tip: Double-click to edit. 186 | 187 | #### onDoubleClick: `(event: React.MouseEvent, zoom: number) => void` 188 | 189 | Triggered when the background svg is double-clicked. 190 | 191 | > Tip: Double-click to create a node. 192 | 193 | ```typescript 194 | function handleDoubleClick(event: React.MouseEvent, zoom: number): void { 195 | const point = { 196 | x: event.nativeEvent.offsetX / zoom, 197 | y: event.nativeEvent.offsetY / zoom, 198 | id: +new Date(), 199 | }; 200 | let nodeData: NodeData; 201 | if (!nodes.find((item) => item.type === "start")) { 202 | nodeData = { 203 | type: "start", 204 | title: "Start", 205 | ...point, 206 | }; 207 | } else if (!nodes.find((item) => item.type === "end")) { 208 | nodeData = { 209 | type: "end", 210 | title: "End", 211 | ...point, 212 | }; 213 | } else { 214 | nodeData = { 215 | ...point, 216 | title: "New", 217 | type: "operation", 218 | }; 219 | } 220 | setNodes((prevState) => [...prevState, nodeData]); 221 | } 222 | ``` 223 | 224 | #### onConnectionDoubleClick: `(connection: ConnectionData) => void` 225 | 226 | Triggered when a connection is double-clicked. 227 | 228 | > Tip: Double-click to edit connection. 229 | 230 | #### onMouseUp: `(event: React.MouseEvent, zoom: number) => void` 231 | 232 | Triggered when the mouse is up on the background svg. 233 | 234 | > Tip: Drop something to here to implement node creation. 235 | 236 | #### className: `string | undefined` 237 | 238 | Custom class name of container. 239 | 240 | ## License 241 | 242 | MIT © [Joyceworks](https://github.com/joyceworks) 243 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | ### 2023-08-08 2 | 3 | - Replace Yarn with pnpm. 4 | - Add missing `package.json` to distribution. 5 | -------------------------------------------------------------------------------- /dist/Connection/Connection.d.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ConnectionData, NodeData } from "../schema"; 3 | interface ConnectionProps { 4 | data: ConnectionData; 5 | nodes: NodeData[]; 6 | isSelected: boolean; 7 | onMouseDown: (event: React.MouseEvent) => void; 8 | onDoubleClick?: (event: React.MouseEvent) => void; 9 | } 10 | declare const FlowchartConnection: ({ data, nodes, isSelected, onMouseDown, onDoubleClick, }: ConnectionProps) => JSX.Element; 11 | export default FlowchartConnection; 12 | -------------------------------------------------------------------------------- /dist/Connection/constant.d.ts: -------------------------------------------------------------------------------- 1 | declare const defaultConnectionColors: { 2 | success: string; 3 | fail: string; 4 | }; 5 | declare const selectedConnectionColors: { 6 | success: string; 7 | fail: string; 8 | }; 9 | export { defaultConnectionColors, selectedConnectionColors }; 10 | -------------------------------------------------------------------------------- /dist/Connection/index.d.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ConnectionData, NodeData } from "../schema"; 3 | interface ConnectionProps { 4 | data: ConnectionData; 5 | nodes: NodeData[]; 6 | isSelected: boolean; 7 | onMouseDown: (event: React.MouseEvent) => void; 8 | onDoubleClick?: (event: React.MouseEvent) => void; 9 | } 10 | export declare function Connection({ data, nodes, isSelected, onMouseDown, onDoubleClick, }: ConnectionProps): JSX.Element; 11 | export {}; 12 | -------------------------------------------------------------------------------- /dist/GuideLine.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { Line } from "./schema"; 3 | export declare function GuideLine({ line }: { 4 | line: Line; 5 | }): JSX.Element; 6 | -------------------------------------------------------------------------------- /dist/Marker.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | export declare function Marker({ id, color }: { 3 | id: string; 4 | color: string; 5 | }): JSX.Element; 6 | -------------------------------------------------------------------------------- /dist/Node/Circle.d.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | export default function Circle(props: { 3 | isConnecting: boolean; 4 | } & React.SVGAttributes): JSX.Element; 5 | -------------------------------------------------------------------------------- /dist/Node/Controller.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { Direction, NodeData } from "../schema"; 3 | declare const Controller: ({ data, onMouseDown, }: { 4 | data: NodeData; 5 | onMouseDown: (direction: Direction) => void; 6 | }) => JSX.Element; 7 | export { Controller }; 8 | -------------------------------------------------------------------------------- /dist/Node/DecisionNode.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { NodeProps } from "./schema"; 3 | declare const DecisionNode: ({ data, isSelected }: NodeProps) => JSX.Element; 4 | export { DecisionNode }; 5 | -------------------------------------------------------------------------------- /dist/Node/G.d.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | export default function G(props: React.SVGAttributes): JSX.Element; 3 | -------------------------------------------------------------------------------- /dist/Node/OperationNode.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { NodeProps } from "./schema"; 3 | declare const OperationNode: ({ data, isSelected, }: NodeProps) => JSX.Element; 4 | export default OperationNode; 5 | -------------------------------------------------------------------------------- /dist/Node/Resizer.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { Direction, NodeData } from "../schema"; 3 | declare const Resizer: ({ data, onMouseDown, }: { 4 | data: NodeData; 5 | onMouseDown: (direction: Direction) => void; 6 | }) => JSX.Element; 7 | export { Resizer }; 8 | -------------------------------------------------------------------------------- /dist/Node/StartEndNode.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { NodeProps } from "./schema"; 3 | declare const StartEndNode: ({ data, isSelected, }: NodeProps) => JSX.Element; 4 | export default StartEndNode; 5 | -------------------------------------------------------------------------------- /dist/Node/Text.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { NodeProps } from "./schema"; 3 | declare const Text: ({ data }: NodeProps) => JSX.Element; 4 | export { Text }; 5 | -------------------------------------------------------------------------------- /dist/Node/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ConnectorPosition, Direction, NodeData } from "../schema"; 2 | import React from "react"; 3 | interface NodeProps { 4 | data: NodeData; 5 | isSelected: boolean; 6 | isConnecting: boolean; 7 | onDoubleClick: (event: React.MouseEvent) => void; 8 | onMouseDown: (event: React.MouseEvent) => void; 9 | onConnectorMouseDown: (position: ConnectorPosition) => void; 10 | onControllerMouseDown: (direction: Direction) => void; 11 | readonly?: boolean; 12 | } 13 | declare const Node: ({ data, isSelected, isConnecting, onDoubleClick, onMouseDown, onConnectorMouseDown, onControllerMouseDown, readonly, }: NodeProps) => JSX.Element; 14 | export default Node; 15 | -------------------------------------------------------------------------------- /dist/Node/schema.d.ts: -------------------------------------------------------------------------------- 1 | import { NodeData } from "../schema"; 2 | export interface NodeProps { 3 | data: NodeData; 4 | isSelected?: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /dist/PendingConnection.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | export declare function PendingConnection({ points }: { 3 | points: [number, number][]; 4 | }): JSX.Element; 5 | -------------------------------------------------------------------------------- /dist/constant.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { NodeData } from "./schema"; 3 | export declare const newNode: NodeData; 4 | export declare const templateNode: NodeData; 5 | export declare const iconAlign: JSX.Element; 6 | export declare const iconZoomOut: JSX.Element; 7 | export declare const iconZoomIn: JSX.Element; 8 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FlowchartProps, IFlowchart } from "./schema"; 3 | import "./index.css"; 4 | import "./output.css"; 5 | declare const Flowchart: React.ForwardRefExoticComponent>; 6 | export default Flowchart; 7 | -------------------------------------------------------------------------------- /dist/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flowchart-react", 3 | "version": "3.16.1", 4 | "main": "./dist", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "npx rollup -c rollup.config.js" 8 | }, 9 | "keywords": [ 10 | "flowchart", 11 | "react", 12 | "diagram" 13 | ], 14 | "peerDependencies": { 15 | "immutability-helper": "^3.1.1", 16 | "react": "^16.8.0 || ^17 || ^18", 17 | "react-dom": "^16.8.0 || ^17 || ^18" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "^7.17.2", 21 | "@babel/preset-env": "^7.16.11", 22 | "@babel/preset-react": "^7.16.7", 23 | "@rollup/plugin-babel": "^5.3.0", 24 | "@rollup/plugin-commonjs": "^21.0.1", 25 | "@rollup/plugin-node-resolve": "^13.1.3", 26 | "@rollup/plugin-typescript": "^8.3.0", 27 | "@types/immutability-helper": "^2.6.3", 28 | "@types/react": "^17.0.39", 29 | "@typescript-eslint/eslint-plugin": "^5.12.0", 30 | "@typescript-eslint/parser": "^5.12.0", 31 | "acorn-jsx": "^5.3.2", 32 | "eslint": "^8.9.0", 33 | "eslint-plugin-react": "^7.28.0", 34 | "eslint-plugin-react-hooks": "^4.3.0", 35 | "postcss": "^8.4.6", 36 | "prettier": "^2.5.1", 37 | "rollup-plugin-copy": "^3.4.0", 38 | "rollup-plugin-postcss": "^4.0.2", 39 | "tailwindcss": "^3.0.24", 40 | "tslib": "^2.3.1", 41 | "typescript": "^4.5.5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /dist/schema.d.ts: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from "react"; 2 | export interface NodeData { 3 | id: number; 4 | title: string | (() => string) | JSX.Element; 5 | type?: NodeType; 6 | x: number; 7 | y: number; 8 | payload?: { 9 | [key: string]: unknown; 10 | }; 11 | width?: number; 12 | height?: number; 13 | containerProps?: SupportedSVGShapeProps; 14 | textProps?: SupportedSVGTextProps; 15 | } 16 | export declare type SupportedSVGShapeProps = Pick, "fill" | "stroke">; 17 | export declare type SupportedSVGTextProps = Pick, "fill">; 18 | export interface ConnectionData { 19 | type?: "success" | "fail"; 20 | source: { 21 | id: number; 22 | position: ConnectorPosition; 23 | }; 24 | destination: { 25 | id: number; 26 | position: ConnectorPosition; 27 | }; 28 | title?: string; 29 | color?: string; 30 | } 31 | export interface Point { 32 | x: number; 33 | y: number; 34 | } 35 | export declare type Line = [Point, Point]; 36 | export interface SelectingInfo { 37 | start: Point; 38 | end: Point; 39 | } 40 | export declare type ConnectorPosition = "left" | "right" | "top" | "bottom"; 41 | export declare type NodeType = "start" | "end" | "operation" | "decision"; 42 | export interface FlowchartProps { 43 | style?: CSSProperties; 44 | nodes: NodeData[]; 45 | connections: ConnectionData[]; 46 | onNodeDoubleClick?: (data: NodeData) => void; 47 | onChange?: (nodes: NodeData[], connections: ConnectionData[]) => void; 48 | onConnectionDoubleClick?: (data: ConnectionData) => void; 49 | onDoubleClick?: ((event: React.MouseEvent, zoom: number) => void) | undefined; 50 | onMouseUp?: ((event: React.MouseEvent, zoom: number) => void) | undefined; 51 | readonly?: boolean; 52 | defaultNodeSize?: { 53 | width: number; 54 | height: number; 55 | }; 56 | showToolbar?: boolean | ("start-end" | "operation" | "decision")[]; 57 | connectionPosition?: "bottom" | "top"; 58 | /** 59 | * Custom class name for the flowchart container 60 | */ 61 | className?: string; 62 | } 63 | export interface DragMovingInfo { 64 | targetIds: number[]; 65 | deltas: { 66 | x: number; 67 | y: number; 68 | }[]; 69 | moved?: true; 70 | } 71 | export interface DragCreatingInfo { 72 | type: NodeType; 73 | x: number; 74 | y: number; 75 | } 76 | export interface DragConnectingInfo { 77 | source: NodeData; 78 | sourcePosition: ConnectorPosition; 79 | } 80 | export interface IFlowchart { 81 | getData: () => { 82 | nodes: NodeData[]; 83 | connections: ConnectionData[]; 84 | }; 85 | } 86 | export interface ControlInfo { 87 | targetId: number; 88 | direction: Direction; 89 | } 90 | export declare type Direction = "l" | "r" | "u" | "d" | "lu" | "ru" | "ld" | "rd"; 91 | -------------------------------------------------------------------------------- /dist/util.d.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionData, ConnectorPosition, Direction, Line, NodeData, Point } from "./schema"; 2 | declare function pathing(p1: Point, p2: Point, startPosition: ConnectorPosition, endPosition: ConnectorPosition | null): [number, number][]; 3 | declare function calcDirection(p1: Point, p2: Point): Direction; 4 | declare function distanceOfP2P(p1: Point, p2: Point): number; 5 | declare function distanceOfP2L(point: Point, line: Line): number; 6 | declare function between(num1: number, num2: number, num: number): boolean; 7 | declare function approximatelyEquals(n: number, m: number): boolean; 8 | declare function calcCorners(points: Point[]): { 9 | start: Point; 10 | end: Point; 11 | }; 12 | declare function center(nodes: NodeData[], width: number, height: number): NodeData[]; 13 | declare function isIntersected(p: Point, rect: { 14 | start: Point; 15 | end: Point; 16 | }): boolean; 17 | declare function roundTo10(number: number): number; 18 | declare function locateConnector(node: NodeData): { 19 | left: Point; 20 | right: Point; 21 | top: Point; 22 | bottom: Point; 23 | }; 24 | /** 25 | * Get angle positions: top-left, top-right, bottom-right, bottom-left 26 | * @param node 27 | */ 28 | declare function locateAngle(node: Pick): [Point, Point, Point, Point]; 29 | declare function calcIntersectedConnections(internalNodes: NodeData[], internalConnections: ConnectionData[], rect: { 30 | start: Point; 31 | end: Point; 32 | }): ConnectionData[]; 33 | declare function calcIntersectedNodes(internalNodes: NodeData[], edge: { 34 | start: Point; 35 | end: Point; 36 | }): NodeData[]; 37 | declare function createConnection(sourceId: number, sourcePosition: ConnectorPosition, destinationId: number, destinationPosition: ConnectorPosition): ConnectionData; 38 | export declare function calcGuidelines(node: Pick, nodes: NodeData[]): Line[]; 39 | export { isIntersected, distanceOfP2L, distanceOfP2P, calcDirection, calcCorners, between, pathing, approximatelyEquals, locateConnector, locateAngle, calcIntersectedConnections, calcIntersectedNodes, createConnection, roundTo10, center, }; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flowchart-react", 3 | "version": "3.16.1", 4 | "main": "./dist", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "npx rollup -c rollup.config.js" 8 | }, 9 | "keywords": [ 10 | "flowchart", 11 | "react", 12 | "diagram" 13 | ], 14 | "peerDependencies": { 15 | "immutability-helper": "^3.1.1", 16 | "react": "^16.8.0 || ^17 || ^18", 17 | "react-dom": "^16.8.0 || ^17 || ^18" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "^7.17.2", 21 | "@babel/preset-env": "^7.16.11", 22 | "@babel/preset-react": "^7.16.7", 23 | "@rollup/plugin-babel": "^5.3.0", 24 | "@rollup/plugin-commonjs": "^21.0.1", 25 | "@rollup/plugin-node-resolve": "^13.1.3", 26 | "@rollup/plugin-typescript": "^8.3.0", 27 | "@types/immutability-helper": "^2.6.3", 28 | "@types/react": "^17.0.39", 29 | "@typescript-eslint/eslint-plugin": "^5.12.0", 30 | "@typescript-eslint/parser": "^5.12.0", 31 | "acorn-jsx": "^5.3.2", 32 | "eslint": "^8.9.0", 33 | "eslint-plugin-react": "^7.28.0", 34 | "eslint-plugin-react-hooks": "^4.3.0", 35 | "postcss": "^8.4.31", 36 | "prettier": "^2.5.1", 37 | "rollup-plugin-copy": "^3.4.0", 38 | "rollup-plugin-postcss": "^4.0.2", 39 | "tailwindcss": "^3.0.24", 40 | "tslib": "^2.3.1", 41 | "typescript": "^4.5.5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import babel, { getBabelOutputPlugin } from "@rollup/plugin-babel"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | import jsx from "acorn-jsx"; 5 | import typescript from "@rollup/plugin-typescript"; 6 | import postcss from "rollup-plugin-postcss"; 7 | import copy from "rollup-plugin-copy"; 8 | 9 | export default { 10 | input: "src/index.tsx", 11 | acornInjectPlugins: [jsx()], 12 | external: "react", 13 | plugins: [ 14 | postcss({ 15 | extensions: [".css"], 16 | }), 17 | resolve(), 18 | commonjs(), 19 | typescript({ jsx: "preserve" }), 20 | babel({ 21 | presets: [["@babel/preset-react", { runtime: "automatic" }]], 22 | babelHelpers: "bundled", 23 | extensions: [".js", ".jsx", ".es6", ".es", ".mjs", ".ts", ".tsx"], 24 | }), 25 | copy({ 26 | targets: [{ src: "package.json", dest: "dist" }], 27 | }), 28 | ], 29 | output: { 30 | dir: "dist", 31 | format: "esm", 32 | plugins: [ 33 | getBabelOutputPlugin({ 34 | presets: ["@babel/preset-env"], 35 | }), 36 | ], 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/Connection/constant.ts: -------------------------------------------------------------------------------- 1 | const defaultConnectionColors = { 2 | success: "#52c41a", 3 | fail: "red", 4 | }; 5 | 6 | const selectedConnectionColors = { 7 | success: "#12640a", 8 | fail: "darkred", 9 | }; 10 | export { defaultConnectionColors, selectedConnectionColors }; 11 | -------------------------------------------------------------------------------- /src/Connection/index.tsx: -------------------------------------------------------------------------------- 1 | import { locateConnector, pathing } from "../util"; 2 | import React, { useCallback, useMemo } from "react"; 3 | import { ConnectionData, ConnectorPosition, NodeData } from "../schema"; 4 | import { defaultConnectionColors, selectedConnectionColors } from "./constant"; 5 | 6 | interface ConnectionProps { 7 | data: ConnectionData; 8 | nodes: NodeData[]; 9 | isSelected: boolean; 10 | onMouseDown: (event: React.MouseEvent) => void; 11 | onDoubleClick?: (event: React.MouseEvent) => void; 12 | } 13 | 14 | export function Connection({ 15 | data, 16 | nodes, 17 | isSelected, 18 | onMouseDown, 19 | onDoubleClick, 20 | }: ConnectionProps): JSX.Element { 21 | const getNodeConnectorOffset = useCallback( 22 | (nodeId: number, connectorPosition: ConnectorPosition) => { 23 | const node = nodes.filter((item) => item.id === nodeId)[0]; 24 | return locateConnector(node)[connectorPosition]; 25 | }, 26 | [nodes] 27 | ); 28 | const points = pathing( 29 | getNodeConnectorOffset(data.source.id, data.source.position), 30 | getNodeConnectorOffset(data.destination.id, data.destination.position), 31 | data.source.position, 32 | data.destination.position 33 | ); 34 | const colors = useMemo((): { success: string; fail: string } => { 35 | return isSelected ? selectedConnectionColors : defaultConnectionColors; 36 | }, [isSelected]); 37 | let center: number[]; 38 | if (points.length % 2 === 0) { 39 | const start = points[points.length / 2 - 1]; 40 | const end = points[points.length / 2]; 41 | center = [ 42 | Math.min(start[0], end[0]) + Math.abs(end[0] - start[0]) / 2, 43 | Math.min(start[1], end[1]) + Math.abs(end[1] - start[1]) / 2, 44 | ]; 45 | } else { 46 | center = points[(points.length - 1) / 2]; 47 | } 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | {points.map((point, i) => { 61 | if (i > points.length - 2) { 62 | return <>; 63 | } 64 | 65 | const source = point; 66 | const destination = points[i + 1]; 67 | const isLast = i === points.length - 2; 68 | const type = data.type || "success"; 69 | const color = data.color || colors[type]; 70 | const id = `arrow${color.replace("#", "")}`; 71 | return ( 72 | <> 73 | 80 | {isLast && ( 81 | 91 | 92 | 93 | )} 94 | { 97 | event.stopPropagation(); 98 | onDoubleClick?.(event); 99 | }} 100 | stroke={"transparent"} 101 | strokeWidth={5} 102 | fill={"none"} 103 | d={`M ${source[0]} ${source[1]} L ${destination[0]} ${destination[1]}`} 104 | /> 105 | {data.title ? ( 106 | 114 | {data.title} 115 | 116 | ) : ( 117 | <> 118 | )} 119 | 120 | ); 121 | })} 122 | 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /src/GuideLine.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Line } from "./schema"; 3 | 4 | export function GuideLine({ line }: { line: Line }): JSX.Element { 5 | return ( 6 | 7 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/Marker.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function Marker({ id, color }: { id: string; color: string }) { 4 | return ( 5 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/Node/Circle.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, useMemo } from "react"; 2 | 3 | export default function Circle( 4 | props: { 5 | isConnecting: boolean; 6 | } & React.SVGAttributes 7 | ): JSX.Element { 8 | const style = useMemo(() => { 9 | if (!props.isConnecting) { 10 | return {}; 11 | } 12 | return { 13 | opacity: 1, 14 | }; 15 | }, [props.isConnecting]); 16 | return ( 17 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/Node/Controller.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Direction, NodeData } from "../schema"; 3 | 4 | const strokeProps: React.SVGProps = { 5 | strokeWidth: 1, 6 | stroke: "lightblue", 7 | }; 8 | const props: React.SVGProps = { 9 | width: 6, 10 | height: 6, 11 | fill: "white", 12 | ...strokeProps, 13 | }; 14 | const Controller = function ({ 15 | data, 16 | onMouseDown, 17 | }: { 18 | data: NodeData; 19 | onMouseDown: (direction: Direction) => void; 20 | }) { 21 | return ( 22 | <> 23 | 31 | {/* Top left */} 32 | { 38 | event.stopPropagation(); 39 | onMouseDown("lu"); 40 | }} 41 | /> 42 | {/* Top */} 43 | { 49 | event.stopPropagation(); 50 | onMouseDown("u"); 51 | }} 52 | /> 53 | { 59 | event.stopPropagation(); 60 | onMouseDown("ld"); 61 | }} 62 | /> 63 | {/* Right */} 64 | { 70 | event.stopPropagation(); 71 | onMouseDown("l"); 72 | }} 73 | /> 74 | {/* Down */} 75 | { 81 | event.stopPropagation(); 82 | onMouseDown("d"); 83 | }} 84 | /> 85 | {/* Left */} 86 | { 92 | event.stopPropagation(); 93 | onMouseDown("r"); 94 | }} 95 | /> 96 | { 102 | event.stopPropagation(); 103 | onMouseDown("ru"); 104 | }} 105 | /> 106 | { 112 | event.stopPropagation(); 113 | onMouseDown("rd"); 114 | }} 115 | /> 116 | 117 | ); 118 | }; 119 | 120 | export { Controller }; 121 | -------------------------------------------------------------------------------- /src/Node/DecisionNode.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NodeProps } from "./schema"; 3 | import { SupportedSVGShapeProps } from "../schema"; 4 | import { Text } from "./Text"; 5 | 6 | const DecisionNode = function ({ data, isSelected }: NodeProps) { 7 | const borderColor = isSelected ? "#666666" : "#bbbbbb"; 8 | const width = data.width || 120; 9 | const halfWidth = width / 2; 10 | const height = data.height || 60; 11 | const halfHeight = height / 2; 12 | const top = `${data.x + halfWidth},${data.y}`; 13 | const bottom = `${data.x + halfWidth},${data.y + height}`; 14 | const left = `${data.x},${data.y + halfHeight}`; 15 | const right = `${data.x + width},${data.y + halfHeight}`; 16 | return ( 17 | <> 18 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export { DecisionNode }; 31 | -------------------------------------------------------------------------------- /src/Node/G.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function G( 4 | props: React.SVGAttributes 5 | ): JSX.Element { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /src/Node/OperationNode.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NodeProps } from "./schema"; 3 | import { SupportedSVGShapeProps } from "../schema"; 4 | import { Text } from "./Text"; 5 | 6 | const OperationNode = function ({ 7 | data, 8 | isSelected = false, 9 | }: NodeProps): JSX.Element { 10 | const borderColor = isSelected ? "#666666" : "#bbbbbb"; 11 | return ( 12 | <> 13 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default OperationNode; 29 | -------------------------------------------------------------------------------- /src/Node/StartEndNode.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NodeProps } from "./schema"; 3 | import { SupportedSVGShapeProps } from "../schema"; 4 | import { Text } from "./Text"; 5 | 6 | const StartEndNode = function ({ 7 | data, 8 | isSelected = false, 9 | }: NodeProps): JSX.Element { 10 | const borderColor = isSelected ? "#666666" : "#bbbbbb"; 11 | const halfWidth = (data.width || 120) / 2; 12 | const halfHeight = (data.height || 60) / 2; 13 | return ( 14 | <> 15 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default StartEndNode; 31 | -------------------------------------------------------------------------------- /src/Node/Text.tsx: -------------------------------------------------------------------------------- 1 | import { SupportedSVGTextProps } from "../schema"; 2 | import React from "react"; 3 | import { NodeProps } from "./schema"; 4 | 5 | const Text = function ({ data }: NodeProps) { 6 | const text = (typeof data.title === "function" && data.title()) || data.title; 7 | return ( 8 | 16 |
28 | {text} 29 |
30 |
31 | ); 32 | }; 33 | 34 | export { Text }; 35 | -------------------------------------------------------------------------------- /src/Node/index.tsx: -------------------------------------------------------------------------------- 1 | import { ConnectorPosition, Direction, NodeData } from "../schema"; 2 | import OperationNode from "./OperationNode"; 3 | import StartEndNode from "./StartEndNode"; 4 | import React, { useMemo } from "react"; 5 | import { locateConnector } from "../util"; 6 | import G from "./G"; 7 | import Circle from "./Circle"; 8 | import { DecisionNode } from "./DecisionNode"; 9 | import { Controller } from "./Controller"; 10 | 11 | interface NodeProps { 12 | data: NodeData; 13 | isSelected: boolean; 14 | isConnecting: boolean; 15 | onDoubleClick: (event: React.MouseEvent) => void; 16 | onMouseDown: (event: React.MouseEvent) => void; 17 | onConnectorMouseDown: (position: ConnectorPosition) => void; 18 | onControllerMouseDown: (direction: Direction) => void; 19 | readonly?: boolean; 20 | } 21 | 22 | const Node = function ({ 23 | data, 24 | isSelected, 25 | isConnecting, 26 | onDoubleClick, 27 | onMouseDown, 28 | onConnectorMouseDown, 29 | onControllerMouseDown, 30 | readonly, 31 | }: NodeProps) { 32 | const position = useMemo(() => locateConnector(data), [data]); 33 | return ( 34 | <> 35 | 36 | {data.type === "operation" || !data.type ? ( 37 | 38 | ) : data.type === "start" || data.type === "end" ? ( 39 | 40 | ) : ( 41 | 42 | )} 43 | {!readonly && 44 | Object.keys(position).map((key) => { 45 | return ( 46 | { 53 | event.stopPropagation(); 54 | onConnectorMouseDown(key as ConnectorPosition); 55 | }} 56 | /> 57 | ); 58 | })} 59 | {isSelected && !readonly ? ( 60 | 61 | ) : ( 62 | <> 63 | )} 64 | 65 | 66 | ); 67 | }; 68 | 69 | export default Node; 70 | -------------------------------------------------------------------------------- /src/Node/schema.ts: -------------------------------------------------------------------------------- 1 | import { NodeData } from "../schema"; 2 | 3 | export interface NodeProps { 4 | data: NodeData; 5 | isSelected?: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/PendingConnection.tsx: -------------------------------------------------------------------------------- 1 | import { defaultConnectionColors } from "./Connection/constant"; 2 | import { Marker } from "./Marker"; 3 | import React from "react"; 4 | 5 | export function PendingConnection({ points }: { points: [number, number][] }) { 6 | return ( 7 | 8 | {points!.map((point, i) => { 9 | if (i > points!.length - 2) { 10 | return <>; 11 | } 12 | 13 | const source = points![i]; 14 | const destination = points![i + 1]; 15 | const isLast = i === points!.length - 2; 16 | const color = defaultConnectionColors.success; 17 | const id = `arrow${color.replace("#", "")}`; 18 | return ( 19 | <> 20 | 27 | {isLast && } 28 | 34 | 35 | ); 36 | })} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/constant.tsx: -------------------------------------------------------------------------------- 1 | import { NodeData } from "./schema"; 2 | import React from "react"; 3 | 4 | export const newNode: NodeData = { 5 | id: 0, 6 | title: "New Item", 7 | type: "start", 8 | x: 0, 9 | y: 0, 10 | }; 11 | 12 | export const templateNode: NodeData = { 13 | id: 0, 14 | title: "", 15 | type: "start", 16 | x: 8, 17 | y: 8, 18 | width: 32, 19 | height: 16, 20 | }; 21 | 22 | export const iconAlign = ( 23 | 34 | ); 35 | 36 | export const iconZoomOut = 51 | 52 | export const iconZoomIn = 63 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | .flowchart-container { 2 | position: relative; 3 | } 4 | .flowchart-container text { 5 | moz-user-select: -moz-none; 6 | -moz-user-select: none; 7 | -o-user-select: none; 8 | -khtml-user-select: none; 9 | -webkit-user-select: none; 10 | -ms-user-select: none; 11 | user-select: none; 12 | } 13 | .flowchart-toolbar { 14 | width: 48px; 15 | height: 100%; 16 | border-left: 1px solid #dfdfdf; 17 | border-top: 1px solid #dfdfdf; 18 | border-bottom: 1px solid #dfdfdf; 19 | } 20 | .flowchart-toolbar-item { 21 | width: 48px; 22 | height: 24px; 23 | } 24 | .flowchart-svg { 25 | height: 100%; 26 | width: 100%; 27 | border: 1px solid #dfdfdf; 28 | background-color: #f3f3f3; 29 | } 30 | .circle { 31 | fill: white; 32 | stroke-width: 1px; 33 | stroke: #1890ff; 34 | cursor: crosshair; 35 | opacity: 0; 36 | } 37 | .circle:hover { 38 | opacity: 1; 39 | } 40 | .g:hover .circle { 41 | opacity: 1; 42 | } 43 | /*# sourceMappingURL=index.css.map */ 44 | -------------------------------------------------------------------------------- /src/index.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["index.less"],"names":[],"mappings":"AAAA;EACE,kBAAA;;AADF,oBAGE;EACE,0BAAA;EACA,sBAAA;EACA,oBAAA;EACA,wBAAA;EACA,yBAAA;EACA,qBAAA;EACA,iBAAA;;AAIJ;EACE,WAAA;EACA,YAAA;EACA,8BAAA;EACA,6BAAA;EACA,gCAAA;;AAGF;EACE,WAAA;EACA,YAAA;;AAGF;EACE,YAAA;EACA,WAAA;EACA,yBAAA;EACA,yBAAA;;AAGF;EACE,WAAA;EACA,iBAAA;EACA,eAAA;EACA,iBAAA;EACA,UAAA;;AAEA,OAAC;EACC,UAAA;;AAKF,EAAC,MACC;EACE,UAAA","file":"index.css"} -------------------------------------------------------------------------------- /src/index.less: -------------------------------------------------------------------------------- 1 | .flowchart-container { 2 | position: relative; 3 | 4 | text { 5 | moz-user-select: -moz-none; 6 | -moz-user-select: none; 7 | -o-user-select: none; 8 | -khtml-user-select: none; 9 | -webkit-user-select: none; 10 | -ms-user-select: none; 11 | user-select: none; 12 | } 13 | } 14 | 15 | .flowchart-toolbar { 16 | width: 48px; 17 | height: 100%; 18 | border-left: 1px solid #dfdfdf; 19 | border-top: 1px solid #dfdfdf; 20 | border-bottom: 1px solid #dfdfdf; 21 | } 22 | 23 | .flowchart-toolbar-item { 24 | width: 48px; 25 | height: 24px; 26 | } 27 | 28 | .flowchart-svg { 29 | height: 100%; 30 | width: 100%; 31 | border: 1px solid #dfdfdf; 32 | background-color: #f3f3f3; 33 | } 34 | 35 | .circle { 36 | fill: white; 37 | stroke-width: 1px; 38 | stroke: #1890ff; 39 | cursor: crosshair; 40 | opacity: 0; 41 | 42 | &:hover { 43 | opacity: 1; 44 | } 45 | } 46 | 47 | .g { 48 | &:hover { 49 | .circle { 50 | opacity: 1; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | Ref, 4 | useCallback, 5 | useImperativeHandle, 6 | useMemo, 7 | useRef, 8 | useState, 9 | } from "react"; 10 | import update from "immutability-helper"; 11 | import { 12 | ConnectorPosition, 13 | DragConnectingInfo, 14 | DragCreatingInfo, 15 | DragMovingInfo, 16 | ControlInfo, 17 | FlowchartProps, 18 | IFlowchart, 19 | Line, 20 | NodeData, 21 | NodeType, 22 | Point, 23 | SelectingInfo, 24 | } from "./schema"; 25 | import { 26 | calcCorners, 27 | calcGuidelines, 28 | calcIntersectedConnections, 29 | calcIntersectedNodes, 30 | center, 31 | createConnection, 32 | distanceOfP2P, 33 | locateConnector, 34 | pathing, 35 | roundTo10, 36 | } from "./util"; 37 | import Node from "./Node"; 38 | import { Connection } from "./Connection"; 39 | import "./index.css"; 40 | import "./output.css"; 41 | import StartEndNode from "./Node/StartEndNode"; 42 | import OperationNode from "./Node/OperationNode"; 43 | import { iconAlign, newNode, templateNode } from "./constant"; 44 | import { GuideLine } from "./GuideLine"; 45 | import { PendingConnection } from "./PendingConnection"; 46 | import { DecisionNode } from "./Node/DecisionNode"; 47 | 48 | const Flowchart = forwardRef( 49 | ( 50 | { 51 | nodes, 52 | connections, 53 | readonly = false, 54 | onNodeDoubleClick, 55 | onConnectionDoubleClick: onConnDoubleClick, 56 | onDoubleClick, 57 | onChange, 58 | onMouseUp, 59 | style, 60 | defaultNodeSize = { width: 120, height: 60 }, 61 | showToolbar, 62 | // You can set connectionPosition to "bottom" to make the connection line behind the node 63 | connectionPosition = "top", 64 | className, 65 | }: FlowchartProps, 66 | ref: Ref 67 | ) => { 68 | const svgRef = useRef(null); 69 | const containerRef = useRef(null); 70 | const [selectedNodeIds, setSelectedNodeIds] = useState([]); 71 | const [selectedConnIds, setSelectedConnIds] = useState([]); 72 | const [selectingInfo, setSelectingInfo] = useState(); 73 | const [connectingInfo, setConnectingInfo] = useState(); 74 | const [controlInfo, setControlInfo] = useState(); 75 | const [movingInfo, setMovingInfo] = useState(); 76 | const [creatingInfo, setCreatingInfo] = useState(); 77 | const [zoom, setZoom] = useState(1); 78 | const internalCenter = useCallback(() => { 79 | if (!svgRef.current) { 80 | return; 81 | } 82 | 83 | onChange?.( 84 | center(nodes, svgRef.current.clientWidth, svgRef.current.clientHeight), 85 | connections 86 | ); 87 | }, [connections, nodes, onChange]); 88 | const zoomIn = useCallback(() => { 89 | setZoom((prevState) => { 90 | const number = Number((prevState - 0.1).toFixed(1)); 91 | return number < 0.1 ? 0.1 : number; 92 | }); 93 | }, []); 94 | const zoomOut = useCallback(() => { 95 | setZoom((prevState) => { 96 | const number = Number((prevState + 0.1).toFixed(1)); 97 | return number > 1 ? 1 : number; 98 | }); 99 | }, []); 100 | const [offsetOfCursorToSVG, setOffsetOfCursorToSVG] = useState({ 101 | x: 0, 102 | y: 0, 103 | }); 104 | const handleWheel = useCallback( 105 | (event: React.WheelEvent) => { 106 | event.stopPropagation(); 107 | if (event.ctrlKey || event.metaKey) { 108 | if (event.deltaY > 0 && zoom === 0.1) { 109 | return; 110 | } 111 | 112 | setZoom((prev) => { 113 | const number = Number((prev - event.deltaY / 100 / 10).toFixed(1)); 114 | return number < 0.6 ? 0.6 : number > 1 ? 1 : number; 115 | }); 116 | } 117 | }, 118 | [zoom] 119 | ); 120 | const handleSVGDoubleClick = useCallback( 121 | (event) => onDoubleClick?.(event, zoom), 122 | [onDoubleClick, zoom] 123 | ); 124 | const handleSVGMouseDown = useCallback( 125 | (event) => { 126 | if (event.ctrlKey || event.metaKey || event.target.tagName !== "svg") { 127 | // ignore propagation 128 | return; 129 | } 130 | 131 | if (event.nativeEvent.button !== 0) { 132 | return; 133 | } 134 | 135 | const point = { 136 | x: event.nativeEvent.offsetX / zoom, 137 | y: event.nativeEvent.offsetY / zoom, 138 | }; 139 | setSelectingInfo({ start: point, end: point }); 140 | setSelectedNodeIds([]); 141 | setSelectedConnIds([]); 142 | }, 143 | [zoom] 144 | ); 145 | const moveTo = useCallback( 146 | (nodes: NodeData[], id: number, x: number, y: number): NodeData[] => { 147 | const index = nodes.findIndex((internalNode) => internalNode.id === id); 148 | return update(nodes, { [index]: { x: { $set: x }, y: { $set: y } } }); 149 | }, 150 | [] 151 | ); 152 | const move = useCallback( 153 | (nodeIds: number[], x: number, y: number) => { 154 | if (readonly) { 155 | return; 156 | } 157 | 158 | const indexes = nodeIds.map((currentNode) => 159 | nodes.findIndex((internalNode) => internalNode.id === currentNode) 160 | ); 161 | 162 | let tempState = nodes; 163 | for (const index of indexes) { 164 | tempState = update(tempState, { 165 | [index]: { 166 | x: { $apply: (prev) => prev + x }, 167 | y: { $apply: (prev) => prev + y }, 168 | }, 169 | }); 170 | } 171 | onChange?.(tempState, connections); 172 | }, 173 | [connections, nodes, onChange, readonly] 174 | ); 175 | const handleSVGMouseMove = useCallback( 176 | (event) => { 177 | const newOffsetOfCursorToSVG: Point = { 178 | x: event.nativeEvent.offsetX / zoom, 179 | y: event.nativeEvent.offsetY / zoom, 180 | }; 181 | 182 | setOffsetOfCursorToSVG(newOffsetOfCursorToSVG); 183 | 184 | if (selectingInfo) { 185 | setSelectingInfo((prevState) => ({ 186 | start: prevState!.start, 187 | end: newOffsetOfCursorToSVG, 188 | })); 189 | 190 | const edge = calcCorners([ 191 | selectingInfo.start, 192 | newOffsetOfCursorToSVG, 193 | ]); 194 | setSelectedNodeIds( 195 | calcIntersectedNodes(nodes, edge).map((item) => item.id) 196 | ); 197 | setSelectedConnIds( 198 | calcIntersectedConnections(nodes, connections, edge).map( 199 | (item, index) => index 200 | ) 201 | ); 202 | } else if (movingInfo) { 203 | let currentNodes: NodeData[] = nodes; 204 | for (let i = 0; i < movingInfo.targetIds.length; i++) { 205 | const t = movingInfo.targetIds[i]; 206 | const delta = movingInfo.deltas[i]; 207 | currentNodes = moveTo( 208 | currentNodes, 209 | t, 210 | newOffsetOfCursorToSVG.x - delta.x, 211 | newOffsetOfCursorToSVG.y - delta.y 212 | ); 213 | } 214 | onChange?.(currentNodes, connections); 215 | setMovingInfo((prevState) => ({ ...prevState!, moved: true })); 216 | } else if (controlInfo) { 217 | const index = nodes.findIndex( 218 | (it) => it.id === controlInfo.targetId 219 | )!; 220 | const node = nodes[index]!; 221 | let patch: { x: number; y: number; width: number; height: number }; 222 | const finalWidth = node.width || 120; 223 | const finalHeight = node.height || 60; 224 | const maxX = node.x + finalWidth; 225 | const maxY = node.y + finalHeight; 226 | switch (controlInfo.direction) { 227 | case "u": 228 | patch = { 229 | x: node.x, 230 | y: newOffsetOfCursorToSVG.y, 231 | width: finalWidth, 232 | height: maxY - newOffsetOfCursorToSVG.y, 233 | }; 234 | break; 235 | case "d": 236 | patch = { 237 | x: node.x, 238 | y: node.y, 239 | width: finalWidth, 240 | height: newOffsetOfCursorToSVG.y - node.y, 241 | }; 242 | break; 243 | case "l": 244 | patch = { 245 | x: newOffsetOfCursorToSVG.x, 246 | y: node.y, 247 | width: maxX - newOffsetOfCursorToSVG.x, 248 | height: finalHeight, 249 | }; 250 | break; 251 | case "r": 252 | patch = { 253 | x: node.x, 254 | y: node.y, 255 | width: newOffsetOfCursorToSVG.x - node.x, 256 | height: finalHeight, 257 | }; 258 | break; 259 | case "lu": 260 | patch = { 261 | x: newOffsetOfCursorToSVG.x, 262 | y: newOffsetOfCursorToSVG.y, 263 | width: maxX - newOffsetOfCursorToSVG.x, 264 | height: maxY - newOffsetOfCursorToSVG.y, 265 | }; 266 | break; 267 | case "ru": 268 | patch = { 269 | x: node.x, 270 | y: newOffsetOfCursorToSVG.y, 271 | width: newOffsetOfCursorToSVG.x - node.x, 272 | height: maxY - newOffsetOfCursorToSVG.y, 273 | }; 274 | break; 275 | case "ld": 276 | patch = { 277 | x: newOffsetOfCursorToSVG.x, 278 | y: node.y, 279 | width: maxX - newOffsetOfCursorToSVG.x, 280 | height: newOffsetOfCursorToSVG.y - node.y, 281 | }; 282 | break; 283 | default: 284 | patch = { 285 | x: node.x, 286 | y: node.y, 287 | width: newOffsetOfCursorToSVG.x - node.x, 288 | height: newOffsetOfCursorToSVG.y - node.y, 289 | }; 290 | break; 291 | } 292 | if (patch.x >= maxX) { 293 | patch.x = maxX - 10; 294 | patch.width = 10; 295 | } 296 | if (patch.y >= maxY) { 297 | patch.y = maxY - 10; 298 | patch.height = 10; 299 | } 300 | if (patch.width <= 0) { 301 | patch.width = 10; 302 | } 303 | if (patch.height <= 0) { 304 | patch.height = 10; 305 | } 306 | onChange?.( 307 | update(nodes, { 308 | [index]: { 309 | $set: { ...node, ...patch! }, 310 | }, 311 | }), 312 | connections 313 | ); 314 | } 315 | }, 316 | [ 317 | zoom, 318 | selectingInfo, 319 | movingInfo, 320 | controlInfo, 321 | nodes, 322 | connections, 323 | onChange, 324 | moveTo, 325 | ] 326 | ); 327 | const moveSelected = useCallback( 328 | (x, y) => { 329 | move(selectedNodeIds, x, y); 330 | }, 331 | [move, selectedNodeIds] 332 | ); 333 | const remove = useCallback(() => { 334 | if (readonly) return; 335 | 336 | // Splice arguments of selected connections 337 | const list1: [number, number][] = selectedConnIds.map((currentConn) => [ 338 | connections.findIndex((interConn, index) => index === currentConn), 339 | 1, 340 | ]); 341 | // Splice arguments of connections of selected nodes 342 | const list2: [number, number][] = selectedNodeIds 343 | .map((item) => 344 | connections.filter( 345 | (interConn) => 346 | interConn.source.id === item || interConn.destination.id === item 347 | ) 348 | ) 349 | .flat() 350 | .map((currentConn) => [ 351 | connections.findIndex((interConn) => interConn === currentConn), 352 | 1, 353 | ]); 354 | const restConnections = update(connections, { 355 | $splice: [...list1, ...list2].sort((a, b) => b[0] - a[0]), 356 | }); 357 | 358 | const restNodes = update(nodes, { 359 | $splice: selectedNodeIds 360 | .map((currNode) => [ 361 | nodes.findIndex((interNode) => interNode.id === currNode), 362 | 1, 363 | ]) 364 | .sort((a, b) => b[0] - a[0]) as [number, number][], 365 | }); 366 | 367 | onChange?.(restNodes, restConnections); 368 | }, [ 369 | readonly, 370 | selectedConnIds, 371 | selectedNodeIds, 372 | connections, 373 | nodes, 374 | onChange, 375 | ]); 376 | const handleSVGKeyDown = useCallback( 377 | (event) => { 378 | switch (event.keyCode) { 379 | case 37: 380 | moveSelected(-10, 0); 381 | break; 382 | case 38: 383 | moveSelected(0, -10); 384 | break; 385 | case 39: 386 | moveSelected(10, 0); 387 | break; 388 | case 40: 389 | moveSelected(0, 10); 390 | break; 391 | case 27: 392 | setSelectedNodeIds([]); 393 | setSelectedConnIds([]); 394 | break; 395 | case 65: 396 | if ( 397 | (event.ctrlKey || event.metaKey) && 398 | document.activeElement === document.getElementById("chart") 399 | ) { 400 | setSelectedNodeIds([]); 401 | setSelectedConnIds([]); 402 | setSelectedNodeIds(nodes.map((item) => item.id)); 403 | setSelectedConnIds([...selectedConnIds]); 404 | } 405 | break; 406 | case 46: // Delete 407 | case 8: // Backspace 408 | remove(); 409 | break; 410 | default: 411 | break; 412 | } 413 | }, 414 | [moveSelected, remove, nodes, selectedConnIds] 415 | ); 416 | 417 | const handleSVGMouseUp = useCallback( 418 | (event: React.MouseEvent) => { 419 | setSelectingInfo(undefined); 420 | setConnectingInfo(undefined); 421 | setMovingInfo(undefined); 422 | setControlInfo(undefined); 423 | 424 | // Align dragging node 425 | if (movingInfo) { 426 | let result: NodeData[] = nodes; 427 | for (const t of movingInfo.targetIds) { 428 | result = update(result, { 429 | [result.findIndex((item) => item.id === t)]: { 430 | x: { $apply: roundTo10 }, 431 | y: { $apply: roundTo10 }, 432 | }, 433 | }); 434 | } 435 | onChange?.(result, connections); 436 | } 437 | 438 | // Connect nodes 439 | if (connectingInfo) { 440 | let node: NodeData | null = null; 441 | let position: ConnectorPosition | null = null; 442 | for (const internalNode of nodes) { 443 | const locations = locateConnector(internalNode); 444 | for (const prop in locations) { 445 | const entry = locations[prop as ConnectorPosition]; 446 | if (distanceOfP2P(entry, offsetOfCursorToSVG) < 6) { 447 | node = internalNode; 448 | position = prop as ConnectorPosition; 449 | } 450 | } 451 | } 452 | if (!node || !position) { 453 | return; 454 | } 455 | if (connectingInfo.source.id === node.id) { 456 | // Node can not connect to itself 457 | return; 458 | } 459 | if ( 460 | connections.find( 461 | (item) => 462 | item.source.id === connectingInfo.source.id && 463 | item.source.position === connectingInfo.sourcePosition && 464 | item.destination.id === node!.id && 465 | item.destination.position === position 466 | ) 467 | ) { 468 | return; 469 | } 470 | const newConnection = createConnection( 471 | connectingInfo.source.id, 472 | connectingInfo.sourcePosition, 473 | node.id, 474 | position 475 | ); 476 | onChange?.(nodes, [...connections, newConnection]); 477 | onMouseUp?.(event, zoom); 478 | } 479 | 480 | if (creatingInfo) { 481 | const nativeEvent = event.nativeEvent; 482 | const point = { 483 | x: 484 | roundTo10(nativeEvent.offsetX - defaultNodeSize.width / 2) / zoom, 485 | y: 486 | roundTo10(nativeEvent.offsetY - defaultNodeSize.height / 2) / 487 | zoom, 488 | id: +new Date(), 489 | title: "New Item", 490 | }; 491 | onChange?.( 492 | [...nodes, { type: creatingInfo.type, ...point }], 493 | connections 494 | ); 495 | } 496 | 497 | if (controlInfo) { 498 | const index = nodes.findIndex( 499 | (it) => it.id === controlInfo.targetId 500 | )!; 501 | switch (controlInfo.direction) { 502 | case "u": 503 | onChange?.( 504 | update(nodes, { 505 | [index]: { 506 | $apply: (it) => { 507 | const newY = roundTo10(it.y); 508 | const maxY = it.height! + it.y; 509 | return { 510 | ...it, 511 | y: newY, 512 | height: maxY - newY, 513 | }; 514 | }, 515 | }, 516 | }), 517 | connections 518 | ); 519 | break; 520 | case "d": 521 | onChange?.( 522 | update(nodes, { 523 | [index]: { 524 | $apply: (it) => { 525 | const maxY = roundTo10(it.height! + it.y); 526 | return { 527 | ...it, 528 | height: maxY - it.y, 529 | }; 530 | }, 531 | }, 532 | }), 533 | connections 534 | ); 535 | break; 536 | case "l": 537 | onChange?.( 538 | update(nodes, { 539 | [index]: { 540 | $apply: (it) => { 541 | const newX = roundTo10(it.x); 542 | const maxX = it.width! + it.x; 543 | return { 544 | ...it, 545 | x: newX, 546 | width: maxX - newX, 547 | }; 548 | }, 549 | }, 550 | }), 551 | connections 552 | ); 553 | break; 554 | case "r": 555 | onChange?.( 556 | update(nodes, { 557 | [index]: { 558 | $apply: (it) => { 559 | const maxX = roundTo10(it.width! + it.x); 560 | return { 561 | ...it, 562 | width: maxX - it.x, 563 | }; 564 | }, 565 | }, 566 | }), 567 | connections 568 | ); 569 | break; 570 | case "lu": 571 | onChange?.( 572 | update(nodes, { 573 | [index]: { 574 | $apply: (it) => { 575 | const newX = roundTo10(it.x); 576 | const newY = roundTo10(it.y); 577 | const maxX = it.width! + it.x; 578 | const maxY = it.height! + it.y; 579 | return { 580 | ...it, 581 | x: newX, 582 | y: newY, 583 | width: maxX - newX, 584 | height: maxY - newY, 585 | }; 586 | }, 587 | }, 588 | }), 589 | connections 590 | ); 591 | break; 592 | case "ru": 593 | onChange?.( 594 | update(nodes, { 595 | [index]: { 596 | $apply: (it) => { 597 | const newY = roundTo10(it.y); 598 | const maxY = it.height! + it.y; 599 | return { 600 | ...it, 601 | y: newY, 602 | width: roundTo10(it.width!), 603 | height: maxY - newY, 604 | }; 605 | }, 606 | }, 607 | }), 608 | connections 609 | ); 610 | break; 611 | case "ld": 612 | onChange?.( 613 | update(nodes, { 614 | [index]: { 615 | $apply: (it) => { 616 | const newX = roundTo10(it.x); 617 | const maxX = it.width! + it.x; 618 | return { 619 | ...it, 620 | x: newX, 621 | width: maxX - newX, 622 | height: roundTo10(it.height!), 623 | }; 624 | }, 625 | }, 626 | }), 627 | connections 628 | ); 629 | break; 630 | case "rd": 631 | onChange?.( 632 | update(nodes, { 633 | [index]: { 634 | $apply: (it) => { 635 | return { 636 | ...it, 637 | width: roundTo10(it.width!), 638 | height: roundTo10(it.height!), 639 | }; 640 | }, 641 | }, 642 | }), 643 | connections 644 | ); 645 | break; 646 | } 647 | } 648 | }, 649 | [ 650 | movingInfo, 651 | connectingInfo, 652 | creatingInfo, 653 | controlInfo, 654 | nodes, 655 | onChange, 656 | connections, 657 | onMouseUp, 658 | zoom, 659 | offsetOfCursorToSVG, 660 | defaultNodeSize.width, 661 | defaultNodeSize.height, 662 | ] 663 | ); 664 | 665 | /** 666 | * Points of connecting line 667 | */ 668 | const points = useMemo(() => { 669 | let points: [number, number][] = []; 670 | if (connectingInfo) { 671 | let endPosition: ConnectorPosition | null = null; 672 | for (const internalNode of nodes) { 673 | const locations = locateConnector(internalNode); 674 | for (const prop in locations) { 675 | const entry = locations[prop as ConnectorPosition]; 676 | if (distanceOfP2P(entry, offsetOfCursorToSVG) < 6) { 677 | endPosition = prop as ConnectorPosition; 678 | } 679 | } 680 | } 681 | 682 | points = pathing( 683 | locateConnector(connectingInfo.source)[connectingInfo.sourcePosition], 684 | offsetOfCursorToSVG!, 685 | connectingInfo.sourcePosition, 686 | endPosition 687 | ); 688 | } 689 | return points; 690 | }, [nodes, connectingInfo, offsetOfCursorToSVG]); 691 | 692 | const guidelines = useMemo(() => { 693 | const guidelines: Line[] = []; 694 | if (movingInfo) { 695 | for (const source of movingInfo.targetIds) { 696 | guidelines.push( 697 | ...calcGuidelines(nodes.find((item) => item.id === source)!, nodes) 698 | ); 699 | } 700 | } else if (creatingInfo) { 701 | guidelines.push( 702 | ...calcGuidelines( 703 | { 704 | id: +new Date(), 705 | x: offsetOfCursorToSVG.x - defaultNodeSize.width / 2, 706 | y: offsetOfCursorToSVG.y - defaultNodeSize.height / 2, 707 | }, 708 | nodes 709 | ) 710 | ); 711 | } else if (controlInfo) { 712 | guidelines.push( 713 | ...calcGuidelines( 714 | nodes.find((item) => item.id === controlInfo.targetId)!, 715 | nodes 716 | ) 717 | ); 718 | } 719 | return guidelines; 720 | }, [ 721 | movingInfo, 722 | creatingInfo, 723 | controlInfo, 724 | nodes, 725 | offsetOfCursorToSVG.x, 726 | offsetOfCursorToSVG.y, 727 | defaultNodeSize.width, 728 | defaultNodeSize.height, 729 | ]); 730 | 731 | useImperativeHandle(ref, () => ({ 732 | getData() { 733 | return { nodes, connections }; 734 | }, 735 | })); 736 | 737 | const selectionAreaCorners = useMemo( 738 | () => 739 | selectingInfo 740 | ? calcCorners([selectingInfo.start, selectingInfo.end]) 741 | : undefined, 742 | [selectingInfo] 743 | ); 744 | const zoomStyle = useMemo( 745 | () => ({ 746 | zoom, 747 | }), 748 | [zoom] 749 | ); 750 | 751 | const nodeElements = useMemo(() => { 752 | return nodes?.map((node) => { 753 | const formattedNode: NodeData = { 754 | ...node, 755 | width: node.width || defaultNodeSize.width, 756 | height: node.height || defaultNodeSize.height, 757 | }; 758 | return ( 759 | item === formattedNode.id 764 | )} 765 | isConnecting={!!connectingInfo} 766 | data={formattedNode} 767 | onDoubleClick={(event) => { 768 | event.stopPropagation(); 769 | if (readonly) { 770 | return; 771 | } 772 | onNodeDoubleClick?.(formattedNode); 773 | }} 774 | onMouseDown={(event) => { 775 | if (event.nativeEvent.button !== 0) { 776 | return; 777 | } 778 | if (event.ctrlKey || event.metaKey) { 779 | const index = selectedNodeIds.findIndex( 780 | (item) => item === formattedNode.id 781 | ); 782 | if (index === -1) { 783 | setSelectedNodeIds([...selectedNodeIds, formattedNode.id]); 784 | } else { 785 | setSelectedNodeIds( 786 | update(selectedNodeIds, { $splice: [[index, 1]] }) 787 | ); 788 | } 789 | } else { 790 | let tempCurrentNodes: number[] = selectedNodeIds; 791 | if (!selectedNodeIds.some((id) => id === formattedNode.id)) { 792 | tempCurrentNodes = [formattedNode.id]; 793 | setSelectedNodeIds(tempCurrentNodes); 794 | } 795 | setSelectedConnIds([]); 796 | if (readonly) { 797 | return; 798 | } 799 | setMovingInfo({ 800 | targetIds: tempCurrentNodes, 801 | deltas: tempCurrentNodes.map((tempCurrentNode) => { 802 | const find = nodes.find( 803 | (item) => item.id === tempCurrentNode 804 | )!; 805 | return { 806 | x: offsetOfCursorToSVG.x - find.x, 807 | y: offsetOfCursorToSVG.y - find.y, 808 | }; 809 | }), 810 | }); 811 | } 812 | }} 813 | onConnectorMouseDown={(position) => { 814 | if (formattedNode.type === "end") { 815 | return; 816 | } 817 | setConnectingInfo({ 818 | source: formattedNode, 819 | sourcePosition: position, 820 | }); 821 | }} 822 | onControllerMouseDown={(direction) => { 823 | setControlInfo({ 824 | direction, 825 | targetId: formattedNode.id, 826 | }); 827 | }} 828 | /> 829 | ); 830 | }); 831 | }, [ 832 | nodes, 833 | defaultNodeSize.width, 834 | defaultNodeSize.height, 835 | readonly, 836 | selectedNodeIds, 837 | connectingInfo, 838 | onNodeDoubleClick, 839 | offsetOfCursorToSVG.x, 840 | offsetOfCursorToSVG.y, 841 | ]); 842 | 843 | const connectionElements = useMemo( 844 | () => 845 | connections?.map((conn, index) => { 846 | return ( 847 | index === item)} 855 | onDoubleClick={() => onConnDoubleClick?.(conn)} 856 | onMouseDown={(event) => { 857 | if (event.ctrlKey || event.metaKey) { 858 | const i = selectedConnIds.findIndex((item) => item === index); 859 | if (i === -1) { 860 | setSelectedConnIds((prevState) => [...prevState, index]); 861 | } else { 862 | setSelectedConnIds((prev) => 863 | update(prev, { $splice: [[i, 1]] }) 864 | ); 865 | } 866 | } else { 867 | setSelectedNodeIds([]); 868 | setSelectedConnIds([index]); 869 | } 870 | }} 871 | data={conn} 872 | nodes={nodes} 873 | /> 874 | ); 875 | }), 876 | [connections, selectedConnIds, nodes, onConnDoubleClick] 877 | ); 878 | 879 | const handleToolbarMouseDown = useCallback( 880 | (type: NodeType, event: React.MouseEvent) => { 881 | const rect = containerRef.current!.getBoundingClientRect(); 882 | setCreatingInfo({ 883 | type, 884 | x: event.clientX - rect.x - (defaultNodeSize.width * zoom) / 2, 885 | y: event.clientY - rect.y - (defaultNodeSize.height * zoom) / 2, 886 | }); 887 | }, 888 | [defaultNodeSize.height, defaultNodeSize.width, zoom] 889 | ); 890 | 891 | const handleContainerMouseUp = useCallback(() => { 892 | setCreatingInfo(undefined); 893 | }, []); 894 | 895 | const handleContainerMouseMove = useCallback( 896 | (event: React.MouseEvent | undefined) => { 897 | if (!event || !creatingInfo) { 898 | return; 899 | } 900 | 901 | const rect = containerRef.current!.getBoundingClientRect(); 902 | setCreatingInfo({ 903 | ...creatingInfo, 904 | x: event.clientX - rect.x - (defaultNodeSize.width * zoom) / 2, 905 | y: event.clientY - rect.y - (defaultNodeSize.height * zoom) / 2, 906 | }); 907 | }, 908 | [defaultNodeSize.height, defaultNodeSize.width, creatingInfo, zoom] 909 | ); 910 | 911 | return ( 912 | <> 913 |
920 |
925 | 928 | 931 | 934 | {!readonly && ( 935 | 941 | )} 942 |
943 |
944 | {readonly || showToolbar === false ? ( 945 | <> 946 | ) : ( 947 |
948 | {showToolbar === true || 949 | (Array.isArray(showToolbar) && 950 | showToolbar.includes("start-end")) ? ( 951 |
953 | handleToolbarMouseDown("start", event) 954 | } 955 | > 956 | 957 | 958 | 959 |
960 | ) : ( 961 | <> 962 | )} 963 | {showToolbar === true || 964 | (Array.isArray(showToolbar) && 965 | showToolbar.includes("operation")) ? ( 966 |
968 | handleToolbarMouseDown("operation", event) 969 | } 970 | > 971 | 972 | 973 | 974 |
975 | ) : ( 976 | <> 977 | )} 978 | {showToolbar === true || 979 | (Array.isArray(showToolbar) && 980 | showToolbar.includes("decision")) ? ( 981 |
983 | handleToolbarMouseDown("decision", event) 984 | } 985 | > 986 | 987 | 988 | 989 |
990 | ) : ( 991 | <> 992 | )} 993 |
994 | )} 995 | 1008 | {connectionPosition === "bottom" ? connectionElements : <>} 1009 | {nodeElements} 1010 | {connectionPosition === "top" || !connectionPosition ? ( 1011 | connectionElements 1012 | ) : ( 1013 | <> 1014 | )} 1015 | {selectionAreaCorners && ( 1016 | 1029 | )} 1030 | 1031 | {guidelines.map((guideline, index) => ( 1032 | 1033 | ))} 1034 | 1035 |
1036 | {creatingInfo ? ( 1037 |
1046 | 1047 | {creatingInfo.type === "start" ? ( 1048 | 1049 | ) : creatingInfo.type === "decision" ? ( 1050 | 1051 | ) : ( 1052 | 1053 | )} 1054 | 1055 |
1056 | ) : ( 1057 | <> 1058 | )} 1059 |
1060 | 1061 | ); 1062 | } 1063 | ); 1064 | export default Flowchart; 1065 | -------------------------------------------------------------------------------- /src/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/output.css: -------------------------------------------------------------------------------- 1 | *, ::before, ::after { 2 | --tw-translate-x: 0; 3 | --tw-translate-y: 0; 4 | --tw-rotate: 0; 5 | --tw-skew-x: 0; 6 | --tw-skew-y: 0; 7 | --tw-scale-x: 1; 8 | --tw-scale-y: 1; 9 | --tw-pan-x: ; 10 | --tw-pan-y: ; 11 | --tw-pinch-zoom: ; 12 | --tw-scroll-snap-strictness: proximity; 13 | --tw-ordinal: ; 14 | --tw-slashed-zero: ; 15 | --tw-numeric-figure: ; 16 | --tw-numeric-spacing: ; 17 | --tw-numeric-fraction: ; 18 | --tw-ring-inset: ; 19 | --tw-ring-offset-width: 0px; 20 | --tw-ring-offset-color: #fff; 21 | --tw-ring-color: rgb(59 130 246 / 0.5); 22 | --tw-ring-offset-shadow: 0 0 #0000; 23 | --tw-ring-shadow: 0 0 #0000; 24 | --tw-shadow: 0 0 #0000; 25 | --tw-shadow-colored: 0 0 #0000; 26 | --tw-blur: ; 27 | --tw-brightness: ; 28 | --tw-contrast: ; 29 | --tw-grayscale: ; 30 | --tw-hue-rotate: ; 31 | --tw-invert: ; 32 | --tw-saturate: ; 33 | --tw-sepia: ; 34 | --tw-drop-shadow: ; 35 | --tw-backdrop-blur: ; 36 | --tw-backdrop-brightness: ; 37 | --tw-backdrop-contrast: ; 38 | --tw-backdrop-grayscale: ; 39 | --tw-backdrop-hue-rotate: ; 40 | --tw-backdrop-invert: ; 41 | --tw-backdrop-opacity: ; 42 | --tw-backdrop-saturate: ; 43 | --tw-backdrop-sepia: 44 | } 45 | 46 | .container { 47 | width: 100% 48 | } 49 | 50 | @media (min-width: 640px) { 51 | .container { 52 | max-width: 640px 53 | } 54 | } 55 | 56 | @media (min-width: 768px) { 57 | .container { 58 | max-width: 768px 59 | } 60 | } 61 | 62 | @media (min-width: 1024px) { 63 | .container { 64 | max-width: 1024px 65 | } 66 | } 67 | 68 | @media (min-width: 1280px) { 69 | .container { 70 | max-width: 1280px 71 | } 72 | } 73 | 74 | @media (min-width: 1536px) { 75 | .container { 76 | max-width: 1536px 77 | } 78 | } 79 | 80 | .pointer-events-none { 81 | pointer-events: none 82 | } 83 | 84 | .absolute { 85 | position: absolute 86 | } 87 | 88 | .top-2 { 89 | top: 0.5rem 90 | } 91 | 92 | .right-2 { 93 | right: 0.5rem 94 | } 95 | 96 | .mt-\[2px\] { 97 | margin-top: 2px 98 | } 99 | 100 | .flex { 101 | display: flex 102 | } 103 | 104 | .inline-flex { 105 | display: inline-flex 106 | } 107 | 108 | .h-full { 109 | height: 100% 110 | } 111 | 112 | .w-full { 113 | width: 100% 114 | } 115 | 116 | .cursor-nw-resize { 117 | cursor: nw-resize 118 | } 119 | 120 | .cursor-n-resize { 121 | cursor: n-resize 122 | } 123 | 124 | .cursor-sw-resize { 125 | cursor: sw-resize 126 | } 127 | 128 | .cursor-w-resize { 129 | cursor: w-resize 130 | } 131 | 132 | .cursor-ne-resize { 133 | cursor: ne-resize 134 | } 135 | 136 | .cursor-se-resize { 137 | cursor: se-resize 138 | } 139 | 140 | .cursor-s-resize { 141 | cursor: s-resize 142 | } 143 | 144 | .cursor-e-resize { 145 | cursor: e-resize 146 | } 147 | 148 | .items-center { 149 | align-items: center 150 | } 151 | 152 | .justify-center { 153 | justify-content: center 154 | } 155 | 156 | .border-none { 157 | border-style: none 158 | } 159 | 160 | .bg-transparent { 161 | background-color: transparent 162 | } 163 | 164 | .filter { 165 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow) 166 | } 167 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from "react"; 2 | 3 | export interface NodeData { 4 | id: number; 5 | title: string | (() => string) | JSX.Element; 6 | type?: NodeType; 7 | // approveMethod?: number; 8 | // editableFields?: string; 9 | // approvers?: { id: number; name: string }[]; 10 | x: number; 11 | y: number; 12 | payload?: { [key: string]: unknown }; 13 | width?: number; 14 | height?: number; 15 | containerProps?: SupportedSVGShapeProps; 16 | textProps?: SupportedSVGTextProps; 17 | } 18 | 19 | export type SupportedSVGShapeProps = Pick< 20 | React.SVGProps, 21 | "fill" | "stroke" 22 | >; 23 | 24 | export type SupportedSVGTextProps = Pick< 25 | React.SVGProps, 26 | "fill" 27 | >; 28 | 29 | export interface ConnectionData { 30 | type?: "success" | "fail"; 31 | source: { 32 | id: number; 33 | position: ConnectorPosition; 34 | }; 35 | destination: { 36 | id: number; 37 | position: ConnectorPosition; 38 | }; 39 | title?: string; 40 | color?: string; 41 | } 42 | 43 | export interface Point { 44 | x: number; 45 | y: number; 46 | } 47 | 48 | export type Line = [Point, Point]; 49 | 50 | export interface SelectingInfo { 51 | start: Point; 52 | end: Point; 53 | } 54 | 55 | export type ConnectorPosition = "left" | "right" | "top" | "bottom"; 56 | export type NodeType = "start" | "end" | "operation" | "decision"; 57 | 58 | export interface FlowchartProps { 59 | style?: CSSProperties; 60 | nodes: NodeData[]; 61 | connections: ConnectionData[]; 62 | onNodeDoubleClick?: (data: NodeData) => void; 63 | onChange?: (nodes: NodeData[], connections: ConnectionData[]) => void; 64 | onConnectionDoubleClick?: (data: ConnectionData) => void; 65 | onDoubleClick?: 66 | | ((event: React.MouseEvent, zoom: number) => void) 67 | | undefined; 68 | onMouseUp?: 69 | | ((event: React.MouseEvent, zoom: number) => void) 70 | | undefined; 71 | readonly?: boolean; 72 | defaultNodeSize?: { 73 | width: number; 74 | height: number; 75 | }; 76 | showToolbar?: boolean | ("start-end" | "operation" | "decision")[]; 77 | connectionPosition?: "bottom" | "top"; 78 | /** 79 | * Custom class name for the flowchart container 80 | */ 81 | className?: string; 82 | } 83 | 84 | export interface DragMovingInfo { 85 | targetIds: number[]; 86 | deltas: { 87 | x: number; 88 | y: number; 89 | }[]; 90 | moved?: true; 91 | } 92 | 93 | export interface DragCreatingInfo { 94 | type: NodeType; 95 | x: number; 96 | y: number; 97 | } 98 | 99 | export interface DragConnectingInfo { 100 | source: NodeData; 101 | sourcePosition: ConnectorPosition; 102 | } 103 | 104 | export interface IFlowchart { 105 | getData: () => { 106 | nodes: NodeData[]; 107 | connections: ConnectionData[]; 108 | }; 109 | } 110 | 111 | export interface ControlInfo { 112 | targetId: number; 113 | direction: Direction; 114 | } 115 | 116 | export type Direction = "l" | "r" | "u" | "d" | "lu" | "ru" | "ld" | "rd"; 117 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConnectionData, 3 | ConnectorPosition, 4 | Direction, 5 | Line, 6 | NodeData, 7 | Point, 8 | } from "./schema"; 9 | import update from "immutability-helper"; 10 | 11 | function pathing( 12 | p1: Point, 13 | p2: Point, 14 | startPosition: ConnectorPosition, 15 | endPosition: ConnectorPosition | null 16 | ): [number, number][] { 17 | const points: [number, number][] = []; 18 | const start: [number, number] = [p1.x, p1.y]; 19 | const end: [number, number] = [p2.x, p2.y]; 20 | const centerX = start[0] + (end[0] - start[0]) / 2; 21 | const centerY = start[1] + (end[1] - start[1]) / 2; 22 | let second: [number, number]; 23 | const addVerticalCenterLine = function () { 24 | const third: [number, number] = [centerX, second[1]]; 25 | const forth: [number, number] = [centerX, penult[1]]; 26 | points.push(third); 27 | points.push(forth); 28 | }; 29 | const addHorizontalCenterLine = function () { 30 | const third: [number, number] = [second[0], centerY]; 31 | const forth: [number, number] = [penult[0], centerY]; 32 | points.push(third); 33 | points.push(forth); 34 | }; 35 | const addHorizontalTopLine = function () { 36 | points.push([second[0], start[1] - 50]); 37 | points.push([penult[0], start[1] - 50]); 38 | }; 39 | const addHorizontalBottomLine = function () { 40 | points.push([second[0], start[1] + 50]); 41 | points.push([penult[0], start[1] + 50]); 42 | }; 43 | const addVerticalRightLine = function () { 44 | points.push([start[0] + 80, second[1]]); 45 | points.push([start[0] + 80, penult[1]]); 46 | }; 47 | const addVerticalLeftLine = function () { 48 | points.push([start[0] - 80, second[1]]); 49 | points.push([start[0] - 80, penult[1]]); 50 | }; 51 | const addSecondXPenultY = function () { 52 | points.push([second[0], penult[1]]); 53 | }; 54 | const addPenultXSecondY = function () { 55 | points.push([penult[0], second[1]]); 56 | }; 57 | switch (startPosition) { 58 | case "left": 59 | second = [start[0] - 20, start[1]]; 60 | break; 61 | case "top": 62 | second = [start[0], start[1] - 20]; 63 | break; 64 | case "bottom": 65 | second = [start[0], start[1] + 20]; 66 | break; 67 | default: 68 | second = [start[0] + 20, start[1]]; 69 | break; 70 | } 71 | let penult: [number, number]; 72 | switch (endPosition) { 73 | case "right": 74 | penult = [end[0] + 20, end[1]]; 75 | break; 76 | case "top": 77 | penult = [end[0], end[1] - 20]; 78 | break; 79 | case "bottom": 80 | penult = [end[0], end[1] + 20]; 81 | break; 82 | default: 83 | penult = [end[0] - 20, end[1]]; 84 | break; 85 | } 86 | points.push(start); 87 | points.push(second); 88 | startPosition = startPosition || "right"; 89 | endPosition = endPosition || "left"; 90 | const direction = calcDirection(p1, p2); 91 | if (direction.indexOf("r") > -1) { 92 | if (startPosition === "right" || endPosition === "left") { 93 | if (second[0] > centerX) { 94 | second[0] = centerX; 95 | } 96 | if (penult[0] < centerX) { 97 | penult[0] = centerX; 98 | } 99 | } 100 | } 101 | if (direction.indexOf("d") > -1) { 102 | if (startPosition === "bottom" || endPosition === "top") { 103 | if (second[1] > centerY) { 104 | second[1] = centerY; 105 | } 106 | if (penult[1] < centerY) { 107 | penult[1] = centerY; 108 | } 109 | } 110 | } 111 | if (direction.indexOf("l") > -1) { 112 | if (startPosition === "left" || endPosition === "right") { 113 | if (second[0] < centerX) { 114 | second[0] = centerX; 115 | } 116 | if (penult[0] > centerX) { 117 | penult[0] = centerX; 118 | } 119 | } 120 | } 121 | if (direction.indexOf("u") > -1) { 122 | if (startPosition === "top" || endPosition === "bottom") { 123 | if (second[1] < centerY) { 124 | second[1] = centerY; 125 | } 126 | if (penult[1] > centerY) { 127 | penult[1] = centerY; 128 | } 129 | } 130 | } 131 | switch (direction) { 132 | case "lu": { 133 | if (startPosition === "right") { 134 | switch (endPosition) { 135 | case "top": 136 | case "right": 137 | addSecondXPenultY(); 138 | break; 139 | default: { 140 | addHorizontalCenterLine(); 141 | break; 142 | } 143 | } 144 | } else if (startPosition === "bottom") { 145 | switch (endPosition) { 146 | case "top": 147 | addVerticalCenterLine(); 148 | break; 149 | default: { 150 | addPenultXSecondY(); 151 | break; 152 | } 153 | } 154 | } else if (startPosition === "top") { 155 | switch (endPosition) { 156 | case "top": 157 | case "right": 158 | addSecondXPenultY(); 159 | break; 160 | default: { 161 | addHorizontalCenterLine(); 162 | break; 163 | } 164 | } 165 | } else { 166 | // startPosition is left 167 | switch (endPosition) { 168 | case "top": 169 | case "right": 170 | addVerticalCenterLine(); 171 | break; 172 | default: { 173 | addPenultXSecondY(); 174 | break; 175 | } 176 | } 177 | } 178 | break; 179 | } 180 | case "u": 181 | if (startPosition === "right") { 182 | switch (endPosition) { 183 | case "right": { 184 | break; 185 | } 186 | case "top": { 187 | addSecondXPenultY(); 188 | break; 189 | } 190 | default: { 191 | addHorizontalCenterLine(); 192 | break; 193 | } 194 | } 195 | } else if (startPosition === "bottom") { 196 | switch (endPosition) { 197 | case "left": 198 | case "right": 199 | addPenultXSecondY(); 200 | break; 201 | default: { 202 | addVerticalRightLine(); 203 | break; 204 | } 205 | } 206 | } else if (startPosition === "top") { 207 | switch (endPosition) { 208 | case "left": { 209 | addPenultXSecondY(); 210 | break; 211 | } 212 | case "right": { 213 | addHorizontalCenterLine(); 214 | break; 215 | } 216 | case "top": 217 | addVerticalRightLine(); 218 | break; 219 | default: { 220 | break; 221 | } 222 | } 223 | } else { 224 | // left 225 | switch (endPosition) { 226 | case "left": 227 | case "right": 228 | break; 229 | default: { 230 | points.push([second[0], penult[1]]); 231 | break; 232 | } 233 | } 234 | } 235 | break; 236 | case "ru": 237 | if (startPosition === "right") { 238 | switch (endPosition) { 239 | case "left": { 240 | addVerticalCenterLine(); 241 | break; 242 | } 243 | case "top": { 244 | addSecondXPenultY(); 245 | break; 246 | } 247 | default: { 248 | addPenultXSecondY(); 249 | break; 250 | } 251 | } 252 | } else if (startPosition === "bottom") { 253 | switch (endPosition) { 254 | case "top": { 255 | addVerticalCenterLine(); 256 | break; 257 | } 258 | default: { 259 | addPenultXSecondY(); 260 | break; 261 | } 262 | } 263 | } else if (startPosition === "top") { 264 | switch (endPosition) { 265 | case "right": { 266 | addVerticalCenterLine(); 267 | break; 268 | } 269 | default: { 270 | addSecondXPenultY(); 271 | break; 272 | } 273 | } 274 | } else { 275 | // left 276 | switch (endPosition) { 277 | case "left": 278 | case "top": 279 | addSecondXPenultY(); 280 | break; 281 | default: { 282 | addHorizontalCenterLine(); 283 | break; 284 | } 285 | } 286 | } 287 | break; 288 | case "l": 289 | if (startPosition === "right") { 290 | switch (endPosition) { 291 | case "left": 292 | case "right": 293 | case "top": 294 | addHorizontalTopLine(); 295 | break; 296 | default: { 297 | addHorizontalBottomLine(); 298 | break; 299 | } 300 | } 301 | } else if (startPosition === "bottom") { 302 | switch (endPosition) { 303 | case "left": { 304 | addHorizontalBottomLine(); 305 | break; 306 | } 307 | case "right": { 308 | addSecondXPenultY(); 309 | break; 310 | } 311 | case "top": { 312 | addVerticalCenterLine(); 313 | break; 314 | } 315 | default: { 316 | break; 317 | } 318 | } 319 | } else if (startPosition === "top") { 320 | switch (endPosition) { 321 | case "left": { 322 | addHorizontalTopLine(); 323 | break; 324 | } 325 | case "right": { 326 | addSecondXPenultY(); 327 | break; 328 | } 329 | case "top": { 330 | break; 331 | } 332 | default: { 333 | addVerticalCenterLine(); 334 | break; 335 | } 336 | } 337 | } else { 338 | // left 339 | switch (endPosition) { 340 | case "left": { 341 | addHorizontalTopLine(); 342 | break; 343 | } 344 | case "right": { 345 | break; 346 | } 347 | default: { 348 | addSecondXPenultY(); 349 | break; 350 | } 351 | } 352 | } 353 | break; 354 | case "r": 355 | if (startPosition === "right") { 356 | switch (endPosition) { 357 | case "left": { 358 | break; 359 | } 360 | case "right": { 361 | addHorizontalTopLine(); 362 | break; 363 | } 364 | default: { 365 | addSecondXPenultY(); 366 | break; 367 | } 368 | } 369 | } else if (startPosition === "bottom") { 370 | switch (endPosition) { 371 | case "left": { 372 | addSecondXPenultY(); 373 | break; 374 | } 375 | case "right": { 376 | addHorizontalBottomLine(); 377 | break; 378 | } 379 | case "top": { 380 | addVerticalCenterLine(); 381 | break; 382 | } 383 | default: { 384 | break; 385 | } 386 | } 387 | } else if (startPosition === "top") { 388 | switch (endPosition) { 389 | case "left": { 390 | addPenultXSecondY(); 391 | break; 392 | } 393 | case "right": { 394 | addHorizontalTopLine(); 395 | break; 396 | } 397 | case "top": { 398 | break; 399 | } 400 | default: { 401 | addVerticalCenterLine(); 402 | break; 403 | } 404 | } 405 | } else { 406 | // left 407 | switch (endPosition) { 408 | case "left": 409 | case "right": 410 | case "top": 411 | addHorizontalTopLine(); 412 | break; 413 | default: { 414 | addHorizontalBottomLine(); 415 | break; 416 | } 417 | } 418 | } 419 | break; 420 | case "ld": 421 | if (startPosition === "right") { 422 | switch (endPosition) { 423 | case "left": { 424 | addHorizontalCenterLine(); 425 | break; 426 | } 427 | default: { 428 | addSecondXPenultY(); 429 | break; 430 | } 431 | } 432 | } else if (startPosition === "bottom") { 433 | switch (endPosition) { 434 | case "left": { 435 | addPenultXSecondY(); 436 | break; 437 | } 438 | case "top": { 439 | addHorizontalCenterLine(); 440 | break; 441 | } 442 | default: { 443 | addSecondXPenultY(); 444 | break; 445 | } 446 | } 447 | } else if (startPosition === "top") { 448 | switch (endPosition) { 449 | case "left": 450 | case "right": 451 | case "top": 452 | addPenultXSecondY(); 453 | break; 454 | default: { 455 | addVerticalCenterLine(); 456 | break; 457 | } 458 | } 459 | } else { 460 | // left 461 | switch (endPosition) { 462 | case "left": 463 | case "top": 464 | addPenultXSecondY(); 465 | break; 466 | case "right": { 467 | addVerticalCenterLine(); 468 | break; 469 | } 470 | default: { 471 | addSecondXPenultY(); 472 | break; 473 | } 474 | } 475 | } 476 | break; 477 | case "d": 478 | if (startPosition === "right") { 479 | switch (endPosition) { 480 | case "left": { 481 | addHorizontalCenterLine(); 482 | break; 483 | } 484 | case "right": { 485 | addPenultXSecondY(); 486 | break; 487 | } 488 | case "top": { 489 | addSecondXPenultY(); 490 | break; 491 | } 492 | default: { 493 | addVerticalRightLine(); 494 | break; 495 | } 496 | } 497 | } else if (startPosition === "bottom") { 498 | switch (endPosition) { 499 | case "left": 500 | case "right": 501 | addPenultXSecondY(); 502 | break; 503 | case "top": { 504 | break; 505 | } 506 | default: { 507 | addVerticalRightLine(); 508 | break; 509 | } 510 | } 511 | } else if (startPosition === "top") { 512 | switch (endPosition) { 513 | case "left": { 514 | addVerticalLeftLine(); 515 | break; 516 | } 517 | default: { 518 | addVerticalRightLine(); 519 | break; 520 | } 521 | } 522 | } else { 523 | // left 524 | switch (endPosition) { 525 | case "left": { 526 | break; 527 | } 528 | case "right": { 529 | addHorizontalCenterLine(); 530 | break; 531 | } 532 | case "top": { 533 | addSecondXPenultY(); 534 | break; 535 | } 536 | default: { 537 | addVerticalLeftLine(); 538 | break; 539 | } 540 | } 541 | } 542 | break; 543 | case "rd": { 544 | if (startPosition === "right" && endPosition === "left") { 545 | addVerticalCenterLine(); 546 | } else if (startPosition === "right" && endPosition === "bottom") { 547 | addSecondXPenultY(); 548 | } else if ( 549 | (startPosition === "right" && endPosition === "top") || 550 | (startPosition === "right" && endPosition === "right") 551 | ) { 552 | addPenultXSecondY(); 553 | } else if (startPosition === "bottom" && endPosition === "left") { 554 | addSecondXPenultY(); 555 | } else if (startPosition === "bottom" && endPosition === "right") { 556 | addPenultXSecondY(); 557 | } else if (startPosition === "bottom" && endPosition === "top") { 558 | addHorizontalCenterLine(); 559 | } else if (startPosition === "bottom" && endPosition === "bottom") { 560 | addSecondXPenultY(); 561 | } else if (startPosition === "top" && endPosition === "left") { 562 | addPenultXSecondY(); 563 | } else if (startPosition === "top" && endPosition === "right") { 564 | addPenultXSecondY(); 565 | } else if (startPosition === "top" && endPosition === "top") { 566 | addPenultXSecondY(); 567 | } else if (startPosition === "top" && endPosition === "bottom") { 568 | addVerticalCenterLine(); 569 | } else if (startPosition === "left" && endPosition === "left") { 570 | addSecondXPenultY(); 571 | } else if (startPosition === "left" && endPosition === "right") { 572 | addHorizontalCenterLine(); 573 | } else if (startPosition === "left" && endPosition === "top") { 574 | addHorizontalCenterLine(); 575 | } else if (startPosition === "left" && endPosition === "bottom") { 576 | addSecondXPenultY(); 577 | } 578 | break; 579 | } 580 | } 581 | points.push(penult); 582 | points.push(end); 583 | return points; 584 | } 585 | 586 | function calcDirection(p1: Point, p2: Point): Direction { 587 | // Use approximatelyEquals to fix the problem of css position precision 588 | if (p2.x < p1.x && p2.y === p1.y) { 589 | return "l"; 590 | } 591 | if (p2.x > p1.x && p2.y === p1.y) { 592 | return "r"; 593 | } 594 | if (p2.x === p1.x && p2.y < p1.y) { 595 | return "u"; 596 | } 597 | if (p2.x === p1.x && p2.y > p1.y) { 598 | return "d"; 599 | } 600 | if (p2.x < p1.x && p2.y < p1.y) { 601 | return "lu"; 602 | } 603 | if (p2.x > p1.x && p2.y < p1.y) { 604 | return "ru"; 605 | } 606 | if (p2.x < p1.x && p2.y > p1.y) { 607 | return "ld"; 608 | } 609 | return "rd"; 610 | } 611 | 612 | function distanceOfP2P(p1: Point, p2: Point): number { 613 | return Math.hypot(p1.x - p2.x, p1.y - p2.y); 614 | } 615 | 616 | function distanceOfP2L(point: Point, line: Line): number { 617 | const start = line[0], 618 | end = line[1]; 619 | const k = (end.y - start.y || 1) / (end.x - start.x || 1); 620 | const b = start.y - k * start.x; 621 | return Math.abs(k * point.x - point.y + b) / Math.sqrt(k * k + 1); 622 | } 623 | 624 | function between(num1: number, num2: number, num: number): boolean { 625 | return (num > num1 && num < num2) || (num > num2 && num < num1); 626 | } 627 | 628 | function approximatelyEquals(n: number, m: number): boolean { 629 | return Math.abs(m - n) <= 3; 630 | } 631 | 632 | function calcCorners(points: Point[]): { start: Point; end: Point } { 633 | const minX = points.reduce((prev, point) => { 634 | return point.x < prev ? point.x : prev; 635 | }, Infinity); 636 | const maxX = points.reduce((prev, point) => { 637 | return point.x > prev ? point.x : prev; 638 | }, 0); 639 | const minY = points.reduce((prev, point) => { 640 | return point.y < prev ? point.y : prev; 641 | }, Infinity); 642 | const maxY = points.reduce((prev, point) => { 643 | return point.y > prev ? point.y : prev; 644 | }, 0); 645 | return { start: { x: minX, y: minY }, end: { x: maxX, y: maxY } }; 646 | } 647 | 648 | function center(nodes: NodeData[], width: number, height: number): NodeData[] { 649 | const corners = calcCorners([ 650 | ...nodes, 651 | ...nodes.map((node) => ({ 652 | x: node.x + (node.width || 120), 653 | y: node.y + (node.height || 60), 654 | })), 655 | ]); 656 | 657 | const offsetX = (width - corners.end.x - corners.start.x) / 2; 658 | const offsetY = (height - corners.end.y - corners.start.y) / 2; 659 | return update(nodes, { 660 | $apply: (state: NodeData[]) => 661 | state.map((node) => ({ 662 | ...node, 663 | x: roundTo10(node.x + offsetX), 664 | y: roundTo10(node.y + offsetY), 665 | })), 666 | }); 667 | } 668 | 669 | function isIntersected( 670 | p: Point, 671 | rect: { 672 | start: Point; 673 | end: Point; 674 | } 675 | ): boolean { 676 | return ( 677 | p.x > rect.start.x && 678 | p.x < rect.end.x && 679 | p.y > rect.start.y && 680 | p.y < rect.end.y 681 | ); 682 | } 683 | 684 | function roundTo10(number: number): number { 685 | return Math.ceil(number / 10) * 10; 686 | } 687 | 688 | function locateConnector(node: NodeData): { 689 | left: Point; 690 | right: Point; 691 | top: Point; 692 | bottom: Point; 693 | } { 694 | const height = node.height || 60; 695 | const width = node.width || 120; 696 | const halfWidth = width / 2; 697 | const halfHeight = height / 2; 698 | const top = { x: node.x + halfWidth, y: node.y }; 699 | const left = { x: node.x, y: node.y + halfHeight }; 700 | const bottom = { x: node.x + halfWidth, y: node.y + height }; 701 | const right = { x: node.x + width, y: node.y + halfHeight }; 702 | return { left, right, top, bottom }; 703 | } 704 | 705 | /** 706 | * Get angle positions: top-left, top-right, bottom-right, bottom-left 707 | * @param node 708 | */ 709 | function locateAngle( 710 | node: Pick 711 | ): [Point, Point, Point, Point] { 712 | const width = node.width || 120; 713 | const height = node.height || 60; 714 | return [ 715 | { x: node.x, y: node.y }, 716 | { x: node.x + width, y: node.y }, 717 | { x: node.x + width, y: node.y + height }, 718 | { x: node.x, y: node.y + height }, 719 | ]; 720 | } 721 | 722 | function calcIntersectedConnections( 723 | internalNodes: NodeData[], 724 | internalConnections: ConnectionData[], 725 | rect: { start: Point; end: Point } 726 | ): ConnectionData[] { 727 | const result: ConnectionData[] = []; 728 | for (const internalConnection of internalConnections) { 729 | const srcNodeData = internalNodes.find( 730 | (item) => item.id === internalConnection.source.id 731 | ); 732 | const destNodeData = internalNodes.find( 733 | (item) => item.id === internalConnection.destination.id 734 | ); 735 | const points = pathing( 736 | locateConnector(srcNodeData!)[internalConnection.source.position], 737 | locateConnector(destNodeData!)[internalConnection.destination.position], 738 | internalConnection.source.position, 739 | internalConnection.destination.position 740 | ); 741 | if ( 742 | points.some((point) => isIntersected({ x: point[0], y: point[1] }, rect)) 743 | ) { 744 | result.push(internalConnection); 745 | } 746 | } 747 | return result; 748 | } 749 | 750 | function calcIntersectedNodes( 751 | internalNodes: NodeData[], 752 | edge: { start: Point; end: Point } 753 | ): NodeData[] { 754 | const tempCurrentNodes: NodeData[] = []; 755 | internalNodes.forEach((item) => { 756 | if (locateAngle(item).some((point) => isIntersected(point, edge))) { 757 | tempCurrentNodes.push(item); 758 | } 759 | }); 760 | return tempCurrentNodes; 761 | } 762 | 763 | function createConnection( 764 | sourceId: number, 765 | sourcePosition: ConnectorPosition, 766 | destinationId: number, 767 | destinationPosition: ConnectorPosition 768 | ): ConnectionData { 769 | return { 770 | source: { id: sourceId, position: sourcePosition }, 771 | destination: { id: destinationId, position: destinationPosition }, 772 | type: "success", 773 | }; 774 | } 775 | 776 | export function calcGuidelines( 777 | node: Pick, 778 | nodes: NodeData[] 779 | ): Line[] { 780 | const guidelines: Line[] = []; 781 | const points = locateAngle(node); 782 | for (let i = 0; i < points.length; i++) { 783 | const srcAnglePoint = { 784 | x: roundTo10(points[i].x), 785 | y: roundTo10(points[i].y), 786 | }; 787 | 788 | let lines: Line[]; 789 | let directions: Direction[]; 790 | switch (i) { 791 | case 0: { 792 | lines = [ 793 | [{ x: srcAnglePoint.x, y: 0 }, srcAnglePoint], 794 | [{ x: 0, y: srcAnglePoint.y }, srcAnglePoint], 795 | ]; 796 | directions = ["lu", "u", "l"]; 797 | break; 798 | } 799 | case 1: { 800 | lines = [ 801 | [{ x: srcAnglePoint.x, y: 0 }, srcAnglePoint], 802 | // todo: replace 10000 with the width of svg 803 | [{ x: 10000, y: srcAnglePoint.y }, srcAnglePoint], 804 | ]; 805 | directions = ["ru", "u", "r"]; 806 | break; 807 | } 808 | case 2: { 809 | lines = [ 810 | [{ x: srcAnglePoint.x, y: 10000 }, srcAnglePoint], 811 | [{ x: 10000, y: srcAnglePoint.y }, srcAnglePoint], 812 | ]; 813 | directions = ["r", "rd", "d"]; 814 | break; 815 | } 816 | default: { 817 | lines = [ 818 | [{ x: srcAnglePoint.x, y: 10000 }, srcAnglePoint], 819 | [{ x: 0, y: srcAnglePoint.y }, srcAnglePoint], 820 | ]; 821 | directions = ["l", "ld", "d"]; 822 | break; 823 | } 824 | } 825 | 826 | for (const destination of nodes.filter( 827 | (internalNode) => internalNode.id !== node.id 828 | )) { 829 | let line: Line | null = null; 830 | for (const destPoint of locateAngle(destination)) { 831 | const direction = calcDirection(srcAnglePoint, destPoint); 832 | if ( 833 | directions.indexOf(direction) > -1 && 834 | (distanceOfP2L(destPoint, lines[0]) < 5 || 835 | distanceOfP2L(destPoint, lines[1]) < 5) 836 | ) { 837 | if ( 838 | line === null || 839 | distanceOfP2P(destPoint, srcAnglePoint) < 840 | distanceOfP2P(line[0], line[1]) 841 | ) { 842 | line = [destPoint, srcAnglePoint]; 843 | } 844 | } 845 | } 846 | if (line) { 847 | guidelines.push(line); 848 | } 849 | } 850 | } 851 | return guidelines; 852 | } 853 | 854 | export { 855 | isIntersected, 856 | distanceOfP2L, 857 | distanceOfP2P, 858 | calcDirection, 859 | calcCorners, 860 | between, 861 | pathing, 862 | approximatelyEquals, 863 | locateConnector, 864 | locateAngle, 865 | calcIntersectedConnections, 866 | calcIntersectedNodes, 867 | createConnection, 868 | roundTo10, 869 | center, 870 | }; 871 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./src/**/*.{ts,tsx}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | corePlugins: { 8 | preflight: false, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es5", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["ESNext", "DOM", "DOM.Iterable"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | "jsx": "react-jsx", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "ESNext", /* Specify what module code is generated. */ 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "dist", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | 68 | /* Interop Constraints */ 69 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 70 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 71 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 72 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 73 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 74 | 75 | /* Type Checking */ 76 | "strict": true, /* Enable all strict type-checking options. */ 77 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 78 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 79 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 80 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 81 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 82 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 83 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 84 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 85 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 86 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 87 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 88 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 89 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 90 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 91 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 92 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 93 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 94 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 95 | 96 | /* Completeness */ 97 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 98 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 99 | } 100 | } 101 | --------------------------------------------------------------------------------