├── .gitignore ├── LICENSE ├── README.en-US.md ├── README.md ├── example ├── index.html ├── index.jsx ├── index.less ├── menu.jsx ├── mock_data │ └── data.jsx ├── package.json ├── tsconfig.json └── webpack.config.js ├── package.json ├── rollup.config.js ├── src ├── adaptor.js ├── canvas │ ├── canvas.js │ ├── collapse-menu.jsx │ ├── edge.js │ ├── empty.js │ ├── endpoint.js │ ├── node.js │ └── right-menu.js ├── component │ ├── action-menu.tsx │ ├── edge-render.tsx │ └── tooltip │ │ ├── index.less │ │ └── index.tsx ├── config.ts ├── index.less ├── index.tsx └── static │ └── iconfont.css └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | package-lock.json 5 | es 6 | pack 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alibaba Cloud 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.en-US.md: -------------------------------------------------------------------------------- 1 |

2 | A React-based DAG diagram for data visualization modeling, suitable for UML, database modeling, data warehouse construction and other businesses. 3 |

4 | 5 | English | [简体中文](./README.md) 6 | 7 |

8 | 9 |

10 | 11 | ## ✨ Feature 12 | 13 | * support custom field properties 14 | * support custom title, title Icon 15 | * support the shrinking / expanding state of nodes, and show the mapping relationship after shrinking 16 | * support custom edge attributes and custom edge label 17 | * support the node, field's status of hover, focus, linked and full chain highlight 18 | * support the right-click menu of node and edge 19 | * support minimap and highlight state of minimap 20 | * support custom empty field content 21 | 22 | ## 📦 Install 23 | 24 | ``` 25 | npm install react-visual-modeling 26 | ``` 27 | 28 | ## API: 29 | 30 | ### VisualModeling properties 31 | 32 | | Property | Description | Type | Default | 33 | |:-------------:|:-------------------------------------------------------------------:|:----------------------------------------------------------------:|:-------:| 34 | | data | data | any | - | 35 | | width | component width | number | string | - | 36 | | height | component height | number | string  | - | 37 | | className | component className | string | - | 38 | | columns | property settings for each column of fields[columns Prop](#columns) | Array< [columns](#columns)> | - | 39 | | nodeMenu | Node Right-click Menu Configuration | Array< [menu](#menu-type)> | [ ] | 40 | | edgeMenu | Edge Right-click Menu Configuration | Array< [menu](#menu-type)> | [ ] | 41 | | config | As configured above[config Prop](#config) | any | - | 42 | | emptyContent | show content when table field is empty | string | JSX. Element | - | 43 | | emptyWidth | table container width when table field is empty | number | string | - | 44 | | onLoaded | canvas loaded event | (canvas) => void | - | 45 | | onChange | canvas data change event | (data) => void | - | 46 | | onFocusNode | focus node events | (node) => void | - | 47 | | onFocusEdge | focus edge events | (edge) => void | - | 48 | | onFocusCanvas | focus canvas blank events | () => void | - | 49 | | onDblClickNode| double click node events | () => void | - | 50 | 51 |
52 | 53 | ### columns 54 | 55 | property settings for each column of fields 56 | 57 | | Property | Description | Type | Default | 58 | |:----------:|:----------------------------------------------------------------------------------:|:-----------------------------------------:|:-------:| 59 | | title | name of each column | string | - | 60 | | key | the unique mark of each column, corresponding to the key value on the data | string | - | 61 | | width | width of each column | number | - | 62 | | primaryKey | whether the value corresponding to the key in this column is used as a unique sign | boolean | - | 63 | | render | Customize the style of each column | (key) => void | - | 64 | 65 |
66 | 67 | ### menu 68 | 69 | right-click menu configuration for'Node/Edge' 70 | 71 | | Property | Description | Type | Default | 72 | |:--------:|:---------------------------------------:|:-----------------------------------------------:|:-------:| 73 | | title | name of each column | string | - | 74 | | key | unique flag for each column menu | string | - | 75 | | render | Customize the style of each column menu | (key) => void | - | 76 | | onClick | Click Callback for Each Column | (key, data) => void | - | 77 | 78 |
79 | 80 | ### config 81 | 82 | the configuration of canvas 83 | 84 | | Property | Description | Type | Default | 85 | |:------------------:|:------------------------------------------------------------------------------:|:---------------------------------------------------------------:|:-------:| 86 | | showActionIcon | whether show operation icon: zoom in, zoom out, focus | boolean | - | 87 | | allowKeyboard | allow keyboard to delete events. Todo: supports shift multiple selection later | boolean | - | 88 | | collapse | whether to allow node shrinkage | [collapse Prop](#collapse-prop) { } | - | 89 | | titleRender | rendering methods for node's title | (title) => void | - | 90 | | titleExtIconRender | rendering method of buttons on right side of node | (node) => void | - | 91 | | labelRender | rendering method of edge's label | (label) => void | - | 92 | | minimap | whether to show minimap | [minimap Prop](#minimap-prop) { } | - | 93 | 94 |
95 | 96 | ### collapse 97 | 98 | the configuration of node contraction 99 | 100 | | Property | Description | Type | Default | 101 | |:-----------:|:-------------------------------:|:-----------------------------------:|:------------------------------------:| 102 | | enable | whether to allow node shrinkage | boolean | - | 103 | | defaultMode | default presentation form | string | show as 'expand/collapse' by default | 104 | 105 |
106 | 107 | ### minimap 108 | 109 | the configuration of minimap 110 | 111 | | Property | Description | Type | Default | 112 | |:--------:|:-----------------------:|:---------------------------------------------------------------------------:|:-------:| 113 | | enable | whether to show minimap | boolean | - | 114 | | config | the config of minimap | [minimap Config Prop](#minimap-config-prop) { } | - | 115 | 116 |
117 | 118 | ### minimap Config 119 | 120 | the config of minimap 121 | 122 | | Property | Description | Type | Default | 123 | |:---------------:|:-----------------:|:-------------------------------:|:-------:| 124 | | nodeColor | node color | any | - | 125 | | activeNodeColor | node active color | any | - | 126 | 127 |
128 | 129 | ## 🔗API 130 | 131 | ``` JSX 132 | import VisualModeling from 'react-visual-modeling'; 133 | import 'react-visual-modeling/dist/index.css'; 134 | {}} 141 | onChange={() => {}} 142 | onFocusNode={() => {}} 143 | onFocusEdge={() => {}} 144 | onFocusCanvas={() => {}} 145 | onDblClickNode={() => {}} 146 | > 147 | 148 | ``` 149 | 150 | ## 🔗API 151 | 152 | ``` javascript 153 | interface columns { // property settings for each column of fields 154 | title ? : string, // name of each column 155 | key: string, // the unique mark of each column, corresponding to the key value on the data 156 | width ? : number, // width of each column 157 | primaryKey: boolean, // whether the value corresponding to the key in this column is used as a unique sign 158 | render ? (value: any, rowData: any) : void // Customize the style of each column 159 | } 160 | 161 | interface config { // 162 | showActionIcon ? : boolean, // whether show operation icon: zoom in, zoom out, focus 163 | allowKeyboard ? : boolean, // allow keyboard to delete events. Todo: supports shift multiple selection later 164 | collapse: { 165 | enable: boolean, // allow node shrinkage 166 | defaultMode: string // show as 'expand/collapse' by default 167 | }, 168 | titleRender ? (title: JSX.Element) : void, // rendering methods for node's title 169 | titleExtIconRender ? (node: JSX.Element) : void, // rendering method of buttons on right side of node 170 | labelRender ? (label: JSX.Element) : void, // rendering method of edge's label 171 | minimap: { // whether to show minimap 172 | enable: boolean, 173 | config: { 174 | nodeColor: any, // node color 175 | activeNodeColor: any // active node color 176 | } 177 | } 178 | } 179 | 180 | interface menu { // right-click menu configuration for'Node/Edge' 181 | title ? : string, // name of each column 182 | key: string, // unique flag for each column menu 183 | render ? (key: string) : void, // Customize the style of each column menu 184 | onClick ? (key: string, data: any) : void, // Click Callback for Each Column 185 | } 186 | 187 | interface props { 188 | width ? : number | string, // component width 189 | height ? : number | string, // component height 190 | className ? : string, // component className 191 | columns: Array < columns > , // similar to antd's table column concept 192 | nodeMenu: Array < menu > , // Node Right-click Menu Configuration 193 | edgeMenu: Array < menu > , // Edge Right-click Menu Configuration 194 | config: config, // As configured above 195 | data: any, // data 196 | emptyContent ? : string | JSX.Element; // show content when table field is empty 197 | emptyWidth ? : number | string; // table container width when table field is empty 198 | onLoaded(canvas: any): void, // canvas loaded event 199 | onChange(data: any): void, // canvas data change event 200 | onFocusNode(node: any): void, // focus node events 201 | onFocusEdge(edge: any): void, // focus edge events 202 | onFocusCanvas(): void, // focus canvas blank events 203 | }; 204 | ``` 205 | 206 | If you need more customized requirements, you can refer to issue or [butterfly](https://github.com/alibaba/butterfly/blob/master/README.en-US.md) to customize your needs 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

🎨可视化模型设计器

