相关背景:
最近由于手头在做手头的相关业务,其中有一个业务涉及到图形编辑,故查阅了相关资料,经过仔细查阅对比,最终选择了AntV下的X6,X6是AntV旗下的图形编辑引擎,提供了一些列开箱即用的交互组件和简单易用的节点定制能力,从而快速搭建流程图、DAG图、ER图等图应用。特点是节点、边等元素定制能力特别强。
主要相关事件及Api:
- Graph(画布):画布是图的载体,,它包含了图上的所有元素(节点、边等),同时挂载了图的相关操作(如交互监听、元素操作、渲染等)。
const graph = new Graph({
panning: true,
})
// 可以添加相关属性来设置画布的基础属性,具体见https://x6.antv.vision/zh/docs/tutorial/basic/graph
- Cell(基类):定义了节点和边共同属性和方法,如属性样式、可见性、业务数据等,并且在实例化、定制样式、配置默认选项等方面具有相同的行为。
- Node(节点):X6 的
Shape命名空间中内置了一些基础节点,,如Rect、Circle、Ellipse等,可以使用这些节点的构造函数来创建节点。
import { Shape } from '@antv/x6'
// 创建节点
const rect = new Shape.Rect({
x: 100,
y: 200,
width: 80,
height: 40,
angle: 30,
attrs: {
body: {
fill: 'blue',
},
label: {
text: 'Hello',
fill: 'white',
},
},
})
// 添加到画布
graph.addNode(rect)
- Edge(边): X6 的
Shape命名空间中内置Edge、DoubleEdge、ShadowEdge三种边,可以使用这些边的构造函数来创建边。
import { Shape } from '@antv/x6'
// 创建边
const edge = new Shape.Edge({
source: rect1,
target: rect2,
})
// 添加到画布
graph.addEdge(edge)
- Port(链接桩):链接桩是节点上的固定连接点,很多图应用都有链接桩,并且有些应用还将链接桩分为输入链接桩和输出连接桩。
创建节点时我们可以通过 `ports` 选项来配置链接桩
const node = new Node({
ports: {
groups: { ... }, // 链接桩组定义
items: [ ... ], // 链接桩
}
})
- Dnd(拖拽):Dnd 是
Addon命名空间中的一个插件,提供了基础的拖拽能力。
// 首先,创建一个 Dnd 的实例,并提供了一些选项来定制拖拽行为。
import { Addon } from '@antv/x6'
const dnd = new Addon.Dnd(options)
// 相关api
/**
*开始拖拽
*dnd.start(node, e)
*@params node:开始拖拽的节点。
*@params e: 鼠标事件
**/
- 序列化/反序列化:
graph.toJSON()和graph.fromJSON两个方法来序列化和反序列化图,
// 序列化
// 我们可以调用 `graph.toJSON()` 方法来导出图中的节点和边,返回一个具有{ cells: [] }` 结构的对象,其中 cells 数组**按渲染顺序**保存节点和边。
// 节点结构如下
{
id: string,
shape: string,
position: {
x: number
y: number
},
size: {
width: number
height: number
},
attrs: object,
zIndex: number,
}
// 边结构如下
{
id: string,
shape: string,
source: object,
target: object,
attrs: object,
zIndex: number,
}
// 反序列化,支持节点/边元数据数组
graph.fromJSON(cells: (Node.Metadata | Edge.Metadata)[])。
graph.fromJSON([
{
id: 'node1',
x: 40,
y: 40,
width: 100,
height: 40,
label: 'Hello',
shape: 'rect',
},
{
id: 'node2',
x: 40,
y: 40,
width: 100,
height: 40,
label: 'Hello',
shape: 'ellipse',
},
{
id: 'edge1',
source: 'node1',
target: 'node2',
shape: 'edge',
}
])
// 可以通过 `graph.fromJSON({ cells: [...] })` 来渲染 `graph.toJSON()` 导出的数据。
事件系统
- 视图交互事件
- 鼠标事件
| 事件 | cell 节点/边 | node 节点 | edge 边 | blank 画布空白区域 |
|---|---|---|---|---|
| 单击 | cell:click | node:click | edge:click | blank:click |
| 双击 | cell:dblclick | node:dblclick | edge:dblclick | blank:dblclick |
| 右键 | cell:contextmenu | node:contextmenu | edge:contextmenu | blank:contextmenu |
| 鼠标按下 | cell:mousedown | node:mousedown | edge:mousedown | blank:mousedown |
| 移动鼠标 | cell:mousemove | node:mousemove | edge:mousemove | blank:mousemove |
| 鼠标抬起 | cell:mouseup | node:mouseup | edge:mouseup | blank:mouseup |
| 鼠标滚轮 | cell:mousewheel | node:mousewheel | edge:mousewheel | blank:mousewheel |
| 鼠标进入 | cell:mouseenter | node:mouseenter | edge:mouseenter | graph:mouseenter |
| 鼠标离开 | cell:mouseleave | node:mouseleave | edge:mouseleave | graph:mouseleave |
graph.on('cell:click', ({ e, x, y, cell, view }) => { })
graph.on('node:click', ({ e, x, y, node, view }) => { })
graph.on('edge:click', ({ e, x, y, edge, view }) => { })
graph.on('blank:click', ({ e, x, y }) => { })
graph.on('cell:mouseenter', ({ e, cell, view }) => { })
graph.on('node:mouseenter', ({ e, node, view }) => { })
graph.on('edge:mouseenter', ({ e, edge, view }) => { })
graph.on('graph:mouseenter', ({ e }) => { })
- 自定义事件
// 在节点/边的 DOM 元素上添加自定义属性 `event` 或 `data-event` 来监听该元素的点击事件
node.attr({
// 表示一个删除按钮,点击时删除该节点
image: {
event: 'node:delete',
xlinkHref: 'trash.png',
width: 20,
height: 20,
},
})
// 可以通过绑定的事件名 `node:delete` 或通用的 `cell:customevent`、`node:customevet`、`edge:customevent` 事件名来监听。
graph.on('node:delete', ({ view, e }) => {
e.stopPropagation()
view.cell.remove()
})
graph.on('node:customevent', ({ name, view, e }) => {
if (name === 'node:delete') {
e.stopPropagation()
view.cell.remove()
}
})
// 这只是简单事件相关api,具体详见:https://x6.antv.vision/zh/docs/tutorial/intermediate/events
相关实践:
以上主要相关api基本介绍完毕了,接下来正式进入图编辑相关实践,其中分为以下步骤
页面初始化
- 生成画布,并设置相关属性。
import { Graph, Dom, Addon, Shape } from '@antv/x6';
const graph = new Graph({
container: this.container, // 画布容器
grid: true, // 网格
history: { // 操作历史
enabled: true,
},
snapline: { // 对齐线
enabled: true,
sharp: true,
},
scroller: { // 滚动画布
enabled: true,
pageVisible: false,
pageBreak: false,
pannable: true,
},
mousewheel: { // 鼠标滚轮缩放
enabled: true,
modifiers: ['ctrl', 'meta'],
},
connecting: { // 连线选项
router: 'manhattan',
connector: {
name: 'rounded',
args: {
radius: 8,
},
},
anchor: 'center',
allowBlank: false,
snap: {
radius: 20,
},
createEdge() { // 创建边属性
return new Shape.Edge({
attrs: {
line: {
stroke: '#A2B1C3',
strokeWidth: 2,
targetMarker: {
name: 'block',
width: 12,
height: 8,
},
},
},
zIndex: 0,
});
},
validateConnection({ targetMagnet }) {
return !!targetMagnet;
},
}
});
- 创建Dnd实例,并设置拖拽行为
this.dnd = new Dnd({
target: graph,
scaled: false,
animation: true,
getDropNode: (draggingNode: any, GetDragNodeOptions) => {
// 节点拖拽介绍执行相关动作
this.closeStepModal();
return draggingNode.clone({ keepId: true });
}
});
- 监听节点/边相关事件
// 监听节点点击事件
graph.on('node:click', ({ e, x, y, node, view }: any) => {
node.attr({
body: {
stroke: '#13c1c2',
strokeDasharray: '5, 1',
},
});
});
// 监听节点移动事件
graph.on('node:moved', ({ node }) => {
this.props.setEditting(true);
});
// @ts-ignore
graph.on('node:delete', ({ view, e }) => {
e.stopPropagation();
console.log('节点删除');
});
// 监听节点被删除事件
graph.on('node:removed', ({ node, index, options }) => {
this.props.setEditting(true);
});
// 监听边被删除事件
graph.on('edge:removed', ({ edge, index, options }) => {
console.log('edge', edge);
this.props.setEditting(true);
});
// 鼠标移入节点时添加删除图标
graph.on('node:mouseenter', ({ node }) => {
node.addTools([
{
name: 'button-remove',
args: { x: 163, y: 5 },
},
]);
});
// 监听鼠标离开节点事件
graph.on('node:mouseleave', ({ node }) => {
if (node.hasTool('button-remove')) {
node.removeTool('button-remove');
}
node.attr({
body: {
stroke: '#13c1c2',
strokeDasharray: '0',
},
});
});
// 鼠标移入边时添加删除图标
graph.on('edge:mouseenter', ({ cell }) => {
cell.addTools([
{
name: 'button-remove',
args: { distance: 15 },
},
]);
});
// 监听鼠标离开边事件
graph.on('edge:mouseleave', ({ cell }) => {
if (cell.hasTool('button-remove')) {
cell.removeTool('button-remove');
}
});
- 动态生成画布中的节点
createGraphNode = (arr: any[], graph: any) => {
// console.log('nodearr', arr);
let _this = this;
if (arr && arr.length === 0) return;
arr.forEach((item, index) => {
// console.log('item', item);
let y = 80 * (index + 1);
graph.addNode({
width: 160,
height: 30,
x: 130,
y,
tools: [
{
name: 'button',
args: {
markup: [
{
tagName: 'circle',
selector: 'button',
attrs: {
r: 8,
stroke: '#13c1c2',
'stroke-width': 2,
fill: 'white',
cursor: 'pointer',
},
},
{
tagName: 'text',
textContent: '+',
selector: 'icon',
attrs: {
fill: '#13c1c2',
'font-size': 12,
'text-anchor': 'middle',
'pointer-events': 'none',
y: '0.3em',
},
},
],
x: 163,
y: 32,
onClick({ view }: any) {
// console.log(view);
_this.closeStepModal();
_this.setState({
currentNode: view?.attr?.view?.cell?.store?.data?.attrs?.label,
});
},
},
},
{
name: 'button',
args: {
markup: [
{
tagName: 'circle',
selector: 'button',
attrs: {
r: 8,
stroke: '#13c1c2',
'stroke-width': 2,
fill: 'white',
cursor: 'pointer',
},
},
{
tagName: 'text',
textContent: '+',
selector: 'icon',
attrs: {
fill: '#13c1c2',
'font-size': 12,
'text-anchor': 'middle',
'pointer-events': 'none',
y: '0.3em',
},
},
],
x: 0,
y: 32,
onClick({ view }: any) {
_this.getLevelTimeout(view?.attr?.view?.cell?.store?.data?.attrs?.label.text);
},
},
},
],
ports: {
groups: {
// 输入链接桩群组定义
in: {
position: 'top',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#31d0c6',
strokeWidth: 2,
fill: '#fff',
},
},
},
// 输出链接桩群组定义
out: {
position: 'bottom',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#31d0c6',
strokeWidth: 2,
fill: '#fff',
},
},
},
},
items: [
{
id: `${item.nodeType}-${createEightRandom()}-1`,
group: 'in',
},
{
id: `${item.nodeType}-${createEightRandom()}-2`,
group: 'out',
},
],
},
attrs: {
label: {
text: item.nodeName,
fill: '#6a6c8a',
id: item.nodeType,
},
body: {
stroke: '#31d0c6',
strokeWidth: 2,
},
},
});
});
const { cells } = graph.toJSON();
// console.log(111, cells);
for (let i = 0; i < cells.length; i++) {
if (i < cells.length - 1) {
let isEdge: boolean = false;
arr.forEach((item) => {
if (item.nodeName === cells[i + 1].attrs.label.text) {
if (item.depends.length > 0) {
isEdge = true;
}
}
});
if (isEdge) {
graph.addEdge({
source: { cell: cells[i].id, port: cells[i].ports.items[1].id }, // 源节点和链接桩 ID
target: {
cell: cells[i + 1].id,
port: cells[i + 1].ports.items[0].id,
}, // 目标节点 ID 和链接桩 ID
attrs: {
line: {
stroke: '#A2B1C3',
strokeWidth: 2,
targetMarker: {
name: 'block',
width: 12,
height: 8,
},
},
},
});
}
}
}
};
初始化完成,从左侧列表中拖拽节点到画布中
- 监听左侧列表中节点信息
<div
data-type="rect"
data-msg={JSON.stringify(item)}
className="dnd-rect"
onMouseDown={this.startDrag}
key={item.id}
>
<script>
const startDrag = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
let _this = this;
const target = e.currentTarget;
const type = target.getAttribute('data-type');
const data: any = target.getAttribute('data-msg');
const JsonData = JSON.parse(data);
const node =
type === 'rect'
? this.graph.createNode({
width: 160,
height: 30,
ports: {
groups: {
// 输入链接桩群组定义
in: {
position: 'top',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#31d0c6',
strokeWidth: 2,
fill: '#fff',
},
},
},
// 输出链接桩群组定义
out: {
position: 'bottom',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#31d0c6',
strokeWidth: 2,
fill: '#fff',
},
},
},
},
items: [
{
id: `${JsonData.id}-${createEightRandom()}-1`,
group: 'in',
// onChange: () => this.connnect(e),
},
{
id: `${JsonData.id}-${createEightRandom()}-2`,
group: 'out',
},
],
},
tools: [
{
name: 'button',
args: {
markup: [
{
tagName: 'circle',
selector: 'button',
attrs: {
r: 8,
stroke: '#13c1c2',
'stroke-width': 2,
fill: 'white',
cursor: 'pointer',
},
},
{
tagName: 'text',
textContent: '+',
selector: 'icon',
attrs: {
fill: '#13c1c2',
'font-size': 12,
'text-anchor': 'middle',
'pointer-events': 'none',
y: '0.3em',
},
},
],
x: 163,
y: 32,
onClick({ view }: any) {
// console.log(view);
_this.closeStepModal();
_this.setState({
currentNode: view?.attr?.view?.cell?.store?.data?.attrs?.label,
});
},
},
},
{
name: 'button',
args: {
markup: [
{
tagName: 'circle',
selector: 'button',
attrs: {
r: 8,
stroke: '#13c1c2',
'stroke-width': 2,
fill: 'white',
cursor: 'pointer',
},
},
{
tagName: 'text',
textContent: '+',
selector: 'icon',
attrs: {
fill: '#13c1c2',
'font-size': 12,
'text-anchor': 'middle',
'pointer-events': 'none',
y: '0.3em',
},
},
],
x: 0,
y: 32,
onClick({ view }: any) {
_this.getLevelTimeout(view?.attr?.view?.cell?.store?.data?.attrs?.label.text);
},
},
},
],
attrs: {
label: {
text: `${JsonData.name}-${createEightRandom()}`,
fill: '#6a6c8a',
id: JsonData.id,
},
body: {
stroke: '#31d0c6',
strokeWidth: 2,
},
},
})
: this.graph.createNode({
width: 60,
height: 60,
shape: 'html',
html: () => {
const wrap = document.createElement('div');
wrap.style.width = '100%';
wrap.style.height = '100%';
wrap.style.display = 'flex';
wrap.style.alignItems = 'center';
wrap.style.justifyContent = 'center';
wrap.style.border = '2px solid rgb(49, 208, 198)';
wrap.style.background = '#fff';
wrap.style.borderRadius = '100%';
wrap.innerText = 'Circle';
return wrap;
},
});
this.dnd.start(node, e.nativeEvent as any);
};
</script>
到此整个流程图就基本渲染结束了,最后贴上最后的流程图
最后总结下整个流程:
- 页面初始化节点:渲染画布,并设置相关属性 --> 获取数据-->动态创建节点,创建链接桩,监听画布中节点事件--> 根据节点依赖绘制节点连接线。
- 节点拖拽阶段:监听节点开始移动,并在节点拖拽结束在画布中生成相关节点,并设置节点的相关信息(节点的工具列表,节点连接桩等)。