Skip to content

Commit e836ab1

Browse files
authored
feat: add group demo (#434)
1 parent 4e33983 commit e836ab1

File tree

17 files changed

+1907
-89
lines changed

17 files changed

+1907
-89
lines changed

apps/basic/config/routes.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export const routes = [
55
{ path: '/dag', component: '@/pages/dag' },
66
{ path: '/diff', component: '@/pages/diff' },
77
{ path: '/flow', component: '@/pages/flow' },
8+
{ path: '/group', component: '@/pages/group' },
89
];

apps/basic/package.json

+9-6
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,19 @@
1010
"dependencies": {
1111
"@antv/xflow": "workspace:*",
1212
"@antv/xflow-diff": "workspace:*",
13+
"@antv/layout": "^0.3.2",
14+
"@antv/x6-react-components": "^2.0.8",
1315
"highlight.js": "^10.7.3",
14-
"umi": "^4.0.64"
16+
"umi": "^4.0.64",
17+
"@ant-design/icons": "^5.2.6",
18+
"classnames": "^2.3.2",
19+
"lodash": "^4.17.15",
20+
"@types/lodash": "4.14.186"
1521
},
1622
"devDependencies": {
17-
"@ant-design/icons": "^5.2.6",
1823
"@types/highlight.js": "^9.12.4",
19-
"@types/react": "^18.2.37",
20-
"@types/react-dom": "^18.2.15",
21-
"antd": "^5.0.0",
22-
"classnames": "^2.3.2"
24+
"@types/react": "^18.0.0",
25+
"@types/react-dom": "^18.0.0"
2326
},
2427
"peerDependencies": {
2528
"antd": "^5.0.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.context-menu-wrapper {
2+
position: absolute;
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* @description 节点的右键菜单
3+
*/
4+
5+
import { Menu } from '@antv/x6-react-components';
6+
import type { Cell } from '@antv/xflow';
7+
// 不引入组件样式的话,节点右键菜单样式会有问题
8+
import '@antv/x6-react-components/es/menu/style/index.css';
9+
10+
import { useGraphEvent } from '@antv/xflow';
11+
import React, { useState, useMemo, useCallback } from 'react';
12+
import ReactDOM from 'react-dom';
13+
14+
import { Utils } from '../utils';
15+
16+
import styles from './index.less';
17+
18+
// https://g6.antv.antgroup.com/manual/advanced/coordinate-system
19+
// 获取到位置后将 dom 节点挂到 body 上定位,否则可能 dom 展示位位置和鼠标点击的位置有偏差
20+
const contextMenuRoot = document.createElement('div');
21+
document.body.appendChild(contextMenuRoot);
22+
23+
interface IProps {
24+
// 重命名群组
25+
renameCombineGroup: (
26+
cell: Cell,
27+
confirmPosition: { confirmX: number; confirmY: number },
28+
) => void;
29+
// 解散群组
30+
removeCombineGroup: (
31+
cell: Cell,
32+
confirmPosition: { confirmX: number; confirmY: number },
33+
) => void;
34+
}
35+
const ContextMenu = (props: IProps) => {
36+
const { renameCombineGroup, removeCombineGroup } = props;
37+
const [contextMenuPosition, setContextMenuPosition] = useState<{
38+
contextMenuX: number;
39+
contextMenuY: number;
40+
} | null>(null);
41+
// 当前操作的 cell
42+
const [currentCell, setCurrnetCell] = useState<Cell | null>();
43+
44+
// 取消右键菜单
45+
const cancelCellContextMenu = () => {
46+
setCurrnetCell(null);
47+
setContextMenuPosition(null);
48+
};
49+
50+
useGraphEvent('cell:contextmenu', ({ e, x, y, cell, view }) => {
51+
if (cell.isNode()) {
52+
const nodeType = cell?.getData()?.originData?.conceptType;
53+
// 除组合节点内部的普通子节点外,其它节点右键都会有操作
54+
if (!(cell.getParent() && !Utils.isCombineGroup(nodeType))) {
55+
setCurrnetCell(cell);
56+
setContextMenuPosition({ contextMenuX: e.pageX, contextMenuY: e.pageY });
57+
}
58+
} else {
59+
setCurrnetCell(cell);
60+
setContextMenuPosition({ contextMenuX: e.pageX, contextMenuY: e.pageY });
61+
}
62+
});
63+
64+
// 取消右键菜单
65+
useGraphEvent('blank:click', () => {
66+
setCurrnetCell(null);
67+
setContextMenuPosition(null);
68+
});
69+
70+
useGraphEvent('cell:click', () => {
71+
setCurrnetCell(null);
72+
setContextMenuPosition(null);
73+
});
74+
75+
// 解散组
76+
const handleRemoveCombineGroup = useCallback(() => {
77+
removeCombineGroup(currentCell as Cell, {
78+
confirmX: contextMenuPosition?.contextMenuX || 0,
79+
confirmY: contextMenuPosition?.contextMenuY || 0,
80+
});
81+
cancelCellContextMenu();
82+
}, [currentCell, contextMenuPosition, removeCombineGroup]);
83+
84+
// 重命名组
85+
const handleRenameCombineGroup = useCallback(() => {
86+
renameCombineGroup(currentCell as Cell, {
87+
confirmX: contextMenuPosition?.contextMenuX || 0,
88+
confirmY: contextMenuPosition?.contextMenuY || 0,
89+
});
90+
cancelCellContextMenu();
91+
}, [currentCell, contextMenuPosition, renameCombineGroup]);
92+
93+
// 获取右键菜单操作
94+
const rightMenuItem = useMemo(() => {
95+
let menuItem: {
96+
key: string;
97+
dom: React.ReactNode;
98+
}[] = [];
99+
if (
100+
currentCell?.isNode() &&
101+
Utils.isCombineGroup(currentCell?.getData()?.nodeType)
102+
) {
103+
menuItem = [
104+
{
105+
key: 'removeCombineGroup',
106+
dom: (
107+
<Menu.Item key="removeCombineGroup" onClick={handleRemoveCombineGroup}>
108+
解除组
109+
</Menu.Item>
110+
),
111+
},
112+
{
113+
key: 'renameCombineGroup',
114+
dom: (
115+
<Menu.Item key="renameCombineGroup" onClick={handleRenameCombineGroup}>
116+
重命名组
117+
</Menu.Item>
118+
),
119+
},
120+
];
121+
}
122+
return menuItem;
123+
}, [currentCell, handleRemoveCombineGroup, handleRenameCombineGroup]);
124+
125+
return ReactDOM.createPortal(
126+
currentCell?.isNode() &&
127+
Utils.isCombineGroup(currentCell?.getData()?.nodeType) &&
128+
contextMenuPosition ? (
129+
<div
130+
className={styles['context-menu-wrapper']}
131+
style={{
132+
left: contextMenuPosition?.contextMenuX,
133+
top: contextMenuPosition?.contextMenuY,
134+
}}
135+
>
136+
<Menu>{rightMenuItem.map((item) => item.dom)}</Menu>
137+
</div>
138+
) : null,
139+
contextMenuRoot,
140+
);
141+
};
142+
143+
export default ContextMenu;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
.group-wrap {
2+
.fold-group-wrap {
3+
display: flex;
4+
flex-direction: column;
5+
align-items: center;
6+
}
7+
8+
.group-name {
9+
margin-top: 6px;
10+
text-align: center;
11+
cursor: pointer;
12+
}
13+
14+
.group-name-fold {
15+
width: 100px;
16+
}
17+
18+
.fold-icon {
19+
position: absolute;
20+
top: 98%;
21+
left: 50%;
22+
width: 16px;
23+
height: 16px;
24+
background-color: rgb(245, 247, 251);
25+
transform: translate(-50%, -50%);
26+
cursor: pointer;
27+
}
28+
29+
.fold-style {
30+
position: relative;
31+
width: 36px;
32+
height: 36px;
33+
border: 2px solid #227eff;
34+
border-radius: 2px;
35+
background-color: #fff;
36+
}
37+
38+
.children-number {
39+
position: absolute;
40+
top: -30%;
41+
left: 80%;
42+
width: 20px;
43+
height: 20px;
44+
line-height: 20px;
45+
text-align: center;
46+
border: 1px solid #fff;
47+
border-radius: 50%;
48+
color: #227eff;
49+
background-color: #dde7f9;
50+
}
51+
52+
.unfold-style {
53+
position: relative;
54+
padding: 12px;
55+
border: 1px solid rgba(#227eff, 0.4);
56+
border-radius: 8px;
57+
background-color: rgba(#227eff, 0.08);
58+
59+
.unfold-icon {
60+
position: absolute;
61+
top: 98%;
62+
left: 50%;
63+
width: 16px;
64+
height: 16px;
65+
background-color: rgb(245, 247, 251);
66+
transform: translate(-50%, -50%);
67+
cursor: pointer;
68+
}
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
2+
import type { Node } from '@antv/xflow';
3+
import cx from 'classnames';
4+
5+
import type { INodeData } from '../type';
6+
7+
import styles from './index.less';
8+
9+
const GroupNode = ({ node }: { node: Node }) => {
10+
const { width, height } = node.getBBox();
11+
const data: INodeData = node.getData();
12+
const isFold = node.getProp('isFold');
13+
const { label } = data || {};
14+
const descendantsNumber = node.getProp('descendantsNumber') || 0;
15+
16+
const renderNodes = () => {
17+
const foldElment = (
18+
<div className={styles['fold-group-wrap']}>
19+
<div className={styles['fold-style']}>
20+
<div className={styles['children-number']}>{descendantsNumber}</div>
21+
{/* event 自定义事件 */}
22+
<div
23+
className={styles['fold-icon']}
24+
event="group:node:collapse"
25+
onClick={(e) => e.stopPropagation()}
26+
>
27+
<PlusCircleOutlined />
28+
</div>
29+
</div>
30+
<div className={cx(styles['group-name'], [styles['group-name-fold']])}>
31+
{label}
32+
</div>
33+
</div>
34+
);
35+
36+
const unfoldElment = (
37+
<>
38+
<div
39+
className={styles['unfold-style']}
40+
style={{ height: height < 18 ? height : height - 18 }}
41+
>
42+
<div
43+
className={styles['unfold-icon']}
44+
// event 自定义事件
45+
event="group:node:collapse"
46+
onClick={(e) => e.stopPropagation()}
47+
>
48+
<MinusCircleOutlined />
49+
</div>
50+
</div>
51+
<div className={styles['group-name']}>{label}</div>
52+
</>
53+
);
54+
return (
55+
<div className={styles['group-wrap']}>{isFold ? foldElment : unfoldElment}</div>
56+
);
57+
};
58+
59+
return <div style={{ width, height }}>{renderNodes()}</div>;
60+
};
61+
62+
export default GroupNode;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.graph-normal-node {
2+
height: 100%;
3+
4+
.node-wrap {
5+
display: flex;
6+
flex-direction: column;
7+
align-items: center;
8+
}
9+
10+
.node-styl {
11+
position: relative;
12+
background-color: #227eff;
13+
border-radius: 2px;
14+
}
15+
16+
.node-text {
17+
width: 100px;
18+
margin-top: 6px;
19+
text-align: center;
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { Node } from '@antv/xflow';
2+
3+
import type { INodeData } from '../type';
4+
5+
import styles from './index.less';
6+
7+
const NormalNode = ({ node }: { node: Node }) => {
8+
const data: INodeData = node.getData();
9+
const { width, height } = node.getBBox();
10+
11+
return (
12+
<div className={styles['graph-normal-node']}>
13+
<div className={styles['node-wrap']}>
14+
<div className={styles['node-styl']} style={{ width, height }} />
15+
<div className={styles['node-text']}>{data?.label}</div>
16+
</div>
17+
</div>
18+
);
19+
};
20+
21+
export default NormalNode;

apps/basic/src/pages/group/const.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// 群组节点默认的 size:调整大小或影响 Ports 的位置
2+
export const defaultGroupSize = {
3+
width: 36,
4+
height: 36,
5+
};
6+
7+
// 群组节点的 padding
8+
export const groupNodePadding = 24;
9+
10+
// 非群组节点的 size:调整大小或影响 Ports 的位置
11+
export const notGroupNodeSize = {
12+
width: 36,
13+
height: 36,
14+
};
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* @description @antv/layout DagreLayout
3+
*/
4+
import { DagreLayout } from '@antv/layout';
5+
import * as _ from 'lodash';
6+
7+
import type { IGraph } from './type';
8+
9+
export const layoutDagre = (graphData: IGraph, options?: Record<string, any>) => {
10+
const antvDagreLayout = new DagreLayout({
11+
type: 'dagre',
12+
nodesep: 50,
13+
// 布局方向和文档说明不一样:https://g6.antv.antgroup.com/manual/middle/layout/graph-layout#dagre
14+
// LR 是从左到有布局,但是配置之后变成从上到下的布局展示
15+
// TB 是从上到下布局,但是配置之后变成从左到右布局
16+
rankdir: 'TB',
17+
begin: options?.begin || [250, 250],
18+
});
19+
antvDagreLayout.layout(graphData);
20+
21+
return {
22+
edges: graphData.edges,
23+
nodes: graphData.nodes,
24+
};
25+
};

0 commit comments

Comments
 (0)