2 | 3 | ![MIT](https://img.shields.io/npm/l/react-visual-modeling) 4 | ![npm](https://img.shields.io/npm/v/react-visual-modeling) 5 | 6 | [English](./README.en-US.md) | 简体中文 7 | 8 | 9 |

10 | 11 |

12 | 13 | ## ✨ 特性 14 | 15 | * 支持定制字段属性 16 | * 支持定制title,title的icon 17 | * 支持节点的收缩/展开状态,以及收缩后的映射关系 18 | * 支持定制线段的属性以及自定义label 19 | * 支持节点,字段的hover,focus,linked以及全链路高亮状态 20 | * 支持节点,线段的右键菜单 21 | * 支持minimap,以及minimap的联动移动和高亮状态 22 | * 支持空字段内容定制 23 | 24 | ## 📦 安装 25 | 26 | ``` shell 27 | $ npm install react-visual-modeling butterfly-dag -S 28 | ``` 29 | 30 | ## 🧤`Props` 31 | 32 | |参数|说明|类型|默认值| 33 | |----|----|----|----| 34 | |data|画布数据|any|-| 35 | |width|组件宽度| `number` \| `string` |-| 36 | |height|组件高度| `number` \| `string` |-| 37 | |className|组件类名 | `string` |-| 38 | |columns| 列的配置描述, 见[columns props](#columns) | Array<[columns](#columns)> | - | 39 | |nodeMenu| 节点右键菜单配置| Array<[menu](#menu-type)> | [ ] | 40 | |edgeMenu| 线段右键菜单配置| Array<[menu](#menu-type)> | [ ] | 41 | |actionMenu | 右上角菜单配置 | `action[]` | [] | 42 | |config| 组件的画布配置,见[config props](#config) | any | |-| 43 | |emptyContent| 当表字段为空时显示内容 | `string` \| `JSX. Element`| - | 44 | |emptyWidth| 当表字段为空时表容器宽度 | `number` \| `string`| - | 45 | |onLoaded| 渲染完毕事件 |`(canvas) => void` | - | 46 | |onChange| 图内数据变化事件|`(data) => void`| - | 47 | |onFocusNode |聚焦节点事件 |`(node) => void`| - | 48 | |onFocusEdge |聚焦线段事件 |`(edge) => void`| - | 49 | |onFocusCanvas | 聚焦空白处事件 | `() => void` | |-| 50 | |onDblClickNode| 双击节点事件 |`(node) => void`| - | 51 | | selectable | 是否开启框选 | `boolean` | false | 52 | |onSelect | 框选事件 | `(nodes, edges) => void`| - | 53 | 54 |
55 | 56 | ### columns 57 | 58 | > 节点字段每列的属性设置 59 | 60 | |参数|说明|类型|默认值| 61 | |---|---|---|---| 62 | |title|每列的名字| `string` |-| 63 | | key| 每列的唯一标志,对应数据上的key值 | `string` |-| 64 | |width|每列宽度| `number` ||-| 65 | | primaryKey | 这列的key对应的value是否作为键值对 | `boolean` |-| 66 | |render|支持每列的自定义样式|`(key) => void`|-| 67 | 68 |
69 | 70 | ### menu 71 | 72 | > '节点/线段'的右键菜单配置 73 | 74 | |参数| 说明|类型| 默认值 | 75 | |---|---|---|---| 76 | | title |每列的展示的名字| `string` ||-| 77 | |key| 每列的唯一标志,对应数据上的key值 | `string` ||-| 78 | | render | 支持每列的自定义样式 | `(key) => void` ||-| 79 | | onClick | 每列的点击回调| `(key, data) => void` | |-| 80 | 81 |
82 | 83 | ### config 84 | 85 | > 画布配置 86 | 87 | |参数|说明|类型|默认值| 88 | |---|---|---|---| 89 | |showActionIcon| 是否展示操作icon:放大,缩小,聚焦 | `boolean` |-| 90 | |allowKeyboard|允许键盘删除事件| `boolean` |-| 91 | | collapse |是否允许节点收缩| [collapse prop](#collapse-prop) { }|-| 92 | | titleRender| 节点title的渲染方法 | `(title) => void` |-| 93 | | titleExtIconRender | 节点右侧按钮的渲染方法 | `(node) => void` |-| 94 | | labelRender| 线段label的渲染方法 | `(label) => void` |-| 95 | |minimap | 是否开启缩略图| [minimap prop](#minimap-prop) { }|-| 96 | 97 |
98 | 99 | ### collapse 100 | 101 | > 节点收缩属性 102 | 103 | | 参数| 说明 | 类型| 默认值 | 104 | |---|---|---|---| 105 | |enable| 是否允许节点收缩 | `boolean` | - | 106 | | defaultMode |默认展示形式 | `string` | 默认以"展开/收缩"形式展示 | 107 | 108 |
109 | 110 | ### minimap 111 | 112 | > 缩略图属性 113 | 114 | |参数|说明|类型|默认值| 115 | |---|---|---|---| 116 | | enable | 是否开启缩略图 | `boolean` | false | 117 | | config | 缩略图的配置 | [minimap props](#minimap-config-prop) | {} | 118 | 119 |
120 | 121 | ### minimap config 122 | 123 | > 缩略图的配置 124 | 125 | |参数|说明|类型|默认值| 126 | |---|---|---|---| 127 | |nodeColor|节点颜色|`string`|-| 128 | |activeNodeColor|节点激活颜色|`string`| -| 129 | 130 |
131 | 132 | ## Usage 133 | 134 | ``` JSX 135 | import VisualModeling from 'react-visual-modeling'; 136 | import 'react-visual-modeling/dist/index.css'; 137 | 138 | // data 参考 example/mock_data/data.jsx 139 | {}} 146 | onChange={() => {}} 147 | onFocusNode={() => {}} 148 | onFocusEdge={() => {}} 149 | onFocusCanvas={() => {}} 150 | onDblClickNode={(node) => {}} // Double Click Node Event 151 | /> 152 | ``` 153 | 154 | ## Interface 155 | 156 | ```ts 157 | // 组件 Props 定义 158 | interface IProps { 159 | width?: number | string, // 组件宽 160 | height?: number | string, // 组件高 161 | className?: string, // 组件classname 162 | columns: Array< columns > , // 跟antd的table的column的概念类似 163 | nodeMenu?: Array< menu > , // 节点右键菜单配置 164 | edgeMenu?: Array< menu > , // 线段右键菜单配置 165 | actionMenu?: action[], // 右上角菜单配置,默认配置的key为 zoom-in(缩小), zoom-out(放大), fit(适配画布) 166 | config?: config, // 往下看 167 | data: IData, // 数据入参,往下看 168 | emptyContent?: JSX.Element; // 当表字段为空时显示内容 169 | emptyWidth?: number | string; // 当表字段为空时表容器宽度 170 | onLoaded(canvas: any): void, // 渲染完毕事件 171 | onChange(data: any): void, // 图内数据变化事件 172 | onFocusNode(node: any): void, // 聚焦节点事件 173 | onFocusEdge(edge: any): void, // 聚焦线段事件 174 | onFocusCanvas(): void, // 聚焦空白处事件 175 | onDblClickNode ? (node: any) : void, // 双击节点事件 176 | onSelect(nodes: any, edges: any): void, // 画布框选事件 177 | selectable: boolean, // 是否可框选 178 | }; 179 | 180 | // 节点字段每列的属性设置 181 | interface columns { 182 | title?: string, // 每列的名字 183 | key: string, // 每列的唯一标志,对应数据上的key值 184 | width?: number, // 每列宽度(px) 185 | primaryKey: boolean, // 这列的key对应的value是否作为键值对 186 | render?: (value: any, rowData: any) => void // 可自定义每列的样式 187 | } 188 | 189 | // 画布显示配置 190 | interface config { 191 | butterfly: any; // butterfly-dag的配置,参考:https://github.com/alibaba/butterfly/blob/dev/v4/docs/zh-CN/canvas.md 192 | showActionIcon?: boolean, // 是否展示操作icon:放大,缩小,聚焦 193 | allowKeyboard?: boolean, // 允许键盘删除事件,TODO: 以后支持shift多选 194 | collapse:{ 195 | enable: boolean, // 允许节点收缩 196 | defaultMode: string // 默认以"展开/收缩"形式展示 197 | }, 198 | titleRender?: (title: JSX.Element) => void, // 节点title的渲染方法 199 | titleExtIconRender?: (node: JSX.Element) => void, // 节点右侧按钮的渲染方法 200 | labelRender?: (label: JSX.Element) => void, // 线段label的渲染方法 201 | minimap: { // 是否开启缩略图 202 | enable: boolean, 203 | config: { 204 | nodeColor: any, // 节点颜色 205 | activeNodeColor: any // 节点激活颜色 206 | } 207 | } 208 | } 209 | 210 | // 输入数据定义 211 | interface IData { 212 | nodes: { // 节点 213 | id: string | number; 214 | title: string; 215 | fields: {id: string, [key: string]: any}[]; // 当前节点字段列表 216 | [key: string]: any; 217 | }[], 218 | edges: { 219 | id: string | number, 220 | sourceNode: string, // 源节点ID 221 | targetNode: string, // 目标节点ID 222 | source: string, // 源节点列ID 223 | target: string, // 目标节点列ID 224 | }[] 225 | } 226 | 227 | // '节点/线段'的右键菜单配置 228 | interface menu { 229 | title?: string, // 每列的展示的名字 230 | key: string, // 每列的唯一标志,对应数据上的key值 231 | render?: (key: string) => JSX.Element, // 支持每列的自定义样式 232 | onClick?: (key: string, data: any) => void, // 每列的点击回调 233 | } 234 | 235 | // action菜单(右上角) 236 | interface action { 237 | key: string; // 唯一标识 238 | title: string; // 名字 239 | icon: string | JSX.Element; // 图标 240 | onClick: (canvas: any) => void; // 响应函数 241 | disable: boolean; // false 则不显示 242 | } 243 | 244 | ``` 245 | 246 | ## 常用功能 247 | 248 | ### 1. 隐藏默认 `actionMenu` 和添加自定义 `actionMenu` 249 | 250 | ```jsx 251 | import {StarOutlined} from '@ant-design/icons'; 252 | 253 | // 默认的三个 actionMenu 为 zoom-in, zoom-out, fit 254 | const actionMenu = [ 255 | { 256 | key: 'zoom-in', 257 | disable: true 258 | }, 259 | { 260 | icon: , 261 | key: 'star', 262 | onClick: () => { 263 | alert('点击收藏!') 264 | } 265 | } 266 | ] 267 | 268 | 269 | ``` 270 | 271 | ### 2. 设置连线配置 272 | 273 | ```jsx 274 | const config = { 275 | butterfly: { 276 | theme: { 277 | edge: { 278 | shapeType: 'Manhattan', 279 | } 280 | } 281 | } 282 | } 283 | 284 | 285 | ``` 286 | 287 | ### 3. 实现框选功能 288 | ```jsx 289 | // 框选结果 290 | const onSelect = (nodes, edges) => { 291 | console.log(nodes, edges); 292 | } 293 | 294 | 298 | ``` 299 | 300 | 301 | 如需要更多定制的需求,您可以提issue或者参考[Butterfly](https://github.com/alibaba/butterfly)来定制您需要的需求 302 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | DTDesign-React数据建模组件 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /example/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _ from 'lodash'; 3 | import ReactDOM from 'react-dom'; 4 | import {Layout, Tooltip} from 'antd'; 5 | import {BrowserRouter as Router} from 'react-router-dom'; 6 | 7 | import TableBuilding from '../src/index.tsx'; 8 | import {nodeMenu, edgeMenu, actionMenu} from './menu'; 9 | import * as MockData from './mock_data/data.jsx'; 10 | 11 | import 'antd/dist/antd.css'; 12 | import './index.less'; 13 | 14 | const {Header} = Layout; 15 | const {columns, data} = MockData; 16 | 17 | const config = { 18 | // butterfly-dag 属性 19 | butterfly:{ 20 | theme:{ 21 | edge: { 22 | // shapeType: 'Manhattan', 23 | } 24 | }, 25 | }, 26 | 27 | // 网格布局 28 | gridMode: { 29 | theme: { 30 | shapeType: 'circle', // 展示的类型,支持line & circle 31 | gap: 20, // 网格间隙 32 | circleRadiu: 1.5, // 圆点半径 33 | circleColor: 'rgba(255, 255, 255, 0.08)', // 圆点颜色 34 | } 35 | }, 36 | 37 | // 键盘事件 38 | allowKeyboard: true, 39 | 40 | // 小地图相关 41 | minimap: { 42 | enable: true, 43 | config: { 44 | nodeColor: 'rgba(216, 216, 216, 0.13)', 45 | activeNodeColor: '#F66902', 46 | viewportStyle: { 47 | 'background-color': 'rgba(216, 216, 216, 0.07)' 48 | } 49 | } 50 | }, 51 | 52 | // 是否表格可折叠 53 | collapse: { 54 | enable: true, 55 | showCollapseDetail: true 56 | }, 57 | titleRender: (title) => { 58 | return title; 59 | }, 60 | titleExtIconRender: () => { 61 | return ( 62 | 63 | 66 | 67 | ); 68 | }, 69 | labelRender: (label) => { 70 | if(!label) { 71 | return 'connection'; 72 | } 73 | 74 | return ( 75 | 76 | {label} 77 | 78 | ) 79 | } 80 | }; 81 | 82 | class Component extends React.Component { 83 | constructor(props) { 84 | super(props); 85 | this.canvas = null; 86 | this.state = { 87 | columns: _.cloneDeep(columns), 88 | data: {}, 89 | selectable: false, 90 | collapse: false 91 | }; 92 | } 93 | 94 | componentWillMount() { 95 | this._data = _.cloneDeep(data); 96 | this.setState({ 97 | data: this._data 98 | }); 99 | } 100 | 101 | onAddEdge = () => { 102 | const data = this.state.data; 103 | 104 | data.edges.push({ 105 | "id": 1, 106 | "sourceNode": "aaa", 107 | "targetNode": "bbb", 108 | "source": "field_1", 109 | "target": "field_2" 110 | }); 111 | 112 | this.setState({ 113 | data: {...data} 114 | }); 115 | } 116 | 117 | onDelEdge = () => { 118 | const data = this.state.data; 119 | data.edges.pop(); 120 | 121 | this.setState({ 122 | data: {...data} 123 | }); 124 | } 125 | 126 | onSetGridMode = () => { 127 | this.setState({ 128 | selectable: true 129 | }); 130 | } 131 | 132 | render() { 133 | const {selectable} = this.state; 134 | 135 | return ( 136 | { 139 | // 自定义注册箭头 140 | const {Arrow} = utils; 141 | Arrow.registerArrow([{ 142 | key: 'arrow1', 143 | type: 'svg', 144 | width: 14, 145 | height: 14, 146 | content: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyBjbGFzcz0iaWNvbiIgd2lkdGg9IjIwMHB4IiBoZWlnaHQ9IjIwMC4wMHB4IiB2aWV3Qm94PSIwIDAgMTAyNCAxMDI0IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg0NS4zNTQ2NjcgMjYuNDk2bDQ1LjQ4MjY2NiA3Mi4xOTJMMzAyLjEyMjY2NyA0NjkuMzMzMzMzSDkxNy4zMzMzMzN2ODUuMzMzMzM0SDM2MC40OTA2NjdsNTMwLjk0NCAzNzMuNjc0NjY2LTQ5LjA2NjY2NyA2OS43Ni02NDUuODAyNjY3LTQ1NC40IDM2LjUyMjY2Ny01Mi4wMTA2NjYtMzUuOTI1MzMzLTU3LjA0NTMzNEw4NDUuMzU0NjY3IDI2LjQ1MzMzM3oiIGZpbGw9IiNGNjY5MDIiIC8+PHBhdGggZD0iTTI3Ny4zMzMzMzMgNTEybS0xMjggMGExMjggMTI4IDAgMSAwIDI1NiAwIDEyOCAxMjggMCAxIDAtMjU2IDBaIiBmaWxsPSIjRjY2OTAyIiAvPjxwYXRoIGQ9Ik0yNzcuMzMzMzMzIDM0MS4zMzMzMzNhMTcwLjY2NjY2NyAxNzAuNjY2NjY3IDAgMSAxIDAgMzQxLjMzMzMzNCAxNzAuNjY2NjY3IDE3MC42NjY2NjcgMCAwIDEgMC0zNDEuMzMzMzM0eiBtMCA4NS4zMzMzMzRhODUuMzMzMzMzIDg1LjMzMzMzMyAwIDEgMCAwIDE3MC42NjY2NjYgODUuMzMzMzMzIDg1LjMzMzMzMyAwIDAgMCAwLTE3MC42NjY2NjZ6IiBmaWxsPSIjRkZCMjdCIiAvPjwvc3ZnPg==' 147 | }]); 148 | }} 149 | 150 | onLoaded={(canvas) => { 151 | this.canvas = canvas; 152 | canvas.on('events', (data) => { 153 | // console.log(data); 154 | }); 155 | }} 156 | 157 | // =========== 节点Table相关属性 =========== 158 | columns={this.state.columns} 159 | data={this.state.data} 160 | onDblClickNode={(node) => {}} 161 | emptyContent={ 162 |
163 |

暂无数据

164 |

{ 167 | e.stopPropagation(); 168 | console.log('自定义空状态'); 169 | }} 170 | > 171 | + 添加字段 172 |

