├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── src
├── components
│ ├── RegisterJumpNode.ts
│ └── RegisterNormalNode.ts
├── index.css
├── index.tsx
├── lib
│ ├── global.ts
│ ├── index.ts
│ ├── registerBehavior
│ │ ├── clickSelectEdge.ts
│ │ ├── dragConnectNode.ts
│ │ ├── dragNodeToEditor.ts
│ │ └── index.ts
│ ├── registerEdge
│ │ ├── cubicVertical.ts
│ │ └── index.ts
│ └── registerNode
│ │ ├── anchorNode.ts
│ │ └── index.ts
├── react-app-env.d.ts
└── utils.ts
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 提供一个基于 G6 3.0 实现流程图编辑的示例,预览地址:http://g6.demo.cyrilszq.cn
2 |
3 | ```
4 | npm install
5 | npm run start
6 | ```
7 |
8 | ### 想法
9 | 不打算在 G6 的基础上进行封装,因为总会遇到各种各样的特殊业务逻辑,封装后的接口很难满足业务需求,通常都还要进行定制。
10 | 所以希望以自定义节点、自定义行为的形式来对 G6 进行一些增强,暂时只考虑流程图编排的情况。
11 |
12 | 已有功能:
13 | - 边的选中行为
14 | - 将节点拖拽到画布行为
15 | - 连接两个锚点行为(配合带锚点的自定义节点 —— anchor-node)
16 |
17 |
18 | ### TODO LIST
19 |
20 | - [ ] 优化边的各种行为,保持和G6-Editor一致
21 | - [ ] 如何保持锚点可控
22 | - [ ] 支持快捷键
23 | - [ ] 支持grid
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "g6-flow-editor",
3 | "version": "0.0.1",
4 | "private": true,
5 | "dependencies": {
6 | "@antv/g6": "^3.0.4",
7 | "@types/antd": "^1.0.0",
8 | "@types/jest": "^24.0.12",
9 | "@types/node": "^12.0.0",
10 | "@types/react": "^16.8.17",
11 | "@types/react-dom": "^16.8.4",
12 | "antd": "^3.17.0",
13 | "react": "^16.8.6",
14 | "react-dom": "^16.8.6",
15 | "react-scripts": "3.0.1",
16 | "typescript": "^3.4.5"
17 | },
18 | "scripts": {
19 | "start": "react-scripts start",
20 | "build": "react-scripts build",
21 | "test": "react-scripts test",
22 | "eject": "react-scripts eject"
23 | },
24 | "eslintConfig": {
25 | "extends": "react-app"
26 | },
27 | "browserslist": {
28 | "production": [
29 | ">0.2%",
30 | "not dead",
31 | "not op_mini all"
32 | ],
33 | "development": [
34 | "last 1 chrome version",
35 | "last 1 firefox version",
36 | "last 1 safari version"
37 | ]
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cyrilszq/g6-flow-editor/3533cc18e253fb20c69e70d49762ca1361e7efc1/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
22 | React App
23 |
24 |
25 |
26 |
27 |
28 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/RegisterJumpNode.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 定义跳转节点
3 | */
4 | import G6 from '@antv/g6'
5 | import { optimizeMultilineText } from '../utils';
6 |
7 | const OPERATION = {
8 | // 编辑
9 | 'EDIT': 'EDIT',
10 | // 删除
11 | 'DEL': 'DEL',
12 | }
13 |
14 | const operationList = [
15 | {
16 | name: OPERATION.DEL,
17 | iconUrl: 'https://img.alicdn.com/tfs/TB1vaomoQvoK1RjSZFDXXXY3pXa-14-15.svg',
18 | size: 14,
19 | },
20 | {
21 | name: OPERATION.EDIT,
22 | iconUrl: 'https://img.alicdn.com/tfs/TB1rNAloHrpK1RjSZTEXXcWAVXa-14-14.svg',
23 | size: 14,
24 | },
25 | ]
26 |
27 | const uiConfig = {
28 | keyShapeStyle: {
29 | // 默认卡片宽
30 | width: 200,
31 | // 固定头部高度
32 | headerHeight: 34,
33 | // 固定内容高度
34 | bodyHeight: 46,
35 | // 固定底部高度
36 | footerHeight: 20,
37 | fill: '#fff',
38 | stroke: '#CED4D9',
39 | radius: 2,
40 | shadowOffsetX: 0,
41 | shadowOffsetY: 4,
42 | shadowBlur: 10,
43 | shadowColor: 'rgba(13, 26, 38, 0.08)',
44 | },
45 | textStyle: {
46 | textAlign: 'start',
47 | textBaseline: 'top',
48 | },
49 | }
50 |
51 | G6.registerNode('jump-node', {
52 | draw(cfg, group) {
53 | this.mainGroup = group
54 | this.model = cfg
55 | const { width, headerHeight, bodyHeight, footerHeight } = uiConfig.keyShapeStyle
56 | // 最小宽度
57 | this.width = width
58 | // 固定高度
59 | this.height = headerHeight + bodyHeight + footerHeight
60 | // 原点为矩形中心,所以要做偏移,偏移后x,y为左上角点,后续所有计算基于此点
61 | this.originX = -this.width / 2
62 | this.originY = -this.height / 2
63 | this.keyShape = this._drawKeyShape()
64 | this.headerShape = this._drawHeaderShape()
65 | this.bodyShape = this._drawBodyShape()
66 | this.footerShape = this._drawFooterShape()
67 | cfg.anchorPoints = [[0.5, 0, { type: 'in' }]]
68 | return this.keyShape
69 | },
70 | _drawKeyShape() {
71 | return this.mainGroup.addShape('rect', {
72 | attrs: {
73 | x: this.originX,
74 | y: this.originY,
75 | width: this.width,
76 | height: this.height,
77 | ...uiConfig.keyShapeStyle,
78 | },
79 | })
80 | },
81 | // 绘制头部,两个icon
82 | _drawHeaderShape() {
83 | const headerShape = this.mainGroup.addGroup()
84 | // 画头部 icon
85 | // 在最右边
86 | let offsetX = this.originX + this.width - 10
87 | const copyOperationList = operationList.slice()
88 | copyOperationList.forEach((operation) => {
89 | headerShape.addShape('image', {
90 | name: operation.name,
91 | attrs: {
92 | img: operation.iconUrl,
93 | x: offsetX - operation.size,
94 | y: this.originY + 10,
95 | width: operation.size,
96 | height: operation.size,
97 | },
98 | })
99 | offsetX = offsetX - 10 - operation.size
100 | })
101 | return headerShape
102 | },
103 | _drawBodyShape() {
104 | const bodyShape = this.mainGroup.addGroup()
105 | const { content, label } = this.model
106 | const { headerHeight, bodyHeight } = uiConfig.keyShapeStyle
107 | if (content) {
108 | const dialogTextShape = bodyShape.addShape('text', {
109 | attrs: {
110 | x: this.originX + 10,
111 | y: this.originY + headerHeight,
112 | height: 30,
113 | text: content,
114 | ...uiConfig.textStyle,
115 | lineHeight: 18,
116 | fill: '#666666',
117 | },
118 | })
119 | const fontWeight = dialogTextShape.attr('fontWeight')
120 | const fontFamily = dialogTextShape.attr('fontFamily')
121 | const fontSize = dialogTextShape.attr('fontSize')
122 | const fontStyle = dialogTextShape.attr('fontStyle')
123 | const fontVariant = dialogTextShape.attr('fontVariant')
124 | const font = `${fontStyle} ${fontVariant} ${fontWeight} ${fontSize}px ${fontFamily}`
125 | dialogTextShape.attr({ text: optimizeMultilineText(content, font, this.width - 20, bodyHeight) })
126 | } else {
127 | bodyShape.addShape('text', {
128 | attrs: {
129 | x: this.originX + 10,
130 | y: this.originY + headerHeight,
131 | text: label,
132 | ...uiConfig.textStyle,
133 | fill: '#00C1DE',
134 | },
135 | })
136 | }
137 | return bodyShape
138 | },
139 | _drawFooterShape() {
140 | const footerShape = this.mainGroup.addGroup()
141 | const { actionText } = this.model
142 | const { headerHeight, bodyHeight } = uiConfig.keyShapeStyle
143 | if (actionText) {
144 | this._drawImage(footerShape, this._getImageUrl('active'))
145 | footerShape.addShape('text', {
146 | attrs: {
147 | x: this.originX + 28,
148 | y: this.originY + headerHeight + bodyHeight - 2,
149 | text: actionText,
150 | ...uiConfig.textStyle,
151 | fill: '#00C1DE',
152 | },
153 | })
154 | } else {
155 | this._drawImage(footerShape, this._getImageUrl('default'))
156 | }
157 | return footerShape
158 | },
159 | _drawImage(group, url) {
160 | const { headerHeight, bodyHeight } = uiConfig.keyShapeStyle
161 | group.addShape('image', {
162 | attrs: {
163 | x: this.originX + 10,
164 | y: this.originY + headerHeight + bodyHeight,
165 | img: url,
166 | width: 12,
167 | height: 9,
168 | },
169 | })
170 | },
171 | _getImageUrl(type) {
172 | switch (type) {
173 | case 'default':
174 | return 'https://img.alicdn.com/tfs/TB1vK0asVYqK1RjSZLeXXbXppXa-12-9.svg'
175 | case 'active':
176 | return 'https://img.alicdn.com/tfs/TB138BbtbvpK1RjSZFqXXcXUVXa-12-9.svg'
177 | default:
178 | break
179 | }
180 | },
181 | }, 'anchor-node');
182 |
--------------------------------------------------------------------------------
/src/components/RegisterNormalNode.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 定义普通节点
3 | */
4 | import G6 from '@antv/g6'
5 | import { optimizeMultilineText } from '../utils';
6 |
7 | const OPERATION = {
8 | // 编辑
9 | 'EDIT': 'EDIT',
10 | // 删除
11 | 'DEL': 'DEL',
12 | }
13 |
14 | const operationList = [
15 | {
16 | name: OPERATION.DEL,
17 | iconUrl: 'https://img.alicdn.com/tfs/TB1vaomoQvoK1RjSZFDXXXY3pXa-14-15.svg',
18 | size: 14,
19 | },
20 | {
21 | name: OPERATION.EDIT,
22 | iconUrl: 'https://img.alicdn.com/tfs/TB1rNAloHrpK1RjSZTEXXcWAVXa-14-14.svg',
23 | size: 14,
24 | },
25 | ]
26 |
27 | const uiConfig = {
28 | keyShapeStyle: {
29 | // 默认卡片宽,基于挂的意图节点数量动态增大
30 | width: 290,
31 | // 固定头部高度
32 | headerHeight: 34,
33 | // 固定话术内容高度
34 | bodyHeight: 58,
35 | // 固定底部高度
36 | footerHeight: 40,
37 | fill: '#fff',
38 | stroke: '#CED4D9',
39 | radius: 2,
40 | shadowOffsetX: 0,
41 | shadowOffsetY: 4,
42 | shadowBlur: 10,
43 | shadowColor: 'rgba(13, 26, 38, 0.08)',
44 | },
45 | defaultIntentKeyShape: {
46 | width: 90,
47 | height: 24,
48 | fill: '#F4F4F4',
49 | },
50 | textStyle: {
51 | textAlign: 'start',
52 | textBaseline: 'top',
53 | },
54 | }
55 |
56 | G6.registerNode('normal-node', {
57 | draw(cfg, group) {
58 | this.mainGroup = group
59 | this.model = cfg
60 | const { width, headerHeight, bodyHeight, footerHeight } = uiConfig.keyShapeStyle
61 | // 最小宽度
62 | this.width = width
63 | // 固定高度
64 | this.height = headerHeight + bodyHeight + footerHeight
65 | // 原点为矩形中心,所以要做偏移,偏移后x,y为左上角点,后续所有计算基于此点
66 | this.originX = -this.width / 2
67 | this.originY = -this.height / 2
68 | this.keyShape = this._drawKeyShape()
69 | this.headerShape = this._drawHeaderShape()
70 | cfg.anchorPoints = [
71 | [0.25, 1, { type: 'out' }],
72 | [0.5, 1, { type: 'out' }],
73 | [0.75, 1, { type: 'out' }],
74 | ]
75 | return this.keyShape
76 | },
77 | _drawKeyShape() {
78 | return this.mainGroup.addShape('rect', {
79 | attrs: {
80 | x: this.originX,
81 | y: this.originY,
82 | width: this.width,
83 | height: this.height,
84 | ...uiConfig.keyShapeStyle,
85 | },
86 | })
87 | },
88 | // 绘制头部,两个icon
89 | _drawHeaderShape() {
90 | const headerShape = this.mainGroup.addGroup()
91 | // 画头部 icon
92 | // 在最右边
93 | let offsetX = this.originX + this.width - 10
94 | const copyOperationList = operationList.slice()
95 | copyOperationList.forEach((operation) => {
96 | headerShape.addShape('image', {
97 | name: operation.name,
98 | attrs: {
99 | img: operation.iconUrl,
100 | x: offsetX - operation.size,
101 | y: this.originY + 10,
102 | width: operation.size,
103 | height: operation.size,
104 | },
105 | })
106 | offsetX = offsetX - 10 - operation.size
107 | })
108 | return headerShape
109 | },
110 | }, 'anchor-node')
111 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import '~antd/dist/antd.css';
2 |
3 | body {
4 | margin: 0;
5 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
6 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
7 | sans-serif;
8 | -webkit-font-smoothing: antialiased;
9 | -moz-osx-font-smoothing: grayscale;
10 | }
11 |
12 | .editor-container {
13 | display: flex;
14 | flex-direction: column;
15 | height: 100vh;
16 | }
17 |
18 | .content {
19 | flex: 1;
20 | display: flex;
21 | }
22 |
23 | .item-panel {
24 | background: #ececec;
25 | min-width: 200px;
26 | height: 100%;
27 | display: flex;
28 | align-items: center;
29 | flex-direction: column;
30 | }
31 |
32 | .item-panel > img {
33 | margin-top: 20px;
34 | }
35 |
36 | #flow {
37 | flex: 1;
38 | height: 100%;
39 | }
40 |
41 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import G6 from '@antv/g6'
4 | import './lib'
5 | import './components/RegisterJumpNode'
6 | import './components/RegisterNormalNode'
7 | import './index.css';
8 |
9 |
10 | class App extends React.Component {
11 | graph: any;
12 | state = {
13 | data: {}
14 | }
15 |
16 | componentDidMount() {
17 | this.initGraph()
18 | this.initItemPanel()
19 | this.bindEvent()
20 | }
21 |
22 | private initGraph(): void {
23 | const container = document.querySelector('#flow')!
24 | const { width, height } = container.getBoundingClientRect()
25 | this.graph = new G6.Graph({
26 | container: 'flow',
27 | width,
28 | height,
29 | modes: {
30 | default: [
31 | 'zoom-canvas',
32 | {
33 | type: 'drag-node',
34 | shouldBegin(ev) {
35 | if (ev.target.get('name') === 'anchor') return false
36 | return true
37 | },
38 | },
39 | 'click-select',
40 | 'drag-node-to-editor',
41 | {
42 | type: 'drag-connect-node',
43 | },
44 | 'click-select-edge',
45 | ]
46 | },
47 | })
48 | this.graph.on('canvas:mousemove', (ev) => {
49 | ev.event.target.style.cursor = 'pointer'
50 | })
51 | }
52 |
53 | private initItemPanel(): void {
54 | const items = [
55 | {
56 | type: 'circle',
57 | width: 200,
58 | height: 100,
59 | label: '请编辑节点内容',
60 | shape: 'jump-node',
61 | },
62 | {
63 | type: 'rect',
64 | width: 290,
65 | height: 132,
66 | label: '矩形',
67 | shape: 'normal-node',
68 | },
69 | ]
70 | const container = document.querySelector('#item-panel')!
71 | container.addEventListener('mousedown', (e) => {
72 | const target = e.target as Element
73 | const type = target.getAttribute('data-type')
74 | this.graph.emit('flow:addnode', items.find(item => item.type === type))
75 | })
76 | }
77 |
78 | private bindEvent(): void {
79 | window.addEventListener('keyup', (ev) => {
80 | // 删除
81 | if (ev.keyCode === 8) {
82 | this.graph.setAutoPaint(false)
83 | this.graph.findAllByState('node', 'selected').forEach((item) => {
84 | this.graph.remove(item)
85 | })
86 | this.graph.findAllByState('edge', 'selected').forEach((item) => {
87 | this.graph.remove(item)
88 | })
89 | this.graph.paint()
90 | this.graph.setAutoPaint(true)
91 | }
92 | })
93 | }
94 |
95 | handleSave = () => {
96 | const data = this.graph.save()
97 | console.log("TCL: App -> handleSave -> data", data)
98 | this.setState({ data })
99 | }
100 |
101 | render() {
102 | const { data } = this.state
103 | return (
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |

113 |

114 |
115 |
116 |
117 |
118 | )
119 | }
120 | }
121 |
122 | ReactDOM.render(, document.getElementById('root'));
123 |
124 |
--------------------------------------------------------------------------------
/src/lib/global.ts:
--------------------------------------------------------------------------------
1 | // 默认锚点样式
2 | export const defaultAnchorStyle = {
3 | symbol: 'circle',
4 | radius: 3.5,
5 | fill: '#fff',
6 | stroke: '#1890FF',
7 | lineAppendWidth: 12,
8 | }
9 |
10 | // hover 锚点样式
11 | export const hoverAnchorStyle = {
12 | radius: 4,
13 | fill: '#1890FF',
14 | stroke: '#1890FF'
15 | }
--------------------------------------------------------------------------------
/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | import './registerBehavior'
2 | import './registerEdge'
3 | import './registerNode'
--------------------------------------------------------------------------------
/src/lib/registerBehavior/clickSelectEdge.ts:
--------------------------------------------------------------------------------
1 | import G6 from '@antv/g6'
2 |
3 | // 点击选中边
4 | G6.registerBehavior('click-select-edge', {
5 | getDefaultCfg() {
6 | return {
7 | }
8 | },
9 | getEvents() {
10 | return {
11 | 'edge:click': 'onClickEdge',
12 | 'canvas:click': 'clearSelect',
13 | 'edge:mouseenter': 'hoverEdge',
14 | 'edge:mouseleave': 'leaveEdge',
15 | };
16 | },
17 | onClickEdge(ev) {
18 | const edge = ev.item;
19 | this.clearSelect()
20 | this.graph.setItemState(edge, 'selected', !edge.hasState('selected')); // 切换选中
21 | },
22 | clearSelect(ev) {
23 | this.graph.findAllByState('edge', 'selected').forEach(edge => this.graph.setItemState(edge, 'selected', false))
24 | },
25 | hoverEdge(ev) {
26 | const edge = ev.item;
27 | this.graph.setItemState(edge, 'active', true);
28 | },
29 | leaveEdge(ev) {
30 | const edge = ev.item;
31 | !edge.hasState('selected') && this.graph.setItemState(edge, 'active', false);
32 | },
33 | })
--------------------------------------------------------------------------------
/src/lib/registerBehavior/dragConnectNode.ts:
--------------------------------------------------------------------------------
1 | import G6 from '@antv/g6'
2 |
3 |
4 | // 定义连接两个锚点
5 | G6.registerBehavior('drag-connect-node', {
6 | getDefaultCfg() {
7 | return {
8 | delegateEdgeStyle: {
9 | stroke: '#1890FF',
10 | lineDash: [ 4, 4 ],
11 | lineWidth: 1
12 | },
13 | edgeStyle: {
14 | lineDash: [],
15 | stroke: '#A3B1BF',
16 | strokeOpacity: 0.92,
17 | lineWidth: 1,
18 | lineAppendWidth: 8,
19 | endArrow: true
20 | },
21 | shouldBegin(anchor, graph) {
22 | const { type } = anchor.get('anchorCfg')
23 | const { edges = [] } = graph.save()
24 | // 鼠标移动到 type 为 out 的锚点 && 该锚点没有连接过 才可以开始连线
25 | return type === 'out' && !edges.find((item) => item.source === anchor.get('id') && item.sourceAnchor === anchor.get('index'))
26 | },
27 | shouldEnd(anchor) {
28 | const { type } = anchor.get('anchorCfg')
29 | return type === 'in'
30 | }
31 | }
32 | },
33 | getEvents() {
34 | return {
35 | 'mouseenter': 'handleHoverAnchor',
36 | 'mousedown': 'handleStartAddEdge',
37 | 'mousemove': 'handleUpdateEdge',
38 | 'mouseleave': 'handleMouseleave',
39 | 'mouseup': 'handleStopAddEdge'
40 | };
41 | },
42 | handleHoverAnchor(ev) {
43 | const anchor = ev.target
44 | if (anchor.get('name') !== 'anchor') return
45 | if (this.selectedAchor && this.addingEdge) {
46 | this.targetAnchor = anchor
47 | } else if (this.shouldBegin(anchor, this.graph)) {
48 | this.setAchorHover(anchor)
49 | this.targetAnchor = null
50 | }
51 | },
52 | // 鼠标按下
53 | handleStartAddEdge(ev) {
54 | const anchor = ev.target
55 | if (anchor.get('name') !== 'anchor') return
56 | if (!this.selectedAchor && this.shouldBegin(anchor, this.graph)) {
57 | this.addingEdge = this.graph.addItem('edge', {
58 | shape: 'flow-cubic-vertical',
59 | style: this.delegateEdgeStyle,
60 | source: anchor.get('id'),
61 | sourceAnchor: anchor.get('index'),
62 | target: { x: anchor.get('x'), y: anchor.get('y') },
63 | })
64 | this.selectedAchor = anchor
65 | this.setAchorActive()
66 | }
67 | },
68 | // 移动鼠标,跟着画线
69 | handleUpdateEdge(ev) {
70 | const point = { x: ev.x, y: ev.y }
71 | if (this.selectedAchor && this.addingEdge) {
72 | // 增加边的过程中,移动时边跟着移动
73 | this.graph.updateItem(this.addingEdge, {
74 | target: point
75 | })
76 | }
77 | },
78 | // 鼠标滑出锚点时清空目标节点id
79 | handleMouseleave(ev) {
80 | const anchor = ev.target
81 | if (anchor.get('name') !== 'anchor') return
82 | if (!this.selectedAchor) {
83 | this.resetAchor(anchor)
84 | }
85 | this.targetAnchor = null
86 | },
87 | // 抬起鼠标,结束绘制,如果在锚点则进行连线
88 | handleStopAddEdge(ev) {
89 | if (!this.selectedAchor) return
90 | this.resetAchor(this.selectedAchor)
91 | if (this.targetAnchor && this.shouldEnd(this.targetAnchor)) {
92 | this.graph.updateItem(this.addingEdge, {
93 | style: this.edgeStyle,
94 | target: this.targetAnchor.get('id'),
95 | targetAnchor: ev.target.get('index'),
96 | })
97 | } else {
98 | this.graph.removeItem(this.addingEdge)
99 | }
100 | this.selectedAchor = null
101 | this.addingEdge = null
102 | },
103 | // 锚点hover样式
104 | setAchorHover(anchor) {
105 | anchor.attr({
106 | radius: 4,
107 | fill: '#1890FF',
108 | stroke: '#1890FF'
109 | })
110 | this.graph.paint()
111 | },
112 | // 锚点可被链接样式
113 | setAchorActive() {
114 | },
115 | // 锚点样式重置
116 | resetAchor(anchor) {
117 | anchor.attr({
118 | radius: 3.5,
119 | fill: '#fff',
120 | fillOpacity: 1,
121 | })
122 | this.graph.paint()
123 | },
124 | })
--------------------------------------------------------------------------------
/src/lib/registerBehavior/dragNodeToEditor.ts:
--------------------------------------------------------------------------------
1 | import G6 from '@antv/g6'
2 | import uuid from 'uuid'
3 |
4 | // 定义拖动节点到画布
5 | G6.registerBehavior('drag-node-to-editor', {
6 | getDefaultCfg() {
7 | return {
8 | delegateShapeStyle: {
9 | fill: '#F3F9FF',
10 | fillOpacity: 0.5,
11 | stroke: '#1890FF',
12 | strokeOpacity: 0.9,
13 | lineDash: [ 5, 5 ]
14 | },
15 | defaultNodeStyle: {
16 | stroke: '#CED4D9',
17 | shadowOffsetX: 0,
18 | shadowOffsetY: 4,
19 | shadowBlur: 10,
20 | shadowColor: 'rgba(13, 26, 38, 0.08)',
21 | lineWidth: 1,
22 | radius: 4,
23 | fillOpacity: 0.92
24 | }
25 | }
26 | },
27 | getEvents() {
28 | return {
29 | 'flow:addnode': 'startAddNode',
30 | // 移入 canvas,创建一个代理矩形
31 | 'canvas:mouseenter': 'onMouseenter',
32 | // 更新代理矩形位置
33 | 'canvas:mousemove': 'onMousemove',
34 | // 移除代理矩形,新增配置节点
35 | 'canvas:mouseup': 'onMouseup',
36 | // 移除canvas,移除代理矩形
37 | 'canvas:mouseleave': 'onMouseleave',
38 | };
39 | },
40 | // 开始添加
41 | startAddNode(node) {
42 | this.addingNode = node
43 | },
44 | onMouseenter(ev) {
45 | const { x, y } = ev
46 | if (!this.delegateShape && this.addingNode) {
47 | const { width, height } = this.addingNode
48 | const parent = this.graph.get('group')
49 | this.delegateShape = parent.addShape('rect', {
50 | attrs: {
51 | width,
52 | height,
53 | x: x - width / 2,
54 | y: y - height / 2,
55 | ...this.delegateShapeStyle,
56 | },
57 | })
58 | this.delegateShape.set('capture', false)
59 | }
60 | },
61 | onMousemove(ev) {
62 | const { x, y } = ev
63 | if (this.delegateShape && this.addingNode) {
64 | const { width, height } = this.addingNode
65 | this.delegateShape.attr({ x: x - width / 2, y: y - height / 2 });
66 | this.graph.paint();
67 | }
68 | },
69 | onMouseup(ev) {
70 | const { x, y } = ev
71 | if (this.delegateShape && this.addingNode) {
72 | const model = {
73 | id: uuid(),
74 | isShowAnchor: true,
75 | x,
76 | y,
77 | size: [this.addingNode.width, this.addingNode.height],
78 | style: this.defaultNodeStyle,
79 | anchorPoints: [[0.5, 0]],
80 | ...this.addingNode,
81 | }
82 | this.graph.add('node', model);
83 | this.delegateShape.remove()
84 | this.delegateShape = undefined
85 | this.addingNode = undefined
86 | this.graph.paint()
87 | }
88 | },
89 | onMouseleave() {
90 | if (this.delegateShape && this.addingNode) {
91 | this.delegateShape.remove()
92 | this.delegateShape = undefined
93 | this.addingNode = undefined
94 | this.graph.paint()
95 | }
96 | },
97 | })
--------------------------------------------------------------------------------
/src/lib/registerBehavior/index.ts:
--------------------------------------------------------------------------------
1 | import './dragConnectNode'
2 | import './dragNodeToEditor'
3 | import './clickSelectEdge'
--------------------------------------------------------------------------------
/src/lib/registerEdge/cubicVertical.ts:
--------------------------------------------------------------------------------
1 | import G6 from '@antv/g6'
2 |
3 | // 继承 cubic-vertical 做边选中样式调整
4 | G6.registerEdge('flow-cubic-vertical', {
5 | // 设置状态
6 | setState(name, value, item) {
7 | const group = item.getContainer();
8 | const shape = group.get('children')[0]; // 顺序根据 draw 时确定
9 | if(name === 'active') {
10 | if(value) {
11 | shape.attr({
12 | stroke: '#1890FF',
13 | strokeOpacity: 0.7,
14 | lineWidth: 2
15 | });
16 | } else {
17 | shape.attr({
18 | stroke: '#A3B1BF',
19 | lineWidth: 1,
20 | });
21 | }
22 | }
23 | if (name === 'selected') {
24 | if(value) {
25 | shape.attr({
26 | stroke: '#1890FF',
27 | strokeOpacity: 1,
28 | lineWidth: 2
29 | });
30 | } else {
31 | shape.attr({
32 | stroke: '#A3B1BF',
33 | lineWidth: 1,
34 | })
35 | }
36 | }
37 | }
38 | }, 'cubic-vertical')
39 |
--------------------------------------------------------------------------------
/src/lib/registerEdge/index.ts:
--------------------------------------------------------------------------------
1 | import './cubicVertical'
--------------------------------------------------------------------------------
/src/lib/registerNode/anchorNode.ts:
--------------------------------------------------------------------------------
1 | import G6 from '@antv/g6'
2 | import uuid from 'uuid'
3 | import { defaultAnchorStyle } from '../global';
4 |
5 | // 继承 node,新增画锚点
6 | G6.registerNode('anchor-node', {
7 | // 绘制后附加锚点
8 | afterDraw(cfg, group) {
9 | const { anchorPoints, width, height, id } = cfg
10 | anchorPoints.forEach((points, index) => {
11 | const [x, y, anchorCfg] = points
12 | // 把原点置为图形左上角
13 | const originX = -width / 2
14 | const originY = -height / 2
15 | const anchorPointX = x * width + originX
16 | const anchorPointY = y * height + originY
17 | const anchor = group.addShape('marker', {
18 | // 临时解决无法监听 anchor 事件的问题
19 | name: 'anchor',
20 | anchorCfg,
21 | id,
22 | index,
23 | attrs: {
24 | ...defaultAnchorStyle,
25 | x: anchorPointX,
26 | y: anchorPointY,
27 | },
28 | });
29 | })
30 | },
31 | }, 'node')
32 |
--------------------------------------------------------------------------------
/src/lib/registerNode/index.ts:
--------------------------------------------------------------------------------
1 | import './anchorNode'
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 |
2 | const canvas = document.createElement('canvas')
3 | const canvasContext = canvas.getContext('2d')!
4 |
5 | export function optimizeMultilineText(text, font, maxWidth, maxHeight, lineHeight = 18) {
6 | canvasContext.font = font
7 | if (canvasContext.measureText(text) <= maxWidth) {
8 | return text
9 | }
10 | let multilineText = ''
11 | let multilineTextWidth = 0
12 | let currentHeight = lineHeight
13 | for (const char of text) {
14 | // 用户输入换行处理
15 | if (char === '\n') {
16 | multilineTextWidth = 0
17 | currentHeight += lineHeight
18 | }
19 | const { width } = canvasContext.measureText(char)
20 | // 加 \n 使文字折行
21 | if (multilineTextWidth + width >= maxWidth) {
22 | multilineText += '\n'
23 | multilineTextWidth = 0
24 | currentHeight += lineHeight
25 | }
26 | // 达到最大高度,给文本加省略号,不再继续计算
27 | if (currentHeight > maxHeight) {
28 | return `${multilineText.substring(0, multilineText.length - 1)}...`
29 | }
30 | multilineText += char
31 | multilineTextWidth += width
32 | }
33 | return multilineText
34 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "noImplicitAny": false,
12 | "esModuleInterop": true,
13 | "allowSyntheticDefaultImports": true,
14 | "strict": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "preserve"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------