使用Vue+Antv-X6实现一个输送线可视化编辑器(支持拖拽、自定义连线、自定义节点等)

最近公司有这样的业务,要实现一个类似流程图的编辑器,可以拖拉拽之类的,网上寻找了一番,最终决定使用Antv-X6这个图形引擎,非常强大,文档多看几遍也就能上手使用了。感觉还不错就写个使用心得期望能帮助到同样需要的猿猿吧

Antv-X6文档地址

Antv-X6国内官网

不多废话,上实现效果

  • 支持拖拽放置图案,支持连线
  • 支持编辑修改图案label
  • 支持修改连线的label(这个label支持自定义改变图形宽度,就是label长短会自动改变图案宽度)
  • 支持点击删除图案
  • 支持点击删除路径
  • 支持CV复制粘贴
  • 支持更多操作、例如清空画布、上一步、下一步历史操作、导出图片

项目环境介绍

技术栈

  • vue: 2.6.13
  • element-ui: 2.13.0

X6各个依赖版本如下

javascript 复制代码
 "@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代码

html 复制代码
<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代码

javascript 复制代码
<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代码

css 复制代码
<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文件中

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的

相关推荐
航Hang*16 分钟前
前端项目2-01:个人简介页面
前端·经验分享·html·css3·html5·webstorm
MaisieKim_29 分钟前
python与nodejs哪个性能高
前端·python·node.js
王光环1 小时前
vscode与keil的乱码不兼容问题
ide·vscode·编辑器
萑澈1 小时前
迁移 Visual Studio Code 设置和扩展到 VSCodium
ide·vscode·编辑器
水煮白菜王1 小时前
深入理解 Webpack 核心机制与编译流程
前端·webpack·node.js
计算机学姐1 小时前
基于SpringBoot的小区停车位管理系统
java·vue.js·spring boot·后端·mysql·spring·maven
梦幻通灵1 小时前
Excel分组计算求和的两种实现方案
前端·excel
geovindu1 小时前
vue3: pdf.js 3.4.120 using javascript
开发语言·javascript·vue.js·pdf
编程乐趣1 小时前
一个用C#开发的记事本Notepads开源编辑器
c#·编辑器·.net
whatever who cares2 小时前
CSS3 伪类和使用场景
前端·css·css3