173 |
174 | } 175 | 176 | // =========== 菜单相关属性 =========== 177 | nodeMenu={nodeMenu} 178 | edgeMenu={edgeMenu} 179 | actionMenu={actionMenu({ 180 | onAddEdge: this.onAddEdge, 181 | onDelEdge: this.onDelEdge, 182 | onSetGridMode: this.onSetGridMode 183 | })} 184 | 185 | // =========== 画布配置 =========== 186 | config={{ 187 | ...config, 188 | collapse: { 189 | status: this.state.collapse, 190 | showCollapseDetail: true 191 | } 192 | }} 193 | 194 | // =========== 框选配置 =========== 195 | selectable={selectable} 196 | onSelect={() => { 197 | this.setState({ 198 | selectable: false 199 | }) 200 | }} 201 | 202 | beforeDeleteNode={(nodes) => { 203 | // 返回false或者Promise.reject则不会删除 204 | }} 205 | beforeDeleteEdge={(edges) => { 206 | console.log(edges); 207 | // 返回false或者Promise.reject则不会删除 208 | }} 209 | /> 210 | ) 211 | } 212 | } 213 | 214 | ReactDOM.render(( 215 | 216 | 217 |
DTDesign-React可视化建模组件
218 | 219 | 220 | 221 |
222 |
223 | ), document.getElementById('main')); 224 | -------------------------------------------------------------------------------- /example/index.less: -------------------------------------------------------------------------------- 1 | html, body { 2 | padding: 0; 3 | margin: 0; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | #main { 9 | width: 100%; 10 | height: 100%; 11 | .ant-layout, .ant-layout-content { 12 | height: 100%; 13 | } 14 | 15 | .menu { 16 | height: 100%; 17 | width: 200px; 18 | overflow-y: auto; 19 | } 20 | 21 | .header.ant-layout-header { 22 | background-color: #212528; 23 | color: #fff; 24 | height: 50px; 25 | line-height: 50px; 26 | } 27 | 28 | section.ant-layout { 29 | background: #2E2E2E; 30 | } 31 | 32 | .butterfly-table-building { 33 | width: 100%; 34 | height: 100%; 35 | } 36 | } 37 | .view-port-background { 38 | background-color: rgba(216, 216, 216, 0.05); 39 | } 40 | 41 | .empty-content { 42 | height: 100px; 43 | padding-top: 20px; 44 | text-align: center; 45 | .desc { 46 | color: #474747; 47 | } 48 | .add-field { 49 | color: #0070cc; 50 | cursor: pointer; 51 | } 52 | } -------------------------------------------------------------------------------- /example/menu.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | StarOutlined, PlusOutlined, 4 | MinusOutlined, GatewayOutlined 5 | } from '@ant-design/icons'; 6 | 7 | // 节点菜单 8 | export const nodeMenu = [ 9 | { 10 | key: 'setting', 11 | title: '节点设置', 12 | onClick: (key, data) => { 13 | console.log('click setting') 14 | } 15 | }, 16 | { 17 | key: 'delete', 18 | render: (key, data) => { 19 | return 节点删除 20 | }, 21 | onClick: (key, data) => { 22 | console.log('delete node'); 23 | } 24 | } 25 | ]; 26 | 27 | // 边菜单 28 | export const edgeMenu= [ 29 | { 30 | key: 'setting', 31 | title: '线段设置', 32 | onClick: (key, data) => { 33 | console.log('click setting') 34 | } 35 | }, 36 | { 37 | key: 'delete', 38 | render: (key, data) => { 39 | return 线段删除 40 | }, 41 | onClick: (key, data) => { 42 | console.log('delete node'); 43 | } 44 | } 45 | ]; 46 | 47 | export const actionMenu = ({ 48 | onAddEdge, 49 | onDelEdge, 50 | onSetGridMode 51 | }) => [ 52 | { 53 | key: 'zoom-in', 54 | disable: true 55 | }, 56 | { 57 | icon: , 58 | key: 'star', 59 | onClick: () => { 60 | alert('点击收藏!') 61 | } 62 | }, 63 | { 64 | icon: , 65 | key: 'plus', 66 | title: '添加一条连线', 67 | onClick: () => { 68 | onAddEdge(); 69 | } 70 | }, 71 | { 72 | icon: , 73 | key: 'minus', 74 | title: '删除一条连线', 75 | onClick: () => { 76 | onDelEdge(); 77 | } 78 | }, 79 | { 80 | icon: , 81 | title: '框选', 82 | key: 'select', 83 | onClick: () => { 84 | onSetGridMode(); 85 | } 86 | } 87 | ] -------------------------------------------------------------------------------- /example/mock_data/data.jsx: -------------------------------------------------------------------------------- 1 | export const columns = [ 2 | { 3 | key: 'id', 4 | primaryKey: true, 5 | width: 76 6 | }, 7 | { 8 | key: 'type', 9 | width: 60, 10 | render: (val, row) => { 11 | return val.toString().toUpperCase(); 12 | } 13 | }, 14 | { 15 | key: 'desc', 16 | width: 90 17 | } 18 | ]; 19 | 20 | export const data = { 21 | "nodes": [ 22 | { 23 | "top": 300, 24 | "left": 200, 25 | "id": "aaa", 26 | "title": "aaa", 27 | "fields": [ 28 | { 29 | "id": "field_1", 30 | "type": "string", 31 | "desc": "字段1" 32 | }, 33 | { 34 | "id": "field_2", 35 | "type": "string", 36 | "desc": "字段2" 37 | }, 38 | { 39 | "id": "field_3", 40 | "type": "string", 41 | "desc": "字段3" 42 | } 43 | ] 44 | }, 45 | { 46 | "top": 500, 47 | "left": 600, 48 | "id": "bbb", 49 | "title": "bbb", 50 | "fields": [ 51 | { 52 | "id": "field_1", 53 | "type": "string", 54 | "desc": "字段1" 55 | }, 56 | { 57 | "id": "field_2", 58 | "type": "string", 59 | "desc": "字段2" 60 | }, 61 | { 62 | "id": "field_3", 63 | "type": "string", 64 | "desc": "字段3" 65 | } 66 | ] 67 | }, 68 | { 69 | "top": 300, 70 | "left": 1000, 71 | "id": "ccc", 72 | "title": "ccc", 73 | "fields": [ 74 | { 75 | "id": "field_1", 76 | "type": "string", 77 | "desc": "字段1" 78 | }, 79 | { 80 | "id": "field_2", 81 | "type": "string", 82 | "desc": "字段2" 83 | }, 84 | { 85 | "id": "field_3", 86 | "type": "string", 87 | "desc": "字段3" 88 | } 89 | ] 90 | }, 91 | { 92 | "top": 100, 93 | "left": 600, 94 | "id": "ddd", 95 | "title": "ddd", 96 | "fields": [ 97 | { 98 | "id": "field_1", 99 | "type": "string", 100 | "desc": "字段1" 101 | }, 102 | { 103 | "id": "field_2", 104 | "type": "string", 105 | "desc": "字段2" 106 | }, 107 | { 108 | "id": "field_3", 109 | "type": "string", 110 | "desc": "字段3" 111 | } 112 | ] 113 | }, 114 | { 115 | "top": 50, 116 | "left": 1000, 117 | "id": "eee", 118 | "title": "eee", 119 | "fields": [ 120 | { 121 | "id": "field_1", 122 | "type": "string", 123 | "desc": "字段1" 124 | }, 125 | { 126 | "id": "field_2", 127 | "type": "string", 128 | "desc": "字段2" 129 | }, 130 | { 131 | "id": "field_3", 132 | "type": "string", 133 | "desc": "字段3" 134 | } 135 | ] 136 | }, 137 | { 138 | "top": 540, 139 | "left": 1000, 140 | "id": "fff", 141 | "title": "自定义空内容", 142 | "fields": [] 143 | } 144 | ], 145 | "edges": [ 146 | { 147 | "id": 1, 148 | "sourceNode": "aaa", 149 | "targetNode": "bbb", 150 | "source": "field_1", 151 | "target": "field_1", 152 | "label": "label" 153 | }, 154 | { 155 | "id": 2, 156 | "sourceNode": "aaa", 157 | "targetNode": "bbb", 158 | "source": "field_3", 159 | "target": "field_3" 160 | }, 161 | { 162 | "id": 3, 163 | "sourceNode": "aaa", 164 | "targetNode": "ddd", 165 | "source": "field_2", 166 | "target": "field_2" 167 | }, 168 | { 169 | "id": 4, 170 | "sourceNode": "ddd", 171 | "targetNode": "eee", 172 | "source": "field_2", 173 | "target": "field_2" 174 | }, 175 | { 176 | "id": 5, 177 | "sourceNode": "ddd", 178 | "targetNode": "ccc", 179 | "source": "field_2", 180 | "target": "field_2" 181 | }, 182 | { 183 | "id": 6, 184 | "sourceNode": "bbb", 185 | "targetNode": "eee", 186 | "source": "field_1", 187 | "target": "field_3" 188 | }, 189 | { 190 | "id": 7, 191 | "sourceNode": "bbb", 192 | "targetNode": "ccc", 193 | "source": "field_3", 194 | "target": "field_1" 195 | }, 196 | { 197 | "id": 8, 198 | "sourceNode": "bbb", 199 | "targetNode": "ccc", 200 | "source": "field_3", 201 | "target": "field_3", 202 | "arrowShapeType": "arrow1" 203 | } 204 | ] 205 | }; 206 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "webpack-dev-server" 9 | }, 10 | "author": "jambin", 11 | "license": "MIT", 12 | "dependencies": { 13 | "antd": "~4.10.3", 14 | "jquery": "^3.4.1", 15 | "react": "~16.12.0", 16 | "react-dom": "^16.13.1", 17 | "react-router": "~5.1.2", 18 | "react-router-dom": "~5.1.2", 19 | "webpack": "^4.41.6", 20 | "webpack-dev-server": "^3.10.3" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "~7.8.3", 24 | "@babel/plugin-proposal-class-properties": "~7.8.3", 25 | "@babel/plugin-proposal-object-rest-spread": "~7.8.3", 26 | "@babel/plugin-transform-runtime": "^7.12.1", 27 | "@babel/preset-env": "~7.8.3", 28 | "@babel/preset-react": "~7.8.3", 29 | "babel-loader": "8.0.6", 30 | "babel-plugin-transform-es2015-modules-commonjs": "~6.26.2", 31 | "css-loader": "~1.0.0", 32 | "eslint": "~5.16.0", 33 | "eslint-config-aliyun": "~2.0.3", 34 | "eslint-plugin-react": "~7.13.0", 35 | "file-loader": "~2.0.0", 36 | "html-webpack-plugin": "^3.2.0", 37 | "less": "~3.7.0", 38 | "less-loader": "~4.1.0", 39 | "mini-css-extract-plugin": "~0.9.0", 40 | "style-loader": "~0.21.0", 41 | "ts-loader": "~8.0.14", 42 | "url-loader": "~1.0.1", 43 | "webpack": "~4.41.5", 44 | "webpack-cli": "^3.0.8", 45 | "webpack-dev-server": "~3.10.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": false, 5 | "module": "commonjs", 6 | "target": "es5", 7 | "jsx": "react", 8 | "allowJs": true 9 | } 10 | } -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const path = require('path'); 6 | 7 | module.exports = { 8 | entry: { 9 | app: './index.jsx' 10 | }, 11 | output: { 12 | filename: '[name].js', 13 | chunkFilename: '[name].js' 14 | }, 15 | resolve: { 16 | modules: [ 17 | path.resolve(process.cwd(), 'node_modules'), 18 | path.resolve(process.cwd(), '../node_modules'), 19 | 'node_modules' 20 | ], 21 | extensions: ['.js', '.jsx', '.ts', '.tsx'] 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.tsx?$/, 27 | use: 'ts-loader', 28 | exclude: /node_modules/, 29 | }, 30 | { 31 | test: /\.(js|jsx)$/, 32 | exclude: /(node_modules|bower_components)/, 33 | use: { 34 | loader: 'babel-loader', 35 | options: { 36 | presets: [ 37 | '@babel/preset-env', 38 | '@babel/preset-react' 39 | ], 40 | plugins: [ 41 | '@babel/plugin-transform-runtime', 42 | '@babel/plugin-transform-modules-commonjs', 43 | '@babel/plugin-proposal-object-rest-spread', 44 | '@babel/plugin-proposal-class-properties', 45 | ] 46 | } 47 | } 48 | }, 49 | { 50 | test: /\.(woff|woff2|eot|ttf|otf)$/, 51 | use: { 52 | loader: 'file-loader', 53 | 54 | options: { 55 | name: '[name][hash].[ext]', 56 | outputPath: 'fonts/' 57 | } 58 | } 59 | }, 60 | { 61 | test: /\.(less|css)$/, 62 | use: [ 63 | { 64 | loader: 'style-loader' 65 | }, 66 | { 67 | loader: 'css-loader' 68 | }, 69 | { 70 | loader: 'less-loader', 71 | options: { 72 | javascriptEnabled: true 73 | } 74 | } 75 | ] 76 | }, 77 | { 78 | test: /\.(png|jpg|gif|svg)$/, 79 | use: [ 80 | { 81 | loader: 'url-loader' 82 | } 83 | ] 84 | } 85 | ] 86 | }, 87 | plugins: [ 88 | new MiniCssExtractPlugin({ 89 | filename: '[name].css' 90 | }), 91 | new HtmlWebpackPlugin({ 92 | template: './index.html' 93 | }) 94 | ], 95 | devServer: { 96 | contentBase: './dist', // 本地服务器所加载的页面所在的目录 97 | historyApiFallback: true, // 不跳转 98 | inline: true, // 实时刷新 99 | index: 'index.html', 100 | port: 8080, 101 | open: true 102 | } 103 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-visual-modeling", 3 | "version": "1.1.5", 4 | "description": "一个基于React的数据可视化建模的DAG图,适用于UML,数据库建模,数据仓库建设等业务", 5 | "main": "dist/index.js", 6 | "pack": "pack/index.js", 7 | "directories": { 8 | "example": "example" 9 | }, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1", 12 | "build": "rollup -c && cp -r ./src/static ./dist && cp -r ./src/static ./pack", 13 | "dev": "rollup -w -c", 14 | "prepublishOnly": "npm run build", 15 | "start": "cd example && npm start" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git@gitlab.alibaba-inc.com:DataQ-FE-Components/butterfly-table-building.git" 20 | }, 21 | "author": "无惟", 22 | "license": "MIT", 23 | "dependencies": { 24 | "butterfly-dag": "^4.0.2", 25 | "jquery": "^3.5.1", 26 | "react-data-mapping": "^1.1.2" 27 | }, 28 | "peerDependencies": { 29 | "react": ">15.6.1, <17.0.0", 30 | "react-dom": ">15.6.1, <17.0.0", 31 | "lodash": "^4.17.20" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "~7.12.0", 35 | "@babel/plugin-proposal-class-properties": "~7.12.1", 36 | "@babel/plugin-proposal-object-rest-spread": "~7.12.0", 37 | "@babel/plugin-transform-modules-commonjs": "^7.12.1", 38 | "@babel/plugin-transform-runtime": "^7.12.1", 39 | "@babel/preset-env": "~7.12.0", 40 | "@babel/preset-react": "~7.12.1", 41 | "@babel/preset-typescript": "~7.12.7", 42 | "@types/jquery": "^3.5.5", 43 | "@types/lodash": "~4.14.167", 44 | "@types/react": "~17.0.0", 45 | "@types/react-dom": "^17.0.0", 46 | "babel-loader": "~8.2.0", 47 | "babel-plugin-transform-es2015-modules-commonjs": "~6.26.2", 48 | "less": "~3.12.2", 49 | "postcss": "~8.1.7", 50 | "rollup": "~2.38.0", 51 | "rollup-plugin-babel": "~4.4.0", 52 | "rollup-plugin-commonjs": "~10.1.0", 53 | "rollup-plugin-extensions": "~0.1.0", 54 | "rollup-plugin-json": "~4.0.0", 55 | "rollup-plugin-peer-deps-external": "~2.2.4", 56 | "rollup-plugin-postcss": "~4.0.0", 57 | "rollup-plugin-typescript2": "~0.29.0", 58 | "rollup-plugin-url": "~3.0.1", 59 | "typescript": "~4.1.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import json from 'rollup-plugin-json'; 3 | import babel from 'rollup-plugin-babel'; 4 | import postcss from 'rollup-plugin-postcss'; 5 | import commonjs from 'rollup-plugin-commonjs'; 6 | import extensions from 'rollup-plugin-extensions'; 7 | import external from 'rollup-plugin-peer-deps-external'; 8 | import url from "rollup-plugin-url"; 9 | import typescript from 'rollup-plugin-typescript2'; 10 | 11 | import pkg from './package.json'; 12 | 13 | const config = { 14 | presets: [ 15 | '@babel/preset-typescript', 16 | '@babel/preset-react', 17 | '@babel/preset-env' 18 | ], 19 | plugins: [ 20 | '@babel/plugin-proposal-class-properties' 21 | ] 22 | }; 23 | 24 | 25 | const plugins = [ 26 | extensions({ 27 | extensions: ['.js'], 28 | resolveIndex: true, 29 | }), 30 | external(), 31 | babel(Object.assign({ 32 | exclude: [ 33 | 'node_modules/**', 34 | ] 35 | }, config)), 36 | postcss({ 37 | extract: true, 38 | modules: false, 39 | use: [ 40 | [ 41 | 'less', 42 | { 43 | javascriptEnabled: true, 44 | } 45 | ] 46 | ] 47 | }), 48 | commonjs(), 49 | json(), 50 | // url({ 51 | // limit: 100 * 1024, // inline files < 100k, copy files > 100k 52 | // include: ["**/*.svg", "**/*.eot", "**/*.tff", "**/*.woff", "**/*.woff2"], // defaults to .svg, .png, .jpg and .gif files 53 | // emitFiles: true // defaults to true 54 | // }), 55 | typescript({ 56 | tsconfigOverride: { 57 | compilerOptions: { 58 | module: "es2015" 59 | } 60 | } 61 | }) 62 | ]; 63 | 64 | const rollupCfg = []; 65 | 66 | // all in one 构建 67 | rollupCfg.push({ 68 | input: path.join(__dirname, 'src/index.tsx'), 69 | output: [ 70 | { 71 | file: pkg.pack, 72 | format: 'cjs', 73 | exports: 'named', 74 | sourcemap: 'inline' 75 | }, 76 | { 77 | file: pkg.main, 78 | format: 'es', 79 | sourcemap: 'inline' 80 | } 81 | ], 82 | plugins 83 | }); 84 | 85 | export default rollupCfg; 86 | -------------------------------------------------------------------------------- /src/adaptor.js: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | import Edge from './canvas/edge'; 4 | import TableNode from './canvas/node'; 5 | 6 | export const transformInitData = (info) => { 7 | let { 8 | columns, data, config, 9 | nodeMenu, edgeMenu, emptyContent, 10 | emptyWidth 11 | } = info; 12 | 13 | let nodes = (data.nodes || []).map((item) => { 14 | return _.assign(item, { 15 | _columns: columns, 16 | _config: config, 17 | _menu: nodeMenu, 18 | Class: TableNode, 19 | _emptyWidth: emptyWidth, 20 | _emptyContent: emptyContent 21 | }); 22 | }); 23 | 24 | let edges = (data.edges || []).map((item) => { 25 | return _.assign(item, { 26 | id: `${item.sourceNode}-${item.source}-${item.targetNode}-${item.target}`, 27 | type: 'endpoint', 28 | _config: config, 29 | _menu: edgeMenu, 30 | Class: Edge, 31 | label: item.label, 32 | }); 33 | }); 34 | 35 | return { 36 | nodes, 37 | edges 38 | } 39 | } 40 | 41 | export const diffPropsData = (newData, oldData, options) => { 42 | const isSameNode = (a, b) => a.id === b.id; 43 | let addNodes = _.differenceWith(newData.nodes, oldData.nodes, isSameNode); 44 | let rmNodes = _.differenceWith(oldData.nodes, newData.nodes, isSameNode); 45 | 46 | const isSameEdge = (a, b) => { 47 | return ( 48 | a.sourceNode === b.sourceNode && 49 | a.targetNode === b.targetNode && 50 | a.source === b.source && 51 | a.target === b.target 52 | ); 53 | } 54 | 55 | const isSameLabel = (a, b) => { 56 | if(typeof a === 'string') { 57 | return a === b; 58 | } 59 | 60 | try { 61 | return JSON.stringify(a) === JSON.stringify(b); 62 | } catch (e) { 63 | return a === b; 64 | } 65 | } 66 | 67 | let addEdges = _.differenceWith(newData.edges, oldData.edges, isSameEdge); 68 | let rmEdges = _.differenceWith(oldData.edges, newData.edges, isSameEdge); 69 | 70 | // 线段的label有变化 71 | let updateLabel = []; 72 | newData.edges.forEach((a) => { 73 | let edge = _.find(oldData.edges, (b) => { 74 | return isSameEdge(a, b); 75 | }); 76 | 77 | if(!edge) { 78 | return; 79 | } 80 | 81 | if(isSameLabel(a.label, edge.label)) { 82 | return; 83 | } 84 | 85 | updateLabel.push({ 86 | edge, 87 | label: a.label 88 | }); 89 | }); 90 | 91 | let updateTitle = []; 92 | let addFields = []; 93 | let rmFields = []; 94 | let updateFields = []; 95 | let newCol = []; 96 | let columns = options.newCol; 97 | 98 | // columns有变化 99 | let addCol = _.differenceWith(options.newCol, options.oldCol, (a, b) => a.key === b.key); 100 | let oldCol = _.differenceWith(options.oldCol, options.newCol, (a, b) => a.key === b.key); 101 | 102 | if (addCol.length !== 0 || oldCol.length !== 0) { 103 | newCol = options.newCol; 104 | } 105 | 106 | (newData.nodes || []).forEach((newNode) => { 107 | let _addFields = []; 108 | let _rmFields = []; 109 | let _updateFields = []; 110 | let oldNode = _.find((oldData.nodes || []), (item) => { 111 | return newNode.id === item.id; 112 | }); 113 | if (!oldNode) { 114 | return false; 115 | } 116 | // 表名有变化 117 | if (newNode.title !== oldNode.title) { 118 | updateTitle.push({ 119 | nodeId: newNode.id, 120 | title: newNode.title 121 | }); 122 | } 123 | 124 | // 列属性有变化 125 | let _primaryKey = _.find(columns, (item) => { 126 | return item.primaryKey; 127 | }).key; 128 | 129 | _addFields = _.differenceWith(newNode.fields, oldNode.fields, (a, b) => { 130 | return a[_primaryKey] === b[_primaryKey]; 131 | }); 132 | 133 | _rmFields = _.differenceWith(oldNode.fields, newNode.fields, (a, b) => { 134 | return a[_primaryKey] === b[_primaryKey]; 135 | }); 136 | 137 | newNode.fields.forEach((newField) => { 138 | let oldField = _.find(oldNode.fields, (item) => { 139 | return newField[_primaryKey] === item[_primaryKey]; 140 | }); 141 | if (!oldField) { 142 | return; 143 | } 144 | for(let i = 0; i < columns.length; i++) { 145 | let col = columns[i]; 146 | if (!col.primaryKey && newField[col.key] !== oldField[col.key]) { 147 | _updateFields.push(newField); 148 | return; 149 | } 150 | } 151 | }) 152 | 153 | _addFields.length > 0 && addFields.push({ 154 | nodeId: newNode.id, 155 | fields: _addFields 156 | }); 157 | 158 | _rmFields.length > 0 && rmFields.push({ 159 | nodeId: newNode.id, 160 | fields: _rmFields 161 | }); 162 | 163 | _updateFields.length > 0 && updateFields.push({ 164 | nodeId: newNode.id, 165 | fields: _updateFields 166 | }); 167 | }); 168 | 169 | return { 170 | addNodes, 171 | rmNodes, 172 | addEdges, 173 | rmEdges, 174 | updateLabel, 175 | updateTitle, 176 | addFields, 177 | rmFields, 178 | updateFields, 179 | newCol 180 | }; 181 | } -------------------------------------------------------------------------------- /src/canvas/canvas.js: -------------------------------------------------------------------------------- 1 | import {Canvas, Tips} from 'butterfly-dag'; 2 | import $ from 'jquery'; 3 | 4 | import CollapseMenuGen from './collapse-menu.jsx'; 5 | 6 | export default class TableCanvas extends Canvas { 7 | constructor(opts) { 8 | super(opts); 9 | this.originEdges = []; 10 | this._focusItem = null; 11 | this._enableHoverChain = opts.data.enableHoverChain; 12 | this._enableFocusChain = opts.data.enableFocusChain; 13 | this._showCollapseDetail = opts.data.showCollapseDetail; 14 | this.attachEvent(); 15 | } 16 | 17 | attachEvent() { 18 | // 线段删除特殊处理 19 | this.on('custom.endpoint.dragNode', (data) => { 20 | let point = data.data; 21 | let node = this.getNode(point.nodeId); 22 | let linkedPoint = node.getEndpoint(point.id + '-right', 'source'); 23 | this.emit('InnerEvents', { 24 | type: 'endpoint:drag', 25 | data: linkedPoint 26 | }); 27 | }); 28 | 29 | // 移动节点时,隐藏菜单 30 | this.on('system.drag.start', (data) => { 31 | Tips.closeMenu(); 32 | }); 33 | 34 | if (this._enableHoverChain) { 35 | this.on('custom.endpoint.hover', (data) => { 36 | let point = data.point; 37 | this.focusChain(point.nodeId, point.id, 'hover-chain'); 38 | }); 39 | this.on('custom.endpoint.unHover', (data) => { 40 | let point = data.point; 41 | this.unfocusChain(point.nodeId, point.id, 'hover-chain'); 42 | }); 43 | } 44 | if (this._enableFocusChain) { 45 | this.on('custom.endpoint.focus', (data) => { 46 | let point = data.point; 47 | this.focusChain(point.nodeId, point.id, 'focus-chain'); 48 | }); 49 | } 50 | 51 | this.on('custom.node.expand', (data) => { 52 | this.expand(data.nodeId); 53 | }); 54 | 55 | this.on('custom.node.collapse', (data) => { 56 | this.collapse(data.nodeId); 57 | }); 58 | 59 | this.on('custom.edge.showCollapseInfo', (data) => { 60 | const {container, pos, edge} = data; 61 | if (this._showCollapseDetail) { 62 | CollapseMenuGen({ 63 | container, 64 | pos, 65 | edge, 66 | originEdgesInfo: this.originEdges 67 | }); 68 | } 69 | }); 70 | 71 | this.on('system.links.delete', (data) => { 72 | data.links.forEach((delLink) => { 73 | this.originEdges = this.originEdges.filter((originLink) => { 74 | return !( 75 | originLink.sourceNode === delLink.sourceNode.id, 76 | originLink.source === _.get(delLink, 'sourceEndpoint.options.originId'), 77 | originLink.targetNode === delLink.targetNode.id, 78 | originLink.target === _.get(delLink, 'targetEndpoint.options.originId') 79 | ); 80 | }); 81 | }); 82 | }); 83 | 84 | this.on('system.nodes.delete', (data) => { 85 | data.nodes.forEach((item) => { 86 | this.originEdges = this.originEdges.filter((originLink) => { 87 | return originLink.sourceNode !== item.id && originLink.targetNode !== item.id; 88 | }); 89 | }) 90 | }); 91 | } 92 | 93 | updateNodes(nodeInfos) { 94 | (nodeInfos.updateTitle || []).forEach((info) => { 95 | let node = this.getNode(info.nodeId); 96 | node._updateTitle(info.title); 97 | }); 98 | 99 | if (nodeInfos.newCol && nodeInfos.newCol.length > 0) { 100 | let edges = this.edges.map(item => item); 101 | let _originEdges = this.originEdges; 102 | this.nodes.forEach((item) => { 103 | item._updateCol(nodeInfos.newCol); 104 | }); 105 | this.originEdges = _originEdges; 106 | 107 | /** 108 | * 更新col时会将之前的 endpoint 删掉, 109 | * 此时需要重新更新 110 | */ 111 | edges.forEach(edge => { 112 | const newSrcEndpoint = edge.sourceNode.getEndpoint(edge.sourceEndpoint.id); 113 | edge.sourceEndpoint = newSrcEndpoint; 114 | 115 | const newTgtEndpoint = edge.targetNode.getEndpoint(edge.targetEndpoint.id); 116 | edge.targetEndpoint = newTgtEndpoint; 117 | }); 118 | 119 | this.addEdges(edges, true); 120 | } 121 | 122 | (nodeInfos.addFields || []).forEach((info) => { 123 | let node = this.getNode(info.nodeId); 124 | node._addFields(info.fields); 125 | }); 126 | (nodeInfos.rmFields || []).forEach((info) => { 127 | let node = this.getNode(info.nodeId); 128 | node._rmFields(info.fields); 129 | }); 130 | (nodeInfos.updateFields || []).forEach((info) => { 131 | let node = this.getNode(info.nodeId); 132 | node._updateFields(info.fields); 133 | }); 134 | } 135 | 136 | updateLabel(infos) { 137 | infos.forEach((info) => { 138 | let _targetEdge = info.edge; 139 | let edge = _.find(this.edges, (item) => { 140 | return ( 141 | _targetEdge.sourceNode === item.sourceNode.id && 142 | _targetEdge.targetNode === item.targetNode.id && 143 | _targetEdge.source === item.sourceEndpoint.options.originId && 144 | _targetEdge.target === item.targetEndpoint.options.originId 145 | ); 146 | }); 147 | 148 | this.getEdge(info.edge.id); 149 | if (edge) { 150 | edge.updateLabel(info.label); 151 | $(edge.labelDom).on('dblclick',(e) => { 152 | e.preventDefault(); 153 | e.stopPropagation(); 154 | edge.emit('custom.edge.dblClick',{ 155 | edge 156 | }); 157 | }); 158 | } 159 | }) 160 | } 161 | 162 | expand(nodeId) { 163 | let node = this.getNode(nodeId); 164 | 165 | if(!node) { 166 | return; 167 | } 168 | 169 | let oldEdges = this.getNeighborEdges(nodeId); 170 | this.removeEdges(oldEdges, true); 171 | node._expand(); 172 | let newEdges = []; 173 | // 从全局展开图里面纠正线段 174 | this.originEdges.forEach((item) => { 175 | if (item.sourceNode === nodeId) { 176 | let targetNode = this.getNode(item.targetNode); 177 | if (targetNode.status === 'collapse') { 178 | newEdges.push(_.assign({}, item, { 179 | sourceNode: item.sourceNode, 180 | source: `${item.source}-right`, 181 | targetNode: item.targetNode, 182 | target: `${targetNode.id}-left` 183 | })); 184 | } else { 185 | newEdges.push(_.assign({}, item, { 186 | sourceNode: item.sourceNode, 187 | source: `${item.source}-right`, 188 | targetNode: item.targetNode, 189 | target: `${item.target}-left` 190 | })); 191 | } 192 | } else if (item.targetNode == nodeId) { 193 | let sourceNode = this.getNode(item.sourceNode); 194 | if (sourceNode.status === 'collapse') { 195 | newEdges.push(_.assign({}, item, { 196 | sourceNode: item.sourceNode, 197 | source: `${sourceNode.id}-right`, 198 | targetNode: item.targetNode, 199 | target: `${item.target}-left` 200 | })); 201 | } else { 202 | newEdges.push(_.assign({}, item, { 203 | sourceNode: item.sourceNode, 204 | source: `${item.source}-right`, 205 | targetNode: item.targetNode, 206 | target: `${item.target}-left` 207 | })); 208 | } 209 | } 210 | }); 211 | // 去重 212 | newEdges = _.uniqWith(newEdges, (a, b) => { 213 | return ( 214 | a.sourceNode === b.sourceNode && 215 | a.targetNode === b.targetNode && 216 | a.souce === b.souce && 217 | a.target === b.target 218 | ); 219 | }); 220 | 221 | this.addEdges(newEdges, true); 222 | 223 | this.emit('table.canvas.expand'); 224 | } 225 | 226 | collapse(nodeId) { 227 | let node = this.getNode(nodeId); 228 | if(!node) { 229 | return; 230 | } 231 | 232 | let oldEdges = this.getNeighborEdges(nodeId); 233 | let oldEdgesInfo = oldEdges.map((item) => { 234 | return item.options; 235 | }); 236 | 237 | this.removeEdges(oldEdges, true); 238 | let newEdgesInfos = node._collapse(oldEdgesInfo); 239 | let newEdges = this.addEdges(newEdgesInfos, true); 240 | 241 | newEdges.forEach((item) => { 242 | item.collapse = true; 243 | item.sourceEndpoint.updatePos(); 244 | item.targetEndpoint.updatePos(); 245 | item.redraw(); 246 | }); 247 | this.emit('table.canvas.collapse'); 248 | } 249 | 250 | focusChain(nodeId, pointId, addClass) { 251 | let chain = this._findChain(nodeId, pointId); 252 | chain.edges.forEach((item) => { 253 | item.focusChain(addClass); 254 | }); 255 | chain.point.forEach((item) => { 256 | item.point.focusChain(addClass); 257 | }); 258 | if (this._focusItem && addClass === 'focus-chain') { 259 | this.unfocusChain(this._focusItem.nodeId, this._focusItem.pointId, addClass); 260 | } 261 | if (addClass === 'focus-chain') { 262 | this._focusItem = { 263 | nodeId: nodeId, 264 | pointId: pointId 265 | } 266 | } 267 | } 268 | 269 | unfocusChain(nodeId, pointId, rmClass) { 270 | let chain = this._findChain(nodeId, pointId); 271 | chain.edges.forEach((item) => { 272 | item.unfocusChain(rmClass); 273 | }); 274 | chain.point.forEach((item) => { 275 | item.point.unfocusChain(rmClass); 276 | }); 277 | } 278 | 279 | focusItems(nodes = [], edges = []) { 280 | this.emit('custom.item.focus', { 281 | nodes, 282 | edges 283 | }); 284 | nodes.forEach((item) => { 285 | item.focus(); 286 | }); 287 | edges.forEach((item) => { 288 | item.focus(); 289 | }); 290 | } 291 | 292 | _findChain(nodeId, pointId) { 293 | let resultPoints = []; 294 | let resultEdges = []; 295 | let queue = [{nodeId, pointId}]; 296 | while(queue.length > 0) { 297 | let item = queue.pop(); 298 | let hasExist = _.some(resultPoints, (point) => { 299 | return item.nodeId === point.id && item.pointId === point.point.id; 300 | }); 301 | if (hasExist) { 302 | return; 303 | } else { 304 | let node = this.getNode(item.nodeId); 305 | let point = node.getEndpoint(item.pointId); 306 | resultPoints.push({ 307 | node, 308 | point 309 | }); 310 | } 311 | let targetEdge = this.getNeighborEdgesByEndpoint(item.nodeId, `${item.pointId}-left`); 312 | let sourceEdge = this.getNeighborEdgesByEndpoint(item.nodeId, `${item.pointId}-right`); 313 | targetEdge.forEach((edge) => { 314 | let node = edge.sourceNode; 315 | let point = node.getEndpoint(_.get(edge, 'sourceEndpoint.options.originId')); 316 | if (!point) { 317 | return; 318 | } 319 | let hasExist = _.some(resultPoints, (item) => { 320 | return item.node.id === edge.sourceNode.id && item.point.id === point.id; 321 | }); 322 | if (!hasExist) { 323 | resultEdges.push(edge); 324 | queue.push({ 325 | nodeId: edge.sourceNode.id, 326 | pointId: point.id 327 | }); 328 | } 329 | }); 330 | sourceEdge.forEach((edge) => { 331 | let node = edge.targetNode; 332 | let point = node.getEndpoint(_.get(edge, 'targetEndpoint.options.originId')); 333 | if (!point) { 334 | return; 335 | } 336 | let hasExist = _.some(resultPoints, (item) => { 337 | return item.node.id === edge.targetNode.id && item.point.id === point.id; 338 | }); 339 | if (!hasExist) { 340 | resultEdges.push(edge); 341 | queue.push({ 342 | nodeId: edge.targetNode.id, 343 | pointId: point.id 344 | }); 345 | } 346 | }); 347 | } 348 | return { 349 | edges: resultEdges, 350 | point: resultPoints 351 | } 352 | } 353 | 354 | unfocus() { 355 | if (this._focusItem && this._enableFocusChain) { 356 | this.unfocusChain(this._focusItem.nodeId, this._focusItem.pointId, 'focus-chain'); 357 | this._focusItem = null; 358 | } 359 | } 360 | }; -------------------------------------------------------------------------------- /src/canvas/collapse-menu.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import $ from 'jquery'; 3 | import * as React from 'react'; 4 | import * as ReactDOM from 'react-dom'; 5 | import ButterflyDataMapping from 'react-data-mapping'; 6 | import 'react-data-mapping/dist/index.css'; 7 | 8 | const Tips = require('butterfly-dag').Tips; 9 | 10 | let _genTipDom = (result) => { 11 | let dom = $(''); 12 | let title = $(''); 13 | let content = $('') 14 | let canvas = ( 15 | 29 | ); 30 | ReactDOM.render( 31 | canvas, 32 | content[0] 33 | ); 34 | dom.append(title).append(content); 35 | return dom[0] 36 | } 37 | 38 | let transformData = (currentEdge, originEdgesInfo) => { 39 | let sourceNode = currentEdge.sourceNode; 40 | let targetNode = currentEdge.targetNode; 41 | let columns = (sourceNode.options._columns || []).filter((item) => { 42 | return item.primaryKey; 43 | }); 44 | let links = originEdgesInfo.filter((item) => { 45 | return item.sourceNode === sourceNode.id && item.targetNode === targetNode.id; 46 | }); 47 | let sourceData = { 48 | title: sourceNode.id, 49 | fields: sourceNode.options.fields 50 | }; 51 | let targetData = { 52 | title: targetNode.id, 53 | fields: targetNode.options.fields 54 | }; 55 | let mappingData = links.map((item) => { 56 | return { 57 | source: item.source, 58 | target: item.target 59 | } 60 | }); 61 | return { 62 | columns, 63 | sourceData, 64 | targetData, 65 | mappingData 66 | } 67 | } 68 | 69 | export default (opts) => { 70 | const {container, pos, edge, originEdgesInfo} = opts; 71 | let result = transformData(edge, originEdgesInfo); 72 | Tips.createMenu({ 73 | className: 'visual-modeling-collapse-menu', 74 | targetDom: container, 75 | genTipDom: () => { return _genTipDom(result) }, 76 | placement: 'right', 77 | action: null, 78 | x: pos[0], 79 | y: pos[1], 80 | closable: true 81 | }, (dom) => { 82 | setTimeout(() => { 83 | const info = { 84 | top: $(container).offset().top, 85 | left: $(container).offset().left, 86 | width: $(container).outerWidth(), 87 | height: $(container).outerHeight(), 88 | actualWidth: $(dom).outerWidth(), 89 | actualHeight: $(dom).outerHeight() 90 | }; 91 | let top = pos[1] - info.actualHeight / 2 - info.height / 2 + 9; 92 | let left = pos[0] + info.width - 12; 93 | $(dom).css({ 94 | top: top, 95 | left: left 96 | }); 97 | }, 100) 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /src/canvas/edge.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import {Edge} from 'butterfly-dag'; 3 | 4 | import RightMenuGen from './right-menu'; 5 | 6 | export default class BaseEdge extends Edge { 7 | mounted() { 8 | // todo 这块可以优化 9 | this._createRightMenu(); 10 | 11 | $(this.eventHandlerDom).on('dblclick',(e) => { 12 | e.preventDefault(); 13 | e.stopPropagation(); 14 | this.emit('custom.edge.dblClick',{ 15 | edge: this 16 | }); 17 | }); 18 | $(this.labelDom).on('dblclick',(e) => { 19 | e.preventDefault(); 20 | e.stopPropagation(); 21 | this.emit('custom.edge.dblClick',{ 22 | edge: this 23 | }); 24 | }); 25 | } 26 | 27 | draw(obj) { 28 | let path = super.draw(obj); 29 | path.setAttribute('class', 'butterflies-link visual-modeling-link'); 30 | return path; 31 | } 32 | 33 | drawArrow(arrow) { 34 | let path = super.drawArrow(arrow); 35 | if (path) { 36 | path.setAttribute('class', 'butterflies-arrow visual-modeling-arrow'); 37 | } 38 | return path; 39 | } 40 | 41 | focus() { 42 | $(this.dom).addClass('focus'); 43 | $(this.arrowDom).addClass('focus'); 44 | $(this.labelDom).addClass('focus'); 45 | } 46 | 47 | unfocus() { 48 | $(this.dom).removeClass('focus'); 49 | $(this.arrowDom).removeClass('focus'); 50 | $(this.labelDom).removeClass('focus'); 51 | } 52 | 53 | focusChain(addClass = 'hover-chain') { 54 | $(this.dom).addClass(addClass); 55 | $(this.arrowDom).addClass(addClass); 56 | $(this.labelDom).addClass(addClass); 57 | } 58 | 59 | unfocusChain(rmClass = 'hover-chain') { 60 | $(this.dom).removeClass(rmClass); 61 | $(this.arrowDom).removeClass(rmClass); 62 | $(this.labelDom).removeClass(rmClass); 63 | } 64 | 65 | drawLabel(label) { 66 | 67 | let sourceNode = this.sourceNode; 68 | let targetNode = this.targetNode; 69 | let labelRender = _.get(this, 'options._config.labelRender'); 70 | 71 | if (sourceNode.status === 'collapse' && targetNode.status === 'collapse') { 72 | let container = $('...'); 73 | container.on('click', this._showCollapseMoldal.bind(this)); 74 | return container[0]; 75 | } 76 | 77 | let dom = null; 78 | // 存在 labelRender 但是没有 label 的时候,需要 labelRender 拿到这个 dom 去渲染 79 | if(labelRender) { 80 | const span = document.createElement('span'); 81 | span.className = 'visual-modeling-label'; 82 | span.style.position = 'absolute'; 83 | span.style.zIndex = 500; 84 | dom = span; 85 | } else if (label && typeof label === 'string') { 86 | let container = $(''); 87 | container.text(label); 88 | dom = container[0]; 89 | } 90 | 91 | $(dom).on('click', () => { 92 | this.emit('system.link.click', { 93 | edge: this 94 | }); 95 | }); 96 | 97 | return dom; 98 | } 99 | 100 | isConnect() { 101 | if (this.sourceNode.id === this.targetNode.id) { 102 | return false; 103 | } 104 | return true; 105 | } 106 | 107 | // 右键菜单 108 | _createRightMenu() { 109 | let menus = _.get(this, 'options._menu', []); 110 | if (menus.length > 0) { 111 | $(this.eventHandlerDom).contextmenu((e) => { 112 | e.preventDefault(); 113 | e.stopPropagation(); 114 | RightMenuGen(this.dom, 'edge', [e.clientX, e.clientY], menus, this.options); 115 | }) 116 | } 117 | } 118 | 119 | _showCollapseMoldal(e) { 120 | e.preventDefault(); 121 | e.stopPropagation(); 122 | this.emit('custom.edge.showCollapseInfo', { 123 | container: e.target, 124 | pos: [e.clientX, e.clientY], 125 | edge: this 126 | }); 127 | } 128 | } 129 | 130 | -------------------------------------------------------------------------------- /src/canvas/empty.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | const isReactEle = (HTMLElement) => { 6 | return React.isValidElement(HTMLElement); 7 | }; 8 | 9 | /** 10 | * params {Object} config 11 | * params {JSX.Element | String} config.content 12 | * params {Number | String} config.width 13 | */ 14 | export default (config) => { 15 | const content = config.content; 16 | const container = config.container[0]; 17 | let width = config.width; 18 | 19 | if (!width) { 20 | width = '150px'; 21 | } 22 | 23 | if (typeof config.width === 'number') { 24 | width = config.width + 'px'; 25 | } 26 | 27 | let emptyDom = '
'; 28 | 29 | if (content) { 30 | if (isReactEle(content)) { 31 | emptyDom = ReactDOM.render(content, container); 32 | } else { 33 | emptyDom = $(content); 34 | } 35 | } else { 36 | emptyDom = $('
'); 37 | const iconDom = $(''); 38 | 39 | emptyDom.append(iconDom); 40 | } 41 | 42 | return emptyDom; 43 | }; 44 | -------------------------------------------------------------------------------- /src/canvas/endpoint.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import {Endpoint} from 'butterfly-dag'; 3 | 4 | class NewEndPoint extends Endpoint { 5 | constructor(opts) { 6 | super(opts); 7 | } 8 | attachEvent() { 9 | $(this.dom).on('mousedown', (e) => { 10 | const LEFT_KEY = 0; 11 | if (e.button !== LEFT_KEY) { 12 | return; 13 | } 14 | e.preventDefault(); 15 | e.stopPropagation(); 16 | if (this.options._isNodeSelf) { 17 | this.emit('custom.endpoint.dragNode', { 18 | data: this 19 | }); 20 | } 21 | this.emit('custom.endpoint.focus', { 22 | point: this 23 | }); 24 | }); 25 | 26 | // todo: 高亮整条链路 27 | if (this.options._isNodeSelf) { 28 | $(this.dom).on('mouseover', (e) => { 29 | this.emit('custom.endpoint.hover', { 30 | point: this 31 | }); 32 | }); 33 | 34 | $(this.dom).on('mouseout', (e) => { 35 | this.emit('custom.endpoint.unHover', { 36 | point: this 37 | }); 38 | }); 39 | } 40 | } 41 | focusChain(addClass) { 42 | $(this.dom).addClass(addClass); 43 | $(this.arrowDom).addClass(addClass); 44 | } 45 | unfocusChain(rmClass) { 46 | $(this.dom).removeClass(rmClass); 47 | $(this.arrowDom).removeClass(rmClass); 48 | } 49 | } 50 | 51 | export default NewEndPoint; 52 | -------------------------------------------------------------------------------- /src/canvas/node.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import * as _ from 'lodash'; 3 | import { Node } from 'butterfly-dag'; 4 | import * as ReactDOM from 'react-dom'; 5 | 6 | import emptyDom from './empty'; 7 | import Endpoint from './endpoint'; 8 | import RightMenuGen from './right-menu'; 9 | 10 | export default class TableNode extends Node { 11 | constructor(opts) { 12 | super(opts); 13 | 14 | this.fieldsList = []; 15 | 16 | // 每列宽度 17 | this.COLUMN_WIDTH = 60; 18 | this.status = 'expand'; 19 | this.emptyDataTree = undefined; 20 | } 21 | 22 | draw(obj) { 23 | let _dom = obj.dom; 24 | if (!_dom) { 25 | _dom = $('
') 26 | .attr('class', 'node table-node') 27 | .attr('id', obj.name); 28 | } 29 | const node = $(_dom); 30 | // 计算节点坐标 31 | if (obj.top !== undefined) { 32 | node.css('top', `${obj.top}px`); 33 | } 34 | if (obj.left !== undefined) { 35 | node.css('left', `${obj.left}px`); 36 | } 37 | 38 | this._createTableTitle(node); 39 | this._createExtIcon(node); 40 | this._createFields(node); 41 | return node[0]; 42 | } 43 | 44 | mounted() { 45 | // 生成field的endpoint 46 | this._createNodeEndpoint(); 47 | 48 | if (this.fieldsList.length === 0) { 49 | $(this.dom).find('.title').css('width', this.options._emptyWidth || 150); 50 | } 51 | 52 | $(this.dom).on('dblclick', (e) => { 53 | this.emit('system.node.dblClick', { 54 | node: this 55 | }); 56 | }); 57 | // 生成右键菜单 58 | this._createRightMenu(); 59 | } 60 | 61 | focus() { 62 | $(this.dom).addClass('focus'); 63 | this.options.minimapActive = true; 64 | } 65 | 66 | unfocus() { 67 | $(this.dom).removeClass('focus'); 68 | this.options.minimapActive = false; 69 | } 70 | 71 | _expand() { 72 | if (this.status === 'expand') { 73 | console.warn(`节点${this.id}已经是展开状态`) 74 | return; 75 | } 76 | 77 | // 清除新锚点 78 | this._rmTitleEndpoint(); 79 | 80 | // 隐藏字段 81 | this.fieldsList.forEach((item) => { 82 | $(item.dom).css('display', 'flex'); 83 | let points = [ 84 | this.getEndpoint(item.id), 85 | this.getEndpoint(`${item.id}-left`), 86 | this.getEndpoint(`${item.id}-right`), 87 | ] 88 | 89 | points.forEach((item) => { 90 | item.updatePos(); 91 | }); 92 | }); 93 | 94 | // 记录状态 95 | this.status = 'expand'; 96 | // 改变伸缩状态 97 | $(this.dom).removeClass('collapse'); 98 | } 99 | 100 | _collapse(oldEdges) { 101 | if (this.status === 'collapse') { 102 | console.warn(`节点${this.id}已经是收缩状态`) 103 | return; 104 | } 105 | // 生成新锚点 106 | this._createTitleEndpoint(); 107 | // 隐藏字段 108 | this.fieldsList.forEach((item) => { 109 | $(item.dom).css('display', 'none'); 110 | }); 111 | // 记录状态 112 | this.status = 'collapse'; 113 | // 改变伸缩状态 114 | $(this.dom).addClass('collapse'); 115 | 116 | // 生成新线段,并去重 117 | let newEdges = []; 118 | oldEdges.forEach((item) => { 119 | let updateObj = {}; 120 | if (item.sourceNode === this.id) { 121 | updateObj['source'] = `${this.id}-right`; 122 | } else if (item.targeNode = this.id) { 123 | updateObj['target'] = `${this.id}-left`; 124 | } 125 | 126 | let newEdgeObj = _.assign({}, item, updateObj); 127 | 128 | let hasExist = _.some(newEdges, (item) => { 129 | return ( 130 | newEdgeObj.sourceNode === item.sourceNode && 131 | newEdgeObj.source === item.source && 132 | newEdgeObj.targetNode === item.targetNode && 133 | newEdgeObj.target === item.target 134 | ) 135 | }); 136 | if (!hasExist) { 137 | newEdges.push(newEdgeObj); 138 | } 139 | }); 140 | 141 | return newEdges; 142 | } 143 | _createTableTitle(container = this.dom) { 144 | let title = _.get(this, 'options.title'); 145 | let titleRender = _.get(this, 'options._config.titleRender'); 146 | let titleDom = $(`
`); 147 | $(container).append(titleDom); 148 | if (title) { 149 | if (titleRender) { 150 | let titleTextDom = $(`
`); 151 | $(titleDom).append(titleTextDom); 152 | ReactDOM.render( 153 | titleRender(title, this.options), 154 | titleTextDom[0], 155 | () => { 156 | this._updateEndpointPos(); 157 | } 158 | ); 159 | } else { 160 | let titleTextDom = $(`
${title}
`); 161 | $(titleDom).append(titleTextDom); 162 | } 163 | } 164 | } 165 | _createExtIcon(container = this.dom) { 166 | let titleDom = $(container).find('.title'); 167 | let titleIcon = $(''); 168 | // 展开收缩icon 169 | let collapseIcon = $(''); 170 | collapseIcon.on('click', (e) => { 171 | if (this.status === 'collapse') { 172 | this.emit('custom.node.expand', { 173 | nodeId: this.id 174 | }); 175 | } else { 176 | this.emit('custom.node.collapse', { 177 | nodeId: this.id 178 | }); 179 | } 180 | }); 181 | titleIcon.append(collapseIcon); 182 | // 删除icon 183 | let deleteIcon = $(''); 184 | deleteIcon.on('click', (e) => { 185 | this.emit('custom.node.delete', { 186 | node: this 187 | }); 188 | }); 189 | titleIcon.append(deleteIcon); 190 | let extIcon = $(''); 191 | let titleIconRender = _.get(this, 'options._config.titleExtIconRender'); 192 | if (titleIconRender) { 193 | titleIcon.prepend(extIcon); 194 | ReactDOM.render( 195 | titleIconRender(this.options), 196 | extIcon[0] 197 | ); 198 | } 199 | titleDom.append(titleIcon); 200 | } 201 | _createFields(container = this.dom, fieldList) { 202 | let fields = fieldList || _.get(this, 'options.fields'); 203 | let coloums = _.get(this, 'options._columns', []); 204 | let _primaryKey = _.get(coloums, '[0].key'); 205 | 206 | if (fields && fields.length) { 207 | return fields.map((_field) => { 208 | let fieldDom = $('
'); 209 | coloums.forEach((_col) => { 210 | if (_col.render) { 211 | let fieldItemDom = $(``); 212 | fieldItemDom.css('width', (_col.width || this.COLUMN_WIDTH) + 'px').attr('dataType', _col.key); 213 | fieldDom.append(fieldItemDom); 214 | ReactDOM.render( 215 | _col.render(_field[_col.key], _field), 216 | fieldItemDom[0] 217 | ); 218 | } else { 219 | let fieldItemDom = $(`${_field[_col.key]}`); 220 | fieldItemDom.css('width', (_col.width || this.COLUMN_WIDTH) + 'px').attr('dataType', _col.key); 221 | fieldDom.append(fieldItemDom); 222 | } 223 | if (_col.primaryKey) { 224 | _primaryKey = _col.key; 225 | } 226 | }); 227 | let leftPoint = $('
'); 228 | let rightPoint = $('
'); 229 | fieldDom.append(leftPoint).append(rightPoint); 230 | container.append(fieldDom); 231 | let _newFieldItem = { 232 | id: _field[_primaryKey], 233 | dom: fieldDom 234 | }; 235 | this.fieldsList.push(_newFieldItem); 236 | return _newFieldItem; 237 | }); 238 | } else { 239 | if(this.emptyDataTree){ 240 | return this.emptyDataTree; 241 | } 242 | const _emptyContent = _.get(this.options, '_emptyContent'); 243 | const noDataCon = $('
'); 244 | container.append(noDataCon); 245 | const noDataTree = emptyDom({ 246 | content: _emptyContent, 247 | container: noDataCon, 248 | width: this.options._emptyWidth 249 | }); 250 | container.append(noDataTree); 251 | const _newFieldItem = { 252 | id: 0, 253 | __type: 'no-data', 254 | dom: noDataTree 255 | }; 256 | this.emptyDataTree = [_newFieldItem]; 257 | return [_newFieldItem]; 258 | } 259 | } 260 | _createNodeEndpoint(fieldList) { 261 | let _fieldList = (fieldList || this.fieldsList); 262 | _fieldList.forEach((item) => { 263 | this.addEndpoint({ 264 | id: item.id, 265 | type: 'onlyConnect', 266 | _isNodeSelf: true, 267 | dom: item.dom[0], 268 | Class: Endpoint 269 | }); 270 | this.addEndpoint({ 271 | id: item.id + '-right', 272 | originId: item.id, 273 | orientation: [1,0], 274 | type: 'source', 275 | _isNodeSelf: false, 276 | dom: $(item.dom).find('.right-point')[0], 277 | Class: Endpoint, 278 | linkable: false, 279 | disLinkable: false 280 | }); 281 | this.addEndpoint({ 282 | id: item.id + '-left', 283 | originId: item.id, 284 | orientation: [-1,0], 285 | type: 'target', 286 | _isNodeSelf: false, 287 | dom: $(item.dom).find('.left-point')[0], 288 | Class: Endpoint, 289 | linkable: false, 290 | disLinkable: false 291 | }); 292 | }); 293 | } 294 | _rmNodeEndpoint(ids) { 295 | ids.forEach((id) => { 296 | this.removeEndpoint(id); 297 | }) 298 | } 299 | _updateTitle(newTitle) { 300 | if (newTitle !== this.options.title) { 301 | this.options.title = newTitle; 302 | let titleTextDom = $(this.dom).find('.title-text'); 303 | let titleRender = _.get(this, 'options._config.titleRender'); 304 | if (titleRender) { 305 | ReactDOM.unmountComponentAtNode(titleTextDom[0]); 306 | ReactDOM.render( 307 | titleRender(newTitle), 308 | titleTextDom[0], 309 | () => { 310 | this._updateEndpointPos(); 311 | } 312 | ); 313 | } else { 314 | titleTextDom.text(newTitle); 315 | } 316 | } 317 | } 318 | 319 | _addFields(fields) { 320 | $(this.dom).find('.no-data').remove(); 321 | this.emptyDataTree = undefined; 322 | let _newFieldsList = this._createFields($(this.dom), fields); 323 | if (_newFieldsList.length >= 1 && _.get(_newFieldsList, ['0', '__type']) !== 'no-data') { 324 | this._createNodeEndpoint(_newFieldsList); 325 | } 326 | } 327 | 328 | _rmFields(fields = this.fieldsList) { 329 | // 寻找primaryKey 330 | let columns = _.get(this, 'options._columns', []); 331 | let _primaryKey = columns[0].key; 332 | columns.forEach((col) => { 333 | if (col.primaryKey) { 334 | _primaryKey = col.key; 335 | } 336 | }); 337 | // 删除field 338 | let ids = []; 339 | fields.forEach((field) => { 340 | ids = ids.concat([field.id, `${field.id}-left`, `${field.id}-right`]); 341 | }); 342 | ids.forEach((id) => { 343 | this.removeEndpoint(id); 344 | this.fieldsList = this.fieldsList.filter((item) => { 345 | return id !== item.id; 346 | }); 347 | this.options.fields = (this.options.fields || []).filter((item) => { 348 | return id !== item[_primaryKey]; 349 | }); 350 | }); 351 | } 352 | 353 | _updateFields(fields) { 354 | let columns = _.get(this, 'options._columns', []); 355 | let _primaryKey = _.find(columns, (item) => { 356 | return item.primaryKey; 357 | }).key; 358 | fields.forEach((newField) => { 359 | let oldFieldInfo = _.find(this.options.fields || [], (item) => { 360 | return newField[_primaryKey] === item[_primaryKey]; 361 | }); 362 | let oldFieldItem = _.find(this.fieldsList || [], (item) => { 363 | return newField[_primaryKey] === item.id; 364 | }); 365 | if (!oldFieldInfo || !oldFieldItem) { 366 | return; 367 | } 368 | columns.forEach((col) => { 369 | let targetDom = $(oldFieldItem.dom).find(`[dataType="${col.key}"]`); 370 | if (col.render) { 371 | ReactDOM.unmountComponentAtNode(targetDom[0]); 372 | ReactDOM.render( 373 | col.render(newField[col.key], newField), 374 | targetDom[0] 375 | ); 376 | } else { 377 | $(targetDom).text(newField[col.key]); 378 | } 379 | }) 380 | }); 381 | } 382 | 383 | // 更新col 384 | _updateCol(newCol) { 385 | let fields = this.fieldsList; 386 | let ids = []; 387 | fields.forEach((field) => { 388 | ids = ids.concat([field.id, `${field.id}-left`, `${field.id}-right`]); 389 | }); 390 | ids.forEach((id) => { 391 | this.removeEndpoint(id); 392 | }); 393 | this.fieldsList = []; 394 | _.set(this, 'options._columns', newCol); 395 | this._addFields(); 396 | 397 | } 398 | 399 | _createTitleEndpoint() { 400 | let titleDom = $(this.dom).find('.title'); 401 | let leftPoint = $('
'); 402 | let rightPoint = $('
'); 403 | titleDom.append(leftPoint).append(rightPoint); 404 | 405 | this.addEndpoint({ 406 | id: this.id + '-right', 407 | orientation: [1,0], 408 | type: 'source', 409 | _isNodeSelf: false, 410 | dom: rightPoint[0], 411 | Class: Endpoint, 412 | linkable: false, 413 | disLinkable: false 414 | }); 415 | this.addEndpoint({ 416 | id: this.id + '-left', 417 | orientation: [-1,0], 418 | type: 'target', 419 | _isNodeSelf: false, 420 | dom: leftPoint[0], 421 | Class: Endpoint, 422 | linkable: false, 423 | disLinkable: false 424 | }); 425 | } 426 | 427 | _rmTitleEndpoint() { 428 | this.removeEndpoint(`${this.id}-left`); 429 | this.removeEndpoint(`${this.id}-right`); 430 | } 431 | 432 | // 右键菜单 433 | _createRightMenu() { 434 | let menus = _.get(this, 'options._menu', []); 435 | if (menus.length > 0) { 436 | $(this.dom).contextmenu((e) => { 437 | e.preventDefault(); 438 | e.stopPropagation(); 439 | RightMenuGen(this.dom, 'node', [e.clientX, e.clientY], menus, this.options); 440 | }) 441 | } 442 | } 443 | 444 | _updateEndpointPos() { 445 | (this.endpoints || []).forEach((item) => { 446 | item.updatePos(); 447 | }); 448 | } 449 | }; -------------------------------------------------------------------------------- /src/canvas/right-menu.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | const Tips = require('butterfly-dag').Tips; 5 | 6 | let _genTipDom = (menuData, data) => { 7 | let dom = $(''); 8 | menuData.forEach((item) => { 9 | let menuItem = $(''); 10 | if (item.onClick) { 11 | menuItem.on('click', (e) => { 12 | item.onClick(item.key, data); 13 | if (item.closable) { 14 | Tips.closeMenu(); 15 | } 16 | }); 17 | } 18 | dom.append(menuItem); 19 | if (item.render) { 20 | ReactDOM.render( 21 | item.render(item.key, data), 22 | menuItem[0] 23 | ); 24 | } else { 25 | menuItem.text(item.title || item.key); 26 | } 27 | }); 28 | return dom[0] 29 | } 30 | 31 | export default (container, type, pos, menuData, data) => { 32 | Tips.createMenu({ 33 | className: `butterfly-${type}-menu`, 34 | targetDom: container, 35 | genTipDom: () => { return _genTipDom(menuData, data) }, 36 | placement: 'right', 37 | action: null, 38 | x: pos[0], 39 | y: pos[1], 40 | closable: true 41 | }); 42 | } -------------------------------------------------------------------------------- /src/component/action-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | 4 | import {actions} from '../config'; 5 | import Tooltip from './tooltip/index'; 6 | 7 | export interface action { 8 | key: string, // 唯一表示 9 | icon: string | JSX.Element, // 图标 10 | title?: string; // 提示 11 | onClick: (canvas: any) => void; // 点击响应函数 12 | disable: boolean; // 是否禁用 13 | } 14 | 15 | interface IProps { 16 | canvas: any; // 画布实例 17 | actionMenu: action[]; // action菜单 18 | visible: boolean; // 是否可见 19 | } 20 | 21 | const ActionMenu = (props: IProps) => { 22 | let {canvas, actionMenu = [], visible} = props; 23 | 24 | if(!visible) { 25 | return null; 26 | } 27 | 28 | if(!Array.isArray(actionMenu)) { 29 | actionMenu = []; 30 | } 31 | 32 | // 合并action菜单 33 | let sysActions = _.cloneDeep(actions); 34 | const allActions = []; 35 | 36 | for(let action of actionMenu) { 37 | const sysAction = _.find(sysActions, (a) => { 38 | return a.key === action.key 39 | }); 40 | 41 | if(!sysAction) { 42 | allActions.push(action); 43 | 44 | continue; 45 | } 46 | 47 | // 合并用户同名 key 48 | _.merge(sysAction, action); 49 | allActions.push(sysAction); 50 | 51 | // 移除用户覆盖的 action 52 | sysActions = sysActions.filter(action => action.key !== sysAction.key); 53 | } 54 | 55 | sysActions.forEach(sysAction => { 56 | allActions.unshift(sysAction); 57 | }); 58 | 59 | // 兼容多类型图标渲染 60 | const renderIcon = (icon) => { 61 | if(typeof icon === 'string') { 62 | return 63 | } 64 | 65 | if(React.isValidElement(icon)) { 66 | return icon; 67 | } 68 | 69 | return null; 70 | } 71 | 72 | return ( 73 |
74 | { 75 | allActions.map(action => { 76 | if(action.disable) { 77 | return null; 78 | } 79 | 80 | return ( 81 |
action.onClick(canvas)} 85 | > 86 | 87 | {renderIcon(action.icon)} 88 | 89 |
90 | ) 91 | }) 92 | } 93 |
94 | ); 95 | } 96 | 97 | export default ActionMenu; 98 | -------------------------------------------------------------------------------- /src/component/edge-render.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | interface IProps { 5 | canvas: any; 6 | labelRender: (label: any, edge: any) => JSX.Element; 7 | } 8 | 9 | const EdgeRender = (props: IProps) => { 10 | const {canvas, labelRender} = props; 11 | 12 | if (!canvas) { 13 | return null; 14 | } 15 | 16 | if (!canvas.edges) { 17 | return null; 18 | } 19 | 20 | return canvas.edges.map(edge => { 21 | if(!edge || !edge.labelDom) { 22 | return null; 23 | } 24 | 25 | const {sourceNode, targetNode} = edge; 26 | const isCollapse = sourceNode.status === 'collapse' && targetNode.status === 'collapse' 27 | 28 | if(isCollapse) { 29 | return null; 30 | } 31 | 32 | return ( 33 | ReactDOM.createPortal( 34 | labelRender(edge.label, edge), 35 | edge.labelDom 36 | ) 37 | ) 38 | }) 39 | } 40 | 41 | export default EdgeRender; 42 | -------------------------------------------------------------------------------- /src/component/tooltip/index.less: -------------------------------------------------------------------------------- 1 | .react-visual-modeling-tooltip { 2 | position: relative; 3 | display: block; 4 | height: 100%; 5 | width: 100%; 6 | 7 | &:hover { 8 | .react-visual-modeling-title { 9 | display: block; 10 | } 11 | } 12 | 13 | .react-visual-modeling-title { 14 | display: none; 15 | 16 | word-wrap: break-word; 17 | background-color: rgba(0,0,0,.75); 18 | border-radius: 2px; 19 | box-shadow: 0 3px 6px -4px rgba(0,0,0,12%), 0 6px 16px 0 rgba(0,0,0,8%), 0 9px 28px 8px rgba(0,0,0,5%); 20 | color: #fff; 21 | min-height: 32px; 22 | min-width: 30px; 23 | padding: 0 8px; 24 | line-height: 32px; 25 | text-align: left; 26 | text-decoration: none; 27 | position: absolute; 28 | right: calc(100% + 5px); 29 | white-space: nowrap; 30 | top: 50%; 31 | height: 32px; 32 | transform: translateY(-50%); 33 | 34 | &::before { 35 | background-color: rgba(0, 0, 0, 0.75); 36 | content: " "; 37 | display: block; 38 | height: 5px; 39 | margin: auto; 40 | pointer-events: auto; 41 | position: absolute; 42 | width: 5px; 43 | display: block; 44 | position: absolute; 45 | right: -2px; 46 | top: 50%; 47 | box-shadow: 3px 3px 7px rgba(0,0,0,7%); 48 | transform: translateY(-50%) rotate(45deg); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/component/tooltip/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import './index.less'; 4 | 5 | interface IProps { 6 | children: JSX.Element, 7 | title: JSX.Element 8 | }; 9 | 10 | const Tooltip = (props: IProps) => { 11 | const {title, children} = props; 12 | 13 | if(!title) { 14 | return children; 15 | } 16 | 17 | return ( 18 | 19 | {title} 20 | {children} 21 | 22 | ) 23 | } 24 | 25 | export default Tooltip; 26 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 组件各项配置 3 | * @author gorgear 4 | */ 5 | import Edge from './canvas/edge'; 6 | 7 | // butterfly 默认配置 8 | export const bfCfg = { 9 | disLinkable: false, 10 | linkable: true, 11 | draggable: true, 12 | zoomable: true, 13 | moveable: true, 14 | theme: { 15 | edge: { 16 | shapeType: 'AdvancedBezier', 17 | arrow: true, 18 | isExpandWidth: true, 19 | arrowPosition: 1, 20 | arrowOffset: 5, 21 | Class: Edge 22 | }, 23 | endpoint: { 24 | expandArea: { 25 | left: 0, 26 | right: 0, 27 | top: 0, 28 | botton: 0 29 | } 30 | }, 31 | autoFixCanvas: { 32 | enable: true, 33 | autoMovePadding: [20, 20, 20, 20] 34 | }, 35 | } 36 | }; 37 | 38 | export const actions = [ 39 | { 40 | key: 'zoom-in', 41 | icon: 'table-build-icon table-build-icon-zoom-in', 42 | title: '放大', 43 | onClick: (canvas) => { 44 | canvas.zoom(canvas._zoomData + 0.1); 45 | } 46 | }, 47 | { 48 | key: 'zoom-out', 49 | icon: 'table-build-icon table-build-icon-zoom-out', 50 | title: '缩小', 51 | onClick: (canvas) => { 52 | canvas.zoom(canvas._zoomData - 0.1); 53 | } 54 | }, 55 | { 56 | key: 'fit', 57 | icon: 'table-build-icon table-build-icon-quanping2', 58 | title: '居中', 59 | onClick: (canvas) => { 60 | canvas.focusCenterWithAnimate(undefined, () => { 61 | console.log('complete!!!') 62 | }); 63 | } 64 | } 65 | ]; 66 | -------------------------------------------------------------------------------- /src/index.less: -------------------------------------------------------------------------------- 1 | @import './static/iconfont.css'; 2 | 3 | .butterfly-table-building { 4 | background: #2E2E2E; 5 | outline: none; 6 | box-shadow: none; 7 | user-select: none; 8 | .table-node { 9 | position: absolute; 10 | border: 1px solid #5A5A5A; 11 | border-radius: 4px; 12 | color: #fff; 13 | background: #252525; 14 | &.collapse { 15 | .title { 16 | border-bottom: none; 17 | } 18 | } 19 | .title { 20 | position: relative; 21 | border-bottom: 1px solid #5A5A5A; 22 | padding: 0 50px 0 15px; 23 | height: 30px; 24 | line-height: 30px; 25 | min-width: 150px; 26 | cursor: pointer; 27 | .title-text { 28 | overflow: hidden; 29 | text-overflow: ellipsis; 30 | white-space: nowrap; 31 | } 32 | .point { 33 | position: absolute; 34 | border-radius: 50%; 35 | visibility: hidden; 36 | &.show { 37 | visibility: visible; 38 | } 39 | &.left-point { 40 | top: 50%; 41 | left: 0px; 42 | } 43 | &.right-point{ 44 | top: 50%; 45 | right: 0px; 46 | } 47 | } 48 | .title-icon-con { 49 | position: absolute; 50 | display: none; 51 | top: 0; 52 | right: 15px; 53 | color: #484848; 54 | i { 55 | padding-left: 3px; 56 | &:hover { 57 | color: rgba(255, 255, 255, 0.5); 58 | } 59 | } 60 | .table-build-icon-xiala{ 61 | display: inline-block; 62 | } 63 | } 64 | } 65 | 66 | .field { 67 | position: relative; 68 | cursor: move; 69 | height: 24px; 70 | line-height: 24px; 71 | align-items: center; 72 | display: flex; 73 | &:hover { 74 | background: rgba(216, 216, 216, 0.06); 75 | .point { 76 | visibility: visible; 77 | } 78 | } 79 | &.hover-chain { 80 | background: rgba(246, 106, 2, 0.3); 81 | } 82 | &.focus-chain { 83 | background: #F66902; 84 | } 85 | .field-item { 86 | overflow: hidden; 87 | white-space: nowrap; 88 | display: inline-block; 89 | text-align: center; 90 | } 91 | .point { 92 | position: absolute; 93 | border-radius: 50%; 94 | visibility: hidden; 95 | &.show { 96 | visibility: visible; 97 | } 98 | &.left-point { 99 | top: 50%; 100 | left: 0px; 101 | } 102 | &.right-point{ 103 | top: 50%; 104 | right: 0px; 105 | } 106 | } 107 | } 108 | &.focus { 109 | border: 1px solid #F66902; 110 | box-shadow: 0px 0px 5px #f66902; 111 | } 112 | &:hover { 113 | .title-icon-con { 114 | display: inline-block; 115 | } 116 | } 117 | .no-data { 118 | margin: 10px 0; 119 | color: #474747; 120 | text-align: center; 121 | .no-data-icon { 122 | font-size: 36px; 123 | } 124 | } 125 | } 126 | .visual-modeling-link{ 127 | &.focus { 128 | stroke: #F66902; 129 | } 130 | &.hover-chain { 131 | stroke: rgba(246, 106, 2, 0.3); 132 | } 133 | &.focus-chain { 134 | stroke: #F66902; 135 | } 136 | } 137 | .visual-modeling-label { 138 | transform: scale(0.8); 139 | background: #313131; 140 | border: 1px solid #828282; 141 | padding: 1px 6px; 142 | border-radius: 13px; 143 | color: #fff; 144 | &.focus { 145 | border: 1px solid #F66902; 146 | } 147 | &.hover-chain { 148 | background: rgba(246, 106, 2, 0.3); 149 | } 150 | &.focus-chain { 151 | background: #F66902; 152 | } 153 | &.butterflies-collapse-label{ 154 | padding: 0px 10px; 155 | height: 16px; 156 | line-height: 8px; 157 | font-weight: 900; 158 | cursor: pointer; 159 | background: #252525; 160 | &:hover { 161 | background: #383838; 162 | } 163 | } 164 | } 165 | .visual-modeling-arrow { 166 | &.focus { 167 | stroke: #F66902; 168 | } 169 | &.hover-chain { 170 | stroke: rgba(246, 106, 2, 0.3); 171 | } 172 | &.focus-chain { 173 | stroke: #F66902; 174 | } 175 | } 176 | .butterflies-link-event-handler { 177 | &:hover { 178 | cursor: pointer; 179 | } 180 | } 181 | .table-build-canvas-action { 182 | background: #333; 183 | box-shadow: 0 0 9px 0 rgba(0, 0, 0, .5); 184 | position: absolute; 185 | right: 10px; 186 | top: 10px; 187 | z-index: 999; 188 | border: 1px solid #676565; 189 | div { 190 | height: 24px; 191 | width: 24px; 192 | text-align: center; 193 | line-height: 24px; 194 | cursor: pointer; 195 | color: #fff; 196 | opacity: .7; 197 | border-bottom: 1px solid #676565; 198 | i { 199 | -webkit-text-stroke-width: 0; 200 | font-size: 14px; 201 | } 202 | } 203 | div:hover{ 204 | background: #0f0f0f; 205 | } 206 | div:last-child { 207 | border-bottom: none; 208 | } 209 | } 210 | } 211 | .visual-modeling-collapse-menu{ 212 | width: 275px; 213 | .butterfly-tooltip-inner { 214 | max-width: 500px!important; 215 | text-align: left!important; 216 | .menu-title { 217 | margin-bottom: 0; 218 | padding: 3px 0; 219 | border-bottom: 1px solid #5A5A5A; 220 | } 221 | .menu-container { 222 | width: 250px; 223 | .butterfly-data-mapping { 224 | margin-top: 10px; 225 | .butterfly-svg { 226 | position: absolute; 227 | } 228 | } 229 | } 230 | } 231 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | import $ from 'jquery'; 5 | import * as _ from 'lodash'; 6 | 7 | import {bfCfg} from './config'; 8 | import {Arrow} from 'butterfly-dag'; 9 | import Canvas from './canvas/canvas'; 10 | import EdgeRender from './component/edge-render' 11 | import ActionMenu, {action} from './component/action-menu'; 12 | import {transformInitData, diffPropsData} from './adaptor'; 13 | 14 | import 'butterfly-dag/dist/index.css'; 15 | 16 | import './index.less'; 17 | 18 | // 跟antd的table的column的概念类似 19 | interface columns { 20 | title?: string, 21 | key: string, 22 | width?: number, 23 | primaryKey: boolean, 24 | render?(value: any, rowData: any): void 25 | } 26 | 27 | interface config { 28 | showActionIcon?: boolean, // 是否展示操作icon:放大,缩小,聚焦 29 | allowKeyboard?: boolean, // 允许键盘删除事件,todo以后支持shift多选 30 | collapse: { 31 | enable: boolean, // todo: 允许节点收缩 32 | defaultMode: string, // todo: 默认以哪种形式展示 33 | status: boolean, // 是否节点收缩 34 | showCollapseDetail: boolean // 展示收缩edge的详情 35 | }, 36 | enableHoverChain: boolean, 37 | enableFoucsChain: boolean, 38 | titleRender?(title: JSX.Element): void, // 节点title的渲染方法 39 | titleExtIconRender?(node: JSX.Element): void, // 节点右侧按钮的渲染方法 40 | labelRender?(label: JSX.Element): void, // 线段label的渲染方法 41 | autoLayout: { 42 | enable: boolean, // 是否开启自动布局 43 | isAlways: boolean, // 是否添加节点后就重新布局 44 | type: string, // 算法类型 45 | config: any // 算法配置 46 | }, 47 | minimap: { // 是否开启缩略图 48 | enable: boolean, 49 | config: { 50 | nodeColor: any 51 | } 52 | }, 53 | gridMode: { 54 | isAdsorb: boolean, 55 | theme: { 56 | shapeType: string, // 展示的类型,支持line & circle 57 | gap: number, // 网格间隙 58 | lineWidth: 1, // 线段粗细 59 | lineColor: string, // 线段颜色 60 | circleRadiu: number, // 圆点半径 61 | circleColor: string // 圆点颜色 62 | } 63 | }, 64 | butterfly: any; // 小蝴蝶的画布配置,参考:https://github.com/alibaba/butterfly/blob/dev/v4/docs/zh-CN/canvas.md 65 | } 66 | 67 | // 右键菜单配置 68 | interface menu { 69 | title?: string, 70 | key: string, 71 | render?(key: string): void, 72 | onClick?(node: any): void, 73 | } 74 | 75 | interface ComProps { 76 | width?: number | string, // 组件宽 77 | height?: number | string, // 组件高 78 | className?: string, // 组件classname 79 | columns: columns[], // 跟antd的table的column的概念类似 80 | nodeMenu: menu[], // 节点右键菜单配置 81 | edgeMenu: menu[], // 线段右键菜单配置 82 | actionMenu: action[], // action菜单 83 | config: config, // 如上述配置 84 | data: any, // 数据 85 | emptyWidth?: number | string, // 空数据时默认标题宽度 86 | emptyContent?: string | JSX.Element, // 空数据显示内容 87 | selectable: boolean; // 开启框选模式 88 | beforeDeleteNode: Promise | boolean, // 删除节点前方法,可做二次删除确认 89 | beforeDeleteEdge: Promise | boolean, // 删除线段前方法,可做二次删除确认 90 | onLoaded(canvas: any, utils: any): void, // 渲染完毕事件 91 | onChange(data: any): void, // 图内数据变化事件 92 | onFocusNode(node: any): void, // 聚焦节点事件 93 | onFocusEdge(edge: any): void, // 聚焦线段事件 94 | onFocusCanvas(): void, // 聚焦空白处事件 95 | onDblClickNode?(node: any): void, // 双击节点事件 96 | onDblClickEdge?(edge: any): void, // 双击线段事件 97 | onSelect(nodes: any, edges: any): void, // 选中事件 98 | 99 | // TODO: 展开/收缩节点 100 | // onDeleteNodes(nodeInfo: any): void, 101 | // onDeleteEdges(edgeInfo: any): void, 102 | // onConnectEdges(edgeInfo: any): void, 103 | // onReConnectEdges(addEdgeInfo: any, rmEdgeInfo: any): void, 104 | }; 105 | 106 | const noop = () => null; 107 | 108 | export default class TableBuilding extends React.Component { 109 | protected canvas: any; 110 | protected canvasData: any; 111 | private _focusNodes: any; 112 | private _columns: any; 113 | private _focusLinks: any; 114 | private _enableHoverChain: any; 115 | private _enableFocusChain: any; 116 | private root: any; 117 | props: any; 118 | 119 | constructor(props: ComProps) { 120 | super(props); 121 | this.canvas = null; 122 | this.canvasData = null; 123 | this.root = null; 124 | 125 | this._focusNodes = []; 126 | this._focusLinks = []; 127 | 128 | this._enableHoverChain = _.get(props, 'config.enableHoverChain', true); 129 | this._enableFocusChain = _.get(props, 'config.enableFocusChain', false); 130 | } 131 | 132 | componentDidMount() { 133 | const {beforeLoad = noop, config = {}} = this.props; 134 | 135 | let root = ReactDOM.findDOMNode(this) as HTMLElement; 136 | this.root = root; 137 | 138 | if (this.props.width !== undefined) { 139 | root.style.width = (this.props.width || 500) + 'px'; 140 | } 141 | 142 | if (this.props.height !== undefined) { 143 | root.style.height = (this.props.height || 500) + 'px'; 144 | } 145 | 146 | let result = transformInitData({ 147 | columns: this.props.columns, 148 | config: this.props.config, 149 | nodeMenu: this.props.nodeMenu, 150 | edgeMenu: this.props.edgeMenu, 151 | data: _.cloneDeep(this.props.data), 152 | emptyContent: this.props.emptyContent, 153 | emptyWidth: this.props.emptyWidth 154 | }); 155 | 156 | this.canvasData = result; 157 | this.canvas = new Canvas( 158 | _.merge( 159 | {}, 160 | // 默认配置 161 | bfCfg, 162 | // 用户配置 163 | (config.butterfly || {}), 164 | // 固定配置 165 | { 166 | root, 167 | data: { 168 | enableHoverChain: this._enableHoverChain, 169 | enableFocusChain: this._enableFocusChain, 170 | showCollapseDetail: _.get(this.props, 'config.collapse.showCollapseDetail', false) 171 | } 172 | } 173 | ) 174 | ); 175 | 176 | beforeLoad({ 177 | canvas: this.canvas, 178 | Arrow 179 | }); 180 | 181 | this.canvas.draw(result, () => { 182 | this.props.onLoaded && this.props.onLoaded(this.canvas); 183 | let minimap = _.get(this, 'props.config.minimap', {}); 184 | 185 | if (_.get(this, 'props.config.allowKeyboard')) { 186 | $(root).attr('tabindex', 0).focus(); 187 | root.addEventListener('keydown', this._deleteFocusItem.bind(this)); 188 | } 189 | 190 | const minimapCfg = _.assign({}, minimap.config, { 191 | events: [ 192 | 'system.node.click', 193 | 'system.canvas.click' 194 | ] 195 | }); 196 | 197 | if (minimap && minimap.enable) { 198 | this.canvas.setMinimap(true, minimapCfg); 199 | } 200 | 201 | if (_.get(this, 'props.config.collapse.defaultMode') === 'collapse') { 202 | this.canvas.nodes.forEach((item) => { 203 | this.canvas.collapse(item.id); 204 | }) 205 | } 206 | 207 | if (_.get(this, 'props.config.gridMode')) { 208 | this.canvas.setGridMode(true, _.assign({}, _.get(this, 'props.config.gridMode', {}))) 209 | } 210 | 211 | this.forceUpdate(); 212 | }); 213 | 214 | this.initEvents(); 215 | } 216 | 217 | /** 218 | * 初始化butterfly事件 219 | */ 220 | initEvents() { 221 | const {config, edgeMenu} = this.props; 222 | let isAfterSelect = false; 223 | 224 | let _addLinks = (links: any) => { 225 | let newLinkOpts = links.map((item: any) => { 226 | let _oldSource = _.get(item, 'sourceEndpoint.id', '').replace('-right', ''); 227 | let _oldTarget = _.get(item, 'targetEndpoint.id', '').replace('-left', ''); 228 | let _newSource = _oldSource + '-right'; 229 | let _newTarget = _oldTarget + '-left'; 230 | return { 231 | id: item.options.id || `${item.options.sourceNode}-${_oldSource}-${item.options.targetNode}-${_oldTarget}`, 232 | sourceNode: item.options.sourceNode, 233 | targetNode: item.options.targetNode, 234 | arrowShapeType: item.arrowShapeType, 235 | source: _newSource, 236 | target: _newTarget, 237 | _menu: item.options._menu || edgeMenu, 238 | _config: item.options._config || config, 239 | type: 'endpoint', 240 | label: item.label 241 | }; 242 | }); 243 | 244 | this.canvas.removeEdges(links, true); 245 | let newEdge = this.canvas.addEdges(newLinkOpts, true); 246 | newEdge.forEach((item) => { 247 | this.canvas.originEdges.push(_.assign({}, item.options, { 248 | source: _.get(item, 'sourceEndpoint.options.originId'), 249 | target: _.get(item, 'targetEndpoint.options.originId'), 250 | })); 251 | }); 252 | 253 | return newEdge; 254 | } 255 | 256 | this.canvas.on('system.link.connect', (data: any) => { 257 | let newEdges = _addLinks(data.links || []); 258 | this.onConnectEdges(newEdges); 259 | this.forceUpdate(); 260 | }); 261 | 262 | this.canvas.on('system.link.reconnect', (data: any) => { 263 | let _addEdges = _addLinks(data.addLinks || []); 264 | this.onReConnectEdges(_addEdges, data.delLinks); 265 | 266 | this.forceUpdate(); 267 | }); 268 | 269 | this.canvas.on('custom.edge.dblClick', (data: any) => { 270 | this.props.onDblClickEdge && this.props.onDblClickEdge(data.edge); 271 | }); 272 | 273 | this.canvas.on('system.node.click', (data: any) => { 274 | $(this.root).attr('tabindex', 0).focus(); 275 | this._focusNode(data.node); 276 | }); 277 | 278 | this.canvas.on('system.node.dblClick', (data: any) => { 279 | this.props.onDblClickNode && this.props.onDblClickNode(data.node); 280 | }); 281 | 282 | this.canvas.on('system.link.click', (data: any) => { 283 | $(this.root).attr('tabindex', 0).focus(); 284 | this._focusLink(data.edge); 285 | }); 286 | 287 | this.canvas.on('system.canvas.click', (data: any) => { 288 | $(this.root).attr('tabindex', 0).focus(); 289 | if(isAfterSelect) { 290 | return; 291 | } 292 | this._unfocus(); 293 | this.props.onFocusCanvas && this.props.onFocusCanvas(); 294 | this.canvas.unfocus(); 295 | }); 296 | 297 | this.canvas.on('system.multiple.select', ({data}) => { 298 | 299 | $(this.root).attr('tabindex', 0).focus(); 300 | 301 | // 加这个判断是为了防止[system.canvas.click]事件和当前事件冲突 302 | isAfterSelect = true; 303 | 304 | const {nodes, edges} = data; 305 | this._unfocus(); 306 | 307 | nodes.forEach(node => { 308 | node.focus(); 309 | this._focusNodes.push(node); 310 | }); 311 | 312 | edges.forEach(edge => { 313 | edge.focus(); 314 | this._focusLinks.push(edge); 315 | }) 316 | 317 | _.isFunction(this.props.onSelect) && this.props.onSelect(nodes, edges); 318 | 319 | // 防止误触 320 | setTimeout(() => { 321 | isAfterSelect = false; 322 | }, 100); 323 | }); 324 | 325 | this.canvas.on('custom.node.delete', (data: any) => { 326 | this.onDeleteNodes([data.node]); 327 | }); 328 | 329 | this.canvas.on('custom.item.focus', (data: any) => { 330 | this._unfocus(); 331 | this._focusNodes = this._focusNodes.concat(data.nodes || []); 332 | this._focusLinks = this._focusLinks.concat(data.edges || []); 333 | 334 | }); 335 | 336 | this.canvas.on('table.canvas.expand', () => { 337 | this.forceUpdate(); 338 | }); 339 | 340 | this.canvas.on('table.canvas.collapse', () => { 341 | this.forceUpdate(); 342 | }); 343 | } 344 | 345 | 346 | shouldComponentUpdate(newProps: ComProps, newState: any) { 347 | 348 | if (this.canvas.isSelectMode !== !!newProps.selectable) { 349 | this.canvas.setSelectMode(!!newProps.selectable); 350 | } 351 | 352 | // 更新节点 353 | let result = transformInitData({ 354 | columns: this.props.columns, 355 | config: this.props.config, 356 | nodeMenu: this.props.nodeMenu, 357 | edgeMenu: this.props.edgeMenu, 358 | data: _.cloneDeep(newProps.data), 359 | emptyContent: this.props.emptyContent, 360 | emptyWidth: this.props.emptyWidth 361 | }); 362 | 363 | let diffInfo = diffPropsData(result, this.canvasData, { 364 | oldCol: this.props.columns, 365 | newCol: newProps.columns 366 | }); 367 | if (diffInfo.addNodes.length > 0) { 368 | this.canvas.addNodes(diffInfo.addNodes); 369 | } 370 | if (diffInfo.rmNodes.length > 0) { 371 | this.canvas.removeNodes(diffInfo.rmNodes.map((item) => item.id)); 372 | } 373 | 374 | // 更新节点中的字段 375 | let _isDiffNode = ( 376 | diffInfo.updateTitle.length > 0 || 377 | diffInfo.updateFields.length > 0 || 378 | diffInfo.addFields.length > 0 || 379 | diffInfo.rmFields.length > 0 || 380 | diffInfo.newCol.length > 0 381 | ); 382 | 383 | if (_isDiffNode) { 384 | this.canvas.updateNodes(diffInfo); 385 | } 386 | 387 | if (diffInfo.addEdges.length > 0) { 388 | this.canvas.addEdges(diffInfo.addEdges); 389 | } 390 | 391 | if (diffInfo.rmEdges.length > 0) { 392 | this.canvas.removeEdges(diffInfo.rmEdges.map(edge => edge.id)); 393 | } 394 | 395 | if (diffInfo.updateLabel.length > 0) { 396 | this.canvas.updateLabel(diffInfo.updateLabel); 397 | } 398 | 399 | let newCollapse = _.get(newProps, 'config.collapse.status', false); 400 | let oldCollapse = _.get(this.props, 'config.collapse.status', false); 401 | 402 | if (newCollapse !== oldCollapse) { 403 | this.canvas.nodes.forEach((node) => { 404 | newCollapse && this.canvas.collapse(node.id); 405 | !newCollapse && this.canvas.expand(node.id); 406 | }); 407 | } 408 | 409 | this.canvasData = result; 410 | return true; 411 | } 412 | 413 | componentWillUnmount() { 414 | if (_.get(this, 'props.config.allowKeyboard')) { 415 | this.root.removeEventListener('keydown', this._deleteFocusItem); 416 | } 417 | } 418 | 419 | onConnectEdges(links) { 420 | let linksInfo = links.map((item) => { 421 | return _.assign(item.options, { 422 | source: _.get(item, 'options.source', '').replace('-right', ''), 423 | target: _.get(item, 'options.target', '').replace('-left', ''), 424 | _sourceNode: item.sourceNode, 425 | _targetNode: item.targetNode, 426 | _sourceEndpoint: item.sourceEndpoint, 427 | _targetEndpoint: item.targetEndpoint 428 | }); 429 | }); 430 | 431 | let newEdges = _.differenceWith(linksInfo, this.canvasData.edges, (a: any, b: any) => { 432 | return ( 433 | a.sourceNode === b.sourceNode && 434 | a.targetNode === b.targetNode && 435 | a.source === b.source && 436 | a.target === b.target 437 | ); 438 | }); 439 | 440 | this.canvasData.edges = this.canvasData.edges.concat(newEdges); 441 | 442 | this.props.onChange && this.props.onChange({ 443 | type: 'system.link.connect', 444 | links: linksInfo 445 | }); 446 | } 447 | 448 | onReConnectEdges(addLinks, rmLinks) { 449 | let addLinksInfo = addLinks.map((item) => { 450 | return _.assign(item.options, { 451 | source: _.get(item, 'options.source', '').replace('-right', ''), 452 | target: _.get(item, 'options.target', '').replace('-left', '') 453 | }); 454 | }); 455 | let rmLinksInfo = rmLinks.map((item) => { 456 | return _.assign(item.options, { 457 | source: _.get(item, 'options.source', '').replace('-right', ''), 458 | target: _.get(item, 'options.target', '').replace('-left', '') 459 | }); 460 | }); 461 | this.props.onChange && this.props.onChange({ 462 | type: 'system.link.reconnect', 463 | addLinks: addLinksInfo, 464 | rmLinks: rmLinksInfo 465 | }); 466 | } 467 | 468 | onDeleteNodes(nodes) { 469 | 470 | let beforeDeleteNode = this.props.beforeDeleteNode || function() {return true}; 471 | 472 | Promise.resolve(beforeDeleteNode(nodes)) 473 | .then((result) => { 474 | if (result === false) { 475 | return false; 476 | } else { 477 | let neighborLinksInfo = []; 478 | nodes.forEach((node) => { 479 | let links = this.canvas.getNeighborEdges(node.id); 480 | let linksInfo = links.map((link) => { 481 | return link.options; 482 | }); 483 | neighborLinksInfo = neighborLinksInfo.concat(linksInfo); 484 | 485 | node.remove(); 486 | }); 487 | 488 | let nodesInfo = nodes.map((item) => { 489 | return item.options; 490 | }); 491 | 492 | this.props.onChange && this.props.onChange({ 493 | type: 'system.node.delete', 494 | nodes: nodesInfo, 495 | neighborLinks: neighborLinksInfo 496 | }); 497 | } 498 | }).catch(() => {}); 499 | } 500 | 501 | onDeleteEdges(links) { 502 | 503 | let beforeDeleteEdge = this.props.beforeDeleteEdge || function() {return true}; 504 | 505 | Promise.resolve(beforeDeleteEdge(links)) 506 | .then((result) => { 507 | if (result === false) { 508 | return; 509 | } else { 510 | let linksInfo = links.map((item) => { 511 | return item.options; 512 | }); 513 | 514 | this.props.onChange && this.props.onChange({ 515 | type: 'system.link.delete', 516 | links: linksInfo 517 | }); 518 | } 519 | }).catch(() => {}); 520 | } 521 | 522 | _genClassName() { 523 | return (this.props.className || '') + ' butterfly-table-building' 524 | } 525 | 526 | // 聚焦节点 527 | _focusNode(node) { 528 | this._unfocus(); 529 | node.focus(); 530 | this._focusNodes.push(node); 531 | this.props.onFocusNode && this.props.onFocusNode(node); 532 | } 533 | 534 | // 聚焦线段 535 | _focusLink(edge) { 536 | this._unfocus(); 537 | edge.focus(); 538 | this._focusLinks.push(edge); 539 | this.props.onFocusEdge && this.props.onFocusEdge(edge); 540 | } 541 | 542 | // 失焦 543 | _unfocus() { 544 | this._focusNodes.forEach((item) => { 545 | item.unfocus(); 546 | }); 547 | 548 | this._focusLinks.forEach((item) => { 549 | item.unfocus(); 550 | }); 551 | 552 | this._focusNodes = []; 553 | this._focusLinks = []; 554 | } 555 | 556 | _deleteFocusItem(e) { 557 | // todo: 这块需要好好思考下 558 | if (e.key === 'Delete' || e.key === 'Backspace') { 559 | if (this._focusNodes && this._focusNodes.length > 0) { 560 | this.onDeleteNodes(this._focusNodes); 561 | } 562 | if (this._focusLinks && this._focusLinks.length > 0) { 563 | this.onDeleteEdges(this._focusLinks); 564 | } 565 | } 566 | } 567 | 568 | _delNodes(nodes) { 569 | return nodes.map((item) => { 570 | return item.options; 571 | }); 572 | } 573 | 574 | _delEdges(edges) { 575 | return edges.map((item) => { 576 | return item.options; 577 | }); 578 | } 579 | 580 | render() { 581 | const {canvas} = this; 582 | const {actionMenu = []} = this.props; 583 | const actionMenuVisible = _.get(this, 'props.config.showActionIcon', true); 584 | const labelRender = _.get(this, 'props.config.labelRender', noop); 585 | const selectable = !!this.props.selectable; 586 | 587 | return ( 588 |
594 | 599 | 603 |
604 | ) 605 | } 606 | } -------------------------------------------------------------------------------- /src/static/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face {font-family: "table-build-icon"; 2 | src: url('//at.alicdn.com/t/font_2369312_0qxuga95yni.eot?t=1613541691358'); /* IE9 */ 3 | src: url('//at.alicdn.com/t/font_2369312_0qxuga95yni.eot?t=1613541691358#iefix') format('embedded-opentype'), /* IE6-IE8 */ 4 | url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAWYAAsAAAAACwgAAAVJAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHEIGVgCDXgqHYIZwATYCJAMcCxAABCAFhU0HdRu9CcgekqRfBAooB8AVIgAgHv6b+7wvM0k+0O4WFKJDxxJRoaoDZCNqRDULu7bEx/9BNjU0TOnmTEyU/l5+nyu9iVB6E1lcy0dQ7yjCtAP5asw+f81h2h6gEhNyRu9qTW0QXSSueyHEbu4ng1QEktWdCkX+6oACqFPlKkx1jbM1Ea9eJrYj80ZpAi1TmUHWBzx9tUayOBBYcKzYw9aok5QXrKERqSbezeIJKDWKu6wLgMfFz8d3NiFKoUzisrYcvVats0/oy4+Q+E84kAhwZX/NMD8iYTEgE7cmW6+AVI0uBrX8v5nt0NLTUKg+5WeNZ91n056TL5/+/w90t8bXQ0+K1/Yvr1IqJKJCwQ6Ue8YqPqGJQPBJNGAYkgBbDmlCoOQzKVDw9WnIuyCv3+lhMhBLAIjXYsNSLyeVJMnydHk1FK3ElFEpRTZ7omrKeZByBq0J/7YCTgLDwMaNo2kbEc/nP08mnveM7yZ5nBwumPCK5jKahiPQkHwuQSeII1cp7hzQIjsxj2GGSR6CtNfmTWVKsXrHJVn9o2CiFTna2rrRLvxlF2U+pzni7Bfy4l1d/d04LtE7OEAlHbdZIEg84lME7XaELkcKjguQIZ0IYS0piMz/neUNpM6Tl2CYktps+Rviezi8WTy+WzGxl8TxbkI+rourUM48SxA8T+Q/jaeftOyDcXPOiqW3bnTeRzXhdoHiSjeG3e9F4NUexJ/EhbS+k3nWdCp++iL7uroATu6KSE+PK0DfvQ2J2wQMqQeq+RaHV+sA9SGFOdbYXq8ROKQRoOZXHEzkbD959gyl5q+m+hpBfKgyb6+WSUmyukNhjvWUjiqZnCScokQVeS4/ZymnLieVU7i0VreWKiwWuuL8ggCVS52hqlY5JTj9/+9MUc6Pqcd09IxfE3CdApCVVlbIwsPC/7cMXI98EpOebSFZe/dCUmJGaQBquXma1x562GonIqC2cMqs3bc7fffuNKbCO+PoDDqt5TQNgxfbViobLt+CiFp1GT34589B+FBcQQ82U10OwokbNybIB+kKnCD+eEe/sg7RNzcdG/c3qefSB+6oLNu9863BW4Acp4VUVFBaokapL6NZiC6bJRKxs3vR799R8u2jKXvhPvjtexVD96Loqd+2o2M6tO7oqA0QP6ggfrctg91tKWAFd+LPcyW7nd/nsx02keXrgJvPdbDO/6Yt8OVXmPo2GwRPg/BtwfTxZ/iau+IqW8dx8aJesbs6tDA8nfZDX9AivS0YYNiTvsN6ETuSpN8LjhXpB2wan0x3U/qf+3dceBn9OVPK7asolsF4zDVX9AxXZMBn/9AQX/wri8wjOfDGqTp7uyeM6aptMRgu0JJX+AN3jrq3v/BE9nzCAN5AoTYHkgEzaVa6QaltA1QGEqFlkcKP28asA0nkJhaaFhCmuwyFnt+QTPdXko2hDqVJ4e6vppSx0LIranNsmxtnW741hDkpyGmG4lxrXPVKPqnLPjCJnPqAlQjv+kFewZhaO3aHfijNAtwI1hZW4xO3ZCg9xgu1Dd5pXZ0LBTVWQ8A+yiNmC1U1NiQnl0V1eby3tCyYapDMrdStiXVSBOWIAnI0QDGHZbhZc26SLEcHjIhicyHAFMFa/oF4CmyeL2Xua2n6D5LKAg89HrBmwWoL6W3QbLXjtSyvVfKcqpPNLkjWpywNBJyLn7TZzAJV0/4Qsx05qWiL4diepTypgBuUxw3rz+UWtMSNqKOIFDnKqKKOhvpcpdWiuGJcXY8mDox3KjqorLI+7zcVI+MkN9uUmIncert6eWi02Xr4bswwPtpWCwAAAA==') format('woff2'), 5 | url('//at.alicdn.com/t/font_2369312_0qxuga95yni.woff?t=1613541691358') format('woff'), 6 | url('//at.alicdn.com/t/font_2369312_0qxuga95yni.ttf?t=1613541691358') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */ 7 | url('//at.alicdn.com/t/font_2369312_0qxuga95yni.svg?t=1613541691358#table-build-icon') format('svg'); /* iOS 4.1- */ 8 | } 9 | @font-face { 10 | font-family: 'table-build-icon'; /* project id 2369312 */ 11 | src: url('//at.alicdn.com/t/font_2369312_wsg9aeubvfs.eot'); 12 | src: url('//at.alicdn.com/t/font_2369312_wsg9aeubvfs.eot?#iefix') format('embedded-opentype'), 13 | url('//at.alicdn.com/t/font_2369312_wsg9aeubvfs.woff2') format('woff2'), 14 | url('//at.alicdn.com/t/font_2369312_wsg9aeubvfs.woff') format('woff'), 15 | url('//at.alicdn.com/t/font_2369312_wsg9aeubvfs.ttf') format('truetype'), 16 | url('//at.alicdn.com/t/font_2369312_wsg9aeubvfs.svg#table-build-icon') format('svg'); 17 | } 18 | 19 | .table-build-icon { 20 | font-family: "table-build-icon" !important; 21 | font-size: 16px; 22 | font-style: normal; 23 | -webkit-font-smoothing: antialiased; 24 | -moz-osx-font-smoothing: grayscale; 25 | } 26 | 27 | .table-build-icon-zoom-in:before { 28 | content: "\e604"; 29 | } 30 | 31 | .table-build-icon-quanping2:before { 32 | content: "\e78b"; 33 | } 34 | 35 | .table-build-icon-zoom-out:before { 36 | content: "\e9e5"; 37 | } 38 | 39 | .table-build-icon-xiala:before { 40 | content: "\e608"; 41 | } 42 | 43 | .table-build-icon-canvas-cuo:before { 44 | content: "\e61f"; 45 | } 46 | 47 | .table-build-icon-iconfontxiaogantanhao:before { 48 | content: "\e60d"; 49 | } 50 | 51 | .table-build-icon-kongshuju:before { 52 | content: "\e600"; 53 | } 54 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": false, 5 | "module": "commonjs", 6 | "target": "es2015", 7 | "jsx": "react", 8 | "allowJs": true, 9 | "esModuleInterop": true 10 | } 11 | } --------------------------------------------------------------------------------