最近公司有这样的业务,要实现一个类似流程图的编辑器,可以拖拉拽之类的,网上寻找了一番,最终决定使用Antv-X6这个图形引擎,非常强大,文档多看几遍也就能上手使用了。感觉还不错就写个使用心得期望能帮助到同样需要的猿猿吧
Antv-X6文档地址
Antv-X6国内官网
不多废话,上实现效果
- 支持拖拽放置图案,支持连线
- 支持编辑修改图案label
- 支持修改连线的label(这个label支持自定义改变图形宽度,就是label长短会自动改变图案宽度)
- 支持点击删除图案
- 支持点击删除路径
- 支持CV复制粘贴
- 支持更多操作、例如清空画布、上一步、下一步历史操作、导出图片
项目环境介绍
技术栈
- vue: 2.6.13
- element-ui: 2.13.0
X6各个依赖版本如下
"@antv/x6": "^2.18.1", // 核心
"@antv/x6-plugin-clipboard": "^2.1.6", // 复制粘贴插件
"@antv/x6-plugin-dnd": "^2.1.1", // 拖拽插件(我没用到,测试了一下)
"@antv/x6-plugin-export": "^2.1.6", // 导出插件
"@antv/x6-plugin-history": "^2.2.4", // 历史记录插件
"@antv/x6-plugin-keyboard": "^2.2.3", // 键盘快捷键插件
"@antv/x6-plugin-selection": "^2.2.2", // 框选插件
"@antv/x6-plugin-snapline": "^2.1.7", // 对齐线插件
"@antv/x6-plugin-stencil": "^2.1.5", // 快捷工具插件(我没用到,自定义程度不高)
开始编码
整体结构
- 布局采用左右布局,左侧是拖拽源,右侧是放置图形区域
- 布局很简单的,一个外部容器设置相对定位,然后左侧容器宽300px,右侧容器动态计算宽calc(100%-300px),顶部操作栏绝对定位
template代码
<template>
<div class="visual_container">
<div class="toolbar">
<el-tooltip class="item" effect="dark" content="清空画布" placement="top-start">
<el-button type="danger" icon="el-icon-delete" circle @click="clearCanvas" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="全屏" placement="top-start">
<el-button icon="el-icon-full-screen" circle @click="fullscreenHandler" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="回退一步" placement="top-start">
<el-button icon="el-icon-refresh-left" circle :disabled="isUndo" @click="undoHandler" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="前进一步" placement="top-start">
<el-button icon="el-icon-refresh-right" circle :disabled="isCando" @click="candoHandler" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="暂存当前画布" placement="top-start">
<el-button icon="el-icon-paperclip" circle @click="cacheCanvas" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="导出为图片" placement="top-start">
<el-button icon="el-icon-camera" circle @click="exportCanvasToPng" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="上传当前配置至服务器" placement="top-start">
<el-button icon="el-icon-upload" circle @click="saveHandler" />
</el-tooltip>
</div>
<div id="toolbox" ref="toolBoxRef">
<div class="row">
<div class="row_label">输送线图形</div>
<div class="row_content">
<div
v-for="item in moduleList"
:key="item.id"
class="item"
draggable="true"
@dragend="handleDragEnd($event, item)"
>
<img :src="item.icon" alt="" srcset="" />
<span>{{ item.name }}</span>
</div>
</div>
</div>
<div class="row">
<div class="row_label">基本图形</div>
<div class="row_content">
<div
v-for="item in moduleList2"
:key="item.id"
class="item"
draggable="true"
@dragend="handleDragEnd($event, item)"
>
<img :src="item.icon" alt="" srcset="" />
<span>{{ item.name }}</span>
</div>
</div>
</div>
<div class="row">
<div class="row_label">个性化图形</div>
<div class="row_content">
<div
v-for="item in moduleList3"
:key="item.id"
class="item"
draggable="true"
@dragend="handleDragEnd($event, item)"
>
<img :src="item.icon" alt="" srcset="" />
<span>{{ item.name }}</span>
</div>
</div>
</div>
</div>
<div id="container" ref="containerRef" />
<!-- <div id="attrbox">
属性栏,开发中
<br />
</div> -->
</div>
</template>
script代码
<script>
import { Graph } from '@antv/x6'
import { Snapline } from '@antv/x6-plugin-snapline'
import { Selection } from '@antv/x6-plugin-selection'
import { Clipboard } from '@antv/x6-plugin-clipboard'
import { Keyboard } from '@antv/x6-plugin-keyboard'
import { Export } from '@antv/x6-plugin-export'
import { History } from '@antv/x6-plugin-history'
import { mapGetters } from 'vuex'
import moment from 'moment'
export default {
name: 'VisualizationLines',
data() {
return {
graph: null,
curSelectNode: null, // 当前选中的节点
curSelectEdge: null, // 当前选中的边
isCando: true,
isUndo: true,
moduleList: [
{
id: 1,
name: '穿梭车',
icon: require('@/assets/images/穿梭车.png')
},
{
id: 2,
name: '堆垛机',
icon: require('@/assets/images/堆垛机.png')
},
{
id: 3,
name: '货架',
icon: require('@/assets/images/货架.png')
},
{
id: 4,
name: '托盘',
icon: require('@/assets/images/托盘.png')
},
{
id: 5,
name: '扫码枪',
icon: require('@/assets/images/扫码枪.png')
},
{
id: 6,
name: '提升机',
icon: require('@/assets/images/提升机.png')
},
{
id: 7,
name: '工人',
icon: require('@/assets/images/工人.png')
},
{
id: 8,
name: 'AGV',
icon: require('@/assets/images/AGV.png')
}
],
moduleList2: [
{
id: 1,
name: '正方形',
icon: require('@/assets/images/正方形.png')
},
{
id: 2,
name: '长方形',
icon: require('@/assets/images/长方形.png')
},
{
id: 3,
name: '圆形',
icon: require('@/assets/images/圆形.png')
},
{
id: 4,
name: '梯形',
icon: require('@/assets/images/梯形.png')
},
{
id: 5,
name: '三角形',
icon: require('@/assets/images/三角形.png')
}
],
moduleList3: [
{
id: 1,
name: '风扇',
icon: require('@/assets/images/风扇.png')
},
{
id: 2,
name: '扳手',
icon: require('@/assets/images/扳手.png')
},
{
id: 3,
name: '齿轮',
icon: require('@/assets/images/齿轮.png')
},
{
id: 4,
name: '时效',
icon: require('@/assets/images/时效.png')
},
{
id: 5,
name: '禁止',
icon: require('@/assets/images/禁止.png')
},
{
id: 6,
name: '易碎品',
icon: require('@/assets/images/易碎品.png')
},
{
id: 7,
name: '防水',
icon: require('@/assets/images/防水.png')
},
{
id: 8,
name: '火焰',
icon: require('@/assets/images/火焰.png')
},
{
id: 9,
name: '叉车',
icon: require('@/assets/images/叉车.png')
},
{
id: 10,
name: '手机',
icon: require('@/assets/images/手机.png')
},
{
id: 11,
name: '电池',
icon: require('@/assets/images/电池.png')
}
],
cacheKey: 'X6_GRAPH_CACHE'
}
},
computed: {
...mapGetters(['sidebar'])
},
mounted() {
// 初始化graph实例以及一些配置
this.initGraph()
// 初始化对应的一些插件
this.initPluging()
// 注册事件
this.initEvent()
// 如果本地存在值那么直接读取本地的内容进行回显
const cache = localStorage.getItem(this.cacheKey)
if (cache && this.graph) {
this.graph.fromJSON(JSON.parse(cache))
}
},
methods: {
initGraph() {
// 自定义边的样式并注册
Graph.registerEdge(
'dag-edge',
{
inherit: 'edge',
connector: { name: 'smooth' },
attrs: {
line: {
stroke: '#5F95FF',
strokeDasharray: 5,
strokeWidth: 3,
targetMarker: 'classic', // 经典箭头样式
// 动画效果
style: {
animation: 'ant-line 30s infinite linear'
}
}
}
},
true
)
this.graph = new Graph({
container: document.getElementById('container'),
autoResize: true, // 自适应布局
background: {
color: '#F2F7FA'
},
panning: true, // 允许拖拽画面
mousewheel: true, // 允许缩放
snapline: true, // 对齐线
// 配置连线规则
connecting: {
snap: true, // 自动吸附
allowBlank: false, // 是否允许连接到画布空白位置的点
allowMulti: true, // 是否允许在相同的起始节点和终止之间创建多条边
allowLoop: true, // 是否允许创建循环连线,即边的起始节点和终止节点为同一节点
highlight: true, // 拖动边时,是否高亮显示所有可用的节点
highlighting: {
magnetAdsorbed: {
name: 'stroke',
args: {
attrs: {
fill: '#5F95FF',
stroke: '#5F95FF'
}
}
}
},
createEdge: () =>
this.graph.createEdge({
shape: 'dag-edge',
attrs: {
line: {
strokeDasharray: '5 5'
}
},
zIndex: -1
}),
validateConnection: ({ sourceMagnet, targetMagnet }) => {
const sourceParentId = sourceMagnet && sourceMagnet.parentNode.parentNode.getAttribute('data-cell-id')
const targetParentId = targetMagnet && targetMagnet.parentNode.parentNode.getAttribute('data-cell-id')
if (sourceParentId === targetParentId) {
return false
}
return true
}
},
grid: {
visible: true,
type: 'doubleMesh',
args: [
{
color: '#eee', // 主网格线颜色
thickness: 1 // 主网格线宽度
},
{
color: '#ddd', // 次网格线颜色
thickness: 1, // 次网格线宽度
factor: 4 // 主次网格线间隔
}
]
}
})
},
// 初始化插件
initPluging() {
// 对齐线
this.graph.use(
new Snapline({
enabled: true
})
)
// 框选
this.graph.use(
new Selection({
enabled: true,
showNodeSelectionBox: false,
rubberband: true, // 是否启用移动框选,这个会和拉动画布冲突
eventTypes: ['mouseWheelDown']
})
)
// 复制粘贴
this.graph.use(
new Clipboard({
enabled: true
})
)
// 快捷键
this.graph.use(
new Keyboard({
enabled: true
})
)
// 绑定cv键
this.graph.bindKey('ctrl+c', () => {
const cells = this.graph.getSelectedCells()
if (cells.length) {
this.graph.copy(cells)
}
return false
})
this.graph.bindKey('ctrl+v', () => {
if (!this.graph.isClipboardEmpty()) {
const cells = this.graph.paste({ offset: 32 })
this.graph.cleanSelection()
this.graph.select(cells)
}
return false
})
// 导出功能
this.graph.use(new Export())
// 历史记录
this.graph.use(
new History({
enabled: true
})
)
},
handleDragEnd(e, item) {
console.log(e, item)
// TODO:这里还要判断左侧导航是否折叠,如果是那还要动态计算一次
this.graph.addNode({
x: this.sidebar.opened ? e.pageX - 300 - 260 : e.pageX - 300,
y: e.pageY - 100,
id: new Date().getTime(),
width: 200,
height: 60,
attrs: {
body: {
stroke: '#5F95FF',
strokeWidth: 1,
strokeDasharray: 5,
fill: 'rgba(95,149,255,0.05)',
refWidth: 1,
refHeight: 1
},
image: {
'xlink:href': require(`@/assets/images/${item.name}.png`),
width: 60,
height: 60,
x: 10,
y: 0
},
title: {
text: item.name,
refX: 80,
refY: 30,
fill: 'rgba(0,0,0,0.85)',
fontSize: 20,
fontWeight: 600,
'text-anchor': 'start'
}
},
// 连接桩配置
ports: {
groups: {
// 上顶点
top: {
position: 'top'
},
// 右顶点
right: {
position: 'right'
},
// 下顶点
bottom: {
position: 'bottom'
},
// 左顶点
left: {
position: 'left'
}
},
items: [
{
group: 'top',
id: 'top',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 2
}
}
},
{
group: 'right',
id: 'right',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 2
}
}
},
{
group: 'bottom',
id: 'bottom',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 2
}
}
},
{
group: 'left',
id: 'left',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 2
}
}
}
]
},
markup: [
{
tagName: 'rect',
selector: 'body'
},
{
tagName: 'image',
selector: 'image'
},
{
tagName: 'text',
selector: 'title'
}
]
})
// this.graph.centerContent()
},
// 注册事件
initEvent() {
// 节点点击事件
this.graph.on('node:click', ({ e, x, y, node, view }) => {
// 判断是否有选中过节点
if (this.curSelectNode) {
// 移除选中状态
this.curSelectNode.removeTools()
// 判断两次选中节点是否相同
if (this.curSelectNode !== node) {
node.addTools([
{
name: 'button-remove',
args: {
x: '100%',
y: 0,
offset: {
x: 0,
y: 0
}
}
}
])
this.curSelectNode = node
} else {
this.curSelectNode = null
}
} else {
this.curSelectNode = node
node.addTools([
{
name: 'button-remove',
args: {
x: '100%',
y: 0,
offset: {
x: 0,
y: 0
}
}
}
])
}
})
// 节点双击事件
this.graph.on('node:dblclick', ({ e, x, y, node, view }) => {
// 编辑器容器父节点
const visualParentNode = document.querySelector('.visual_container')
// 创建一个文本框
const textField = document.createElement('input')
textField.type = 'text'
// 设置绝对定位,是相对于这个编辑器的父元素
textField.style.position = 'absolute'
textField.style.left = x + 200 + 'px'
textField.style.top = y + 10 + 'px'
// 给输入框添加一个类
textField.classList.add('customer_visual_input')
// 将原本的label填入输入框
textField.value = node.attrs.title.text
// 设置占位符
textField.placeholder = '请输入'
// 将内容添加到容器父节点,让他们共享坐标系
visualParentNode.appendChild(textField)
// 自动聚焦
textField.focus()
// 监听失去焦点事件
textField.addEventListener('blur', () => {
if (!textField.value) {
this.$message.error('标签名不能为空')
return
} else {
// 修改节点的label文字
node.attr('title/text', textField.value)
// 修改节点的大小,根据里面的文字自动调整
node.prop('size', {
width: textField.value.length <= 0 ? 200 : textField.value.length * 20 + 100,
height: 60
})
// 移除dom元素
visualParentNode.removeChild(textField)
}
})
})
// 边点击事件
this.graph.on('edge:click', ({ e, x, y, edge, view }) => {
if (this.curSelectEdge) {
// 移除选中状态
this.curSelectEdge.removeTools()
this.curSelectEdge = null
} else {
this.curSelectEdge = edge
edge.addTools([
{
name: 'button-remove',
args: {
x: x,
y: y,
offset: {
x: 0,
y: 0
}
}
}
])
edge.setAttrs({
line: {
stroke: '#409EFF'
}
})
edge.zIndex = 99 // 保证当前悬停的线在最上层,不会被遮挡
}
})
// 边双击
this.graph.on('edge:dblclick', ({ e, x, y, edge, view }) => {
// 编辑器容器父节点
const visualParentNode = document.querySelector('.visual_container')
// 创建一个文本框
const textField = document.createElement('input')
textField.type = 'text'
// 设置绝对定位,是相对于这个编辑器的父元素
textField.style.position = 'absolute'
textField.style.left = x + 200 + 'px'
textField.style.top = y + 10 + 'px'
// 给输入框添加一个类
textField.classList.add('customer_visual_input')
// 设置占位符
textField.placeholder = '请输入'
// 如果已经存在标签了,那么将原本的内容写入输入框
const labels = edge.getLabels()
if (labels.length > 0) {
console.log(labels[0].attrs.text.text)
textField.value = labels[0].attrs.text.text
}
// 将内容添加到容器父节点,让他们共享坐标系
visualParentNode.appendChild(textField)
// 自动聚焦
textField.focus()
// 监听失去焦点事件
textField.addEventListener('blur', () => {
if (!textField.value) {
// 如果没有输入内容那就删除
edge.removeLabelAt(0)
}
edge.appendLabel({
attrs: {
text: {
text: textField.value
}
}
})
// 移除dom元素
visualParentNode.removeChild(textField)
})
})
// 空白画布点击事件
this.graph.on('blank:click', () => {
// 移除选中元素的删除图标
this.curSelectNode && this.curSelectNode.removeTools()
this.curSelectEdge && this.curSelectEdge.removeTools()
// 同时移除选中对象
this.curSelectNode = null
this.curSelectEdge = null
})
// 历史记录变更的时候
this.graph.on('history:change', () => {
this.isCando = !this.graph.canRedo()
this.isUndo = !this.graph.canUndo()
})
},
fullscreenHandler() {
document.querySelector('#container').requestFullscreen()
},
// 清空画布内容
clearCanvas() {
if (this.graph) {
const nodes = this.graph.getNodes()
if (nodes.length <= 0) {
this.$message.error('当前画布没有任何内容')
return
}
this.$confirm('此操作将清空画布内容以及所有历史记录,无法还原, 是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
this.graph.clearCells()
localStorage.removeItem(this.cacheKey)
})
.catch(() => {
this.$message({
type: 'info',
message: '已取消删除'
})
})
}
},
// 回退
undoHandler() {
this.graph.undo()
},
// 前进
candoHandler() {
this.graph.redo()
},
// 暂存当前画布内容
cacheCanvas() {
if (this.graph) {
const nodes = this.graph.getNodes()
if (nodes.length <= 0) {
this.$message.error('当前画布没有任何内容')
return
}
const cache = this.graph.toJSON()
localStorage.setItem(this.cacheKey, JSON.stringify(cache))
this.$message.success('暂存成功,刷新浏览器或者关闭浏览器再重新打开会还原当前画布内容')
}
},
// 导出图片
exportCanvasToPng() {
if (this.graph) {
const nodes = this.graph.getNodes()
if (nodes.length <= 0) {
this.$message.error('当前画布没有任何内容')
return
}
this.graph.exportPNG(`${moment().format('YYYY-MM-DD')}画布图`, {
width: 1920,
height: 1080
})
}
},
// 上传至服务器
saveHandler() {
if (this.graph) {
const nodes = this.graph.getNodes()
if (nodes.length <= 0) {
this.$message.error('当前画布没有任何内容')
return
}
this.$message.warning('功能开发中')
}
}
}
}
</script>
style代码
<style lang="scss" scoped>
.visual_container {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
position: relative;
.toolbar {
position: absolute;
top: 0;
left: 260px;
height: 46px;
line-height: 46px;
padding-left: 10px;
width: 100%;
background-color: white;
z-index: 2001;
box-sizing: border-box;
}
#toolbox {
width: 300px;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
.row {
.row_label {
font-size: 16px;
background-color: azure;
padding: 10px;
position: sticky;
top: 0;
left: 0;
font-weight: 600;
}
.row_content {
display: flex;
row-gap: 20px;
column-gap: 28px;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
align-content: flex-start;
padding: 0 5px;
.item {
width: 60px;
height: 80px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
cursor: move;
img {
width: 100%;
height: 80px;
}
}
}
}
}
#container {
width: calc(100% - 300px);
height: 100%;
}
/* #attrbox {
width: 300px;
height: 100%;
padding: 10px;
box-sizing: border-box;
} */
}
.customer_svg {
cursor: move;
width: 60px;
height: 60px;
}
</style>
特殊CSS,里面的边动画
最好写在全局的index.css文件中
// 边动画效果
@keyframes ant-line {
to {
stroke-dashoffset: -1000;
}
}
// 输送线可视化输入框样式(这个元素是动态添加的)
.customer_visual_input {
width: 200px;
height: 40px;
outline: none;
border: 1px solid rgb(168, 198, 252);
border-radius: 3px;
&:focus {
border: 2px solid #5f95ff;
}
}
静态资源文件
全部从阿里图标库下载,大小是64*64,颜色是绿色
代码注释都非常详细,耐心看绝对能看懂并运用起来的,最主要是要熟悉官网的文档,里面多看几遍多试几次就会发现写的还挺全面的,特别是事件那一部分。另外那些演示示例里面包含的代码其实也是文档的一部分,告诉你怎么用这个x6的