最近公司有这样的业务,要实现一个类似流程图的编辑器,可以拖拉拽之类的,网上寻找了一番,最终决定使用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><divclass="visual_container"><divclass="toolbar"><el-tooltipclass="item"effect="dark"content="清空画布"placement="top-start"><el-buttontype="danger"icon="el-icon-delete"circle@click="clearCanvas"/></el-tooltip><el-tooltipclass="item"effect="dark"content="全屏"placement="top-start"><el-buttonicon="el-icon-full-screen"circle@click="fullscreenHandler"/></el-tooltip><el-tooltipclass="item"effect="dark"content="回退一步"placement="top-start"><el-buttonicon="el-icon-refresh-left"circle:disabled="isUndo"@click="undoHandler"/></el-tooltip><el-tooltipclass="item"effect="dark"content="前进一步"placement="top-start"><el-buttonicon="el-icon-refresh-right"circle:disabled="isCando"@click="candoHandler"/></el-tooltip><el-tooltipclass="item"effect="dark"content="暂存当前画布"placement="top-start"><el-buttonicon="el-icon-paperclip"circle@click="cacheCanvas"/></el-tooltip><el-tooltipclass="item"effect="dark"content="导出为图片"placement="top-start"><el-buttonicon="el-icon-camera"circle@click="exportCanvasToPng"/></el-tooltip><el-tooltipclass="item"effect="dark"content="上传当前配置至服务器"placement="top-start"><el-buttonicon="el-icon-upload"circle@click="saveHandler"/></el-tooltip></div><divid="toolbox"ref="toolBoxRef"><divclass="row"><divclass="row_label">输送线图形</div><divclass="row_content"><divv-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><divclass="row"><divclass="row_label">基本图形</div><divclass="row_content"><divv-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><divclass="row"><divclass="row_label">个性化图形</div><divclass="row_content"><divv-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><divid="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'exportdefault{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 =newGraph({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){returnfalse}returntrue}},grid:{visible:true,type:'doubleMesh',args:[{color:'#eee',// 主网格线颜色thickness:1// 主网格线宽度},{color:'#ddd',// 次网格线颜色thickness:1,// 次网格线宽度factor:4// 主次网格线间隔}]}})},// 初始化插件initPluging(){// 对齐线this.graph.use(newSnapline({enabled:true}))// 框选this.graph.use(newSelection({enabled:true,showNodeSelectionBox:false,rubberband:true,// 是否启用移动框选,这个会和拉动画布冲突eventTypes:['mouseWheelDown']}))// 复制粘贴this.graph.use(newClipboard({enabled:true}))// 快捷键this.graph.use(newKeyboard({enabled:true}))// 绑定cv键this.graph.bindKey('ctrl+c',()=>{const cells =this.graph.getSelectedCells()if(cells.length){this.graph.copy(cells)}returnfalse})this.graph.bindKey('ctrl+v',()=>{if(!this.graph.isClipboardEmpty()){const cells =this.graph.paste({offset:32})this.graph.cleanSelection()this.graph.select(cells)}returnfalse})// 导出功能this.graph.use(newExport())// 历史记录this.graph.use(newHistory({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:newDate().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 =nullthis.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的
版权归原作者 廖子默 所有, 如有侵权,请联系我们删除。