@antv/x6、@antv/x6-vue-shape X6 图编辑引擎在 vue 中实践

作者: tww844475003 分类: 前端开发 发布时间: 2021-07-21 23:30

X6 图编辑引擎

X6 是 AntV 旗下的图编辑引擎,提供了一系列开箱即用的交互组件和简单易用的节点定制能力,方便我们快速搭建 DAG 图、ER 图、流程图等应用。

说明文档:https://x6.antv.vision/zh/docs/tutorial/about

  • 主要源码
<template>
  <div class="antv-x6">
    <div class="node-c">
      <dl @mousedown="startDragToGraph('startNode', $event)">
        <dt class="circle start"></dt>
        <dd>开始节点</dd>
      </dl>
      <dl @mousedown="startDragToGraph('endNode', $event)">
        <dt class="circle end"></dt>
        <dd>结束节点</dd>
      </dl>
      <dl @mousedown="startDragToGraph('taskNode', $event)">
        <dt class="rect end"></dt>
        <dd>任务节点</dd>
      </dl>
      <dl>
        <el-button @click="getData">获取数据</el-button>
      </dl>
    </div>
    <div class="mini-map-container" ref="miniMapContainerRef"></div>
    <div class="container" ref="containerRef"></div>
    <div class="operating">
      <el-tooltip class="item" effect="light" content="撤销" placement="bottom">
        <i class="el-icon-refresh-left" :class="{opacity: !canUndo}" @click="undoFn"></i>
      </el-tooltip>
      <el-tooltip class="item" effect="light" content="重做" placement="bottom">
        <i class="el-icon-refresh-right" :class="{opacity: !canRedo}" @click="redoFn"></i>
      </el-tooltip>
      <el-tooltip class="item" effect="light" content="放大" placement="bottom">
        <i class="el-icon-zoom-in" @click="zoomInFn"></i>
      </el-tooltip>
      <el-tooltip class="item" effect="light" content="缩小" placement="bottom">
        <i class="el-icon-zoom-out" :class="{opacity: !canZoomOut}" @click="zoomOutFn"></i>
      </el-tooltip>
      <el-tooltip class="item" effect="light" content="导出PNG" placement="bottom">
        <i class="el-icon-picture-outline" @click="exportPNGFn"></i>
      </el-tooltip>
    </div>
    <right-drawer v-if="showRight" @updateVisable="updateVisableFn" :node-data="filterFn(nodeData)" :select-cell="selectCell"></right-drawer>
  </div>
</template>

<script>
import '@antv/x6-vue-shape'
import { Graph, Shape, FunctionExt, Addon, DataUri } from '@antv/x6'
import { ports } from './graph/methods'
import StartNode from './components/StartNode'
import EndNode from './components/EndNode'
import TaskNode from './components/TaskNode'
import RightDrawer from './components/RightDrawer'

export default {
  components: {
    RightDrawer
  },
  data() {
    return {
      graph: null,
      selectCell: '',
      canRedo: false,
      canUndo: false,
      canZoomOut: true,
      showRight: false,
      nodeData: [],
      nodeId: ''
    }
  },
  mounted() {
    this.initAntvX6()
  },
  methods: {
    initAntvX6() {
      const self = this
      const containerRef = this.$refs.containerRef
      const miniMapContainerRef = this.$refs.miniMapContainerRef
      const graph = new Graph({
        container: containerRef, // 画布的容器
        selecting: true,
        snapline: true, // 对齐线
        history: true, // 启动历史记录
        background: {
          color: '#ffffff'
        },
        // 网格
        grid: {
          size: 10,
          visible: true,
          type: 'doubleMesh',
          args: [
            {
              color: '#E7E8EA',
              thickness: 1
            },
            {
              color: '#CBCED3',
              thickness: 1,
              factor: 5
            }
          ]
        },
        // Scroller 使画布具备滚动、平移、居中、缩放等能力
        scroller: {
          enabled: true,
          pageVisible: true,
          pageBreak: true,
          pannable: true,
        },
        // Scroller 后可开启小地图
        minimap: {
          enabled: true,
          container: miniMapContainerRef
        },
        // 鼠标滚轮的默认行为是滚动页面
        mousewheel: {
          enabled: true,
          modifiers: ['ctrl', 'meta'],
          minScale: 0.5,
          maxScale: 2
        },
        // 节点连接
        connecting: {
          anchor: 'center',
          connectionPoint: 'anchor',
          snap: true, // 自动吸附
          allowBlank: false, // 是否允许连接到画布空白位置的点
          allowLoop: false, // 是否允许创建循环连线,即边的起始节点和终止节点为同一节点
          allowNode: false, // 是否允许边链接到节点(非节点上的链接桩)
          createEdge () {
            return new Shape.Edge({
              attrs: {
                line: {
                  stroke: '#1890ff',
                  strokeWidth: 1,
                  targetMarker: {
                    name: 'classic',
                    size: 8
                  },
                  strokeDasharray: 0, //虚线
                  style: {
                    animation: 'ant-line 30s infinite linear'
                  }
                }
              },
              label: {
                text:''
              },
              connector: 'normal',
              router: {
                name: 'manhattan'
              },
              zIndex: 0
            })
          }
        },
        // 高亮
        highlighting: {
          magnetAvailable: {
            name: 'stroke',
            args: {
              padding: 4,
              attrs: {
                strokeWidth: 4,
                stroke: '#6a6c8a'
              }
            }
          }
        }
      })
      // 注册 vue 组件
      Graph.registerVueComponent('start-node-component', {
        template: `<start-node></start-node>`,
        components: {
          StartNode
        }
      }, true)
      Graph.registerVueComponent('end-node-component', {
        template: `<end-node></end-node>`,
        components: {
          EndNode
        }
      }, true)
      Graph.registerVueComponent('task-node-component', {
        template: `<task-node :node-data="nodeData"></task-node>`,
        data() {
          return {
            nodeData: self.nodeData
          }
        },
        components: {
          TaskNode
        }
      }, true)
      
      this.graph = graph
      // 清除 history 版本
      this.graph.history.redo()
      this.graph.history.undo()
      // 监听历史版本
      this.graph.history.on('change', () => {
        this.canRedo = this.graph.history.canRedo()
        this.canUndo = this.graph.history.canUndo()
      })
      // 是否显示右则菜单
      this.graph.on('blank:click', () => {
        this.nodeId = ''
        this.showRight = false
      })
      // 节点点击
      this.graph.on('node:click', ({ node }) => {
        const data = node.store.data
        const { type, id } = data
        
        if (type === 'taskNode') {
          this.nodeId = id
          this.showRight = true
        } else {
          this.nodeId = ''
          this.showRight = false
        }
      })
      // 节点鼠标移入
      this.graph.on('node:mouseenter', FunctionExt.debounce(({ node }) => {
          // 添加连接点
          const ports = containerRef.querySelectorAll('.x6-port-body')
          this.showPorts(ports, true)

          // 添加删除
          const type = node.store.data.type
          const x = type === 'taskNode' ? 300 : 60
          node.addTools({
            name: 'button-remove',
            args: {
              x: 0,
              y: 0,
              offset: { x: x, y: 10 },
            },
          })
        }),
        500
      )
      // 节点鼠标移出
      this.graph.on('node:mouseleave', ({ node }) => {
        // 添加连接点
        const ports = containerRef.querySelectorAll('.x6-port-body')
        this.showPorts(ports, false)

        // 移除删除
        node.removeTools()
      })
      // 连接线鼠标移入
      this.graph.on('edge:mouseenter', ({ edge }) => {
        edge.addTools([
          'source-arrowhead',
          'target-arrowhead',
          {
            name: 'button-remove',
            args: {
              distance: -30,
            }
          }
        ])
      })
      // 连接线鼠标移出
      this.graph.on('edge:mouseleave', ({ edge }) => {
        edge.removeTools()
      })
      // cell 节点时才触发
      this.graph.on('node:added', ({ node }) => {
        const data = node.store.data

        if (data.type === 'taskNode') {
          const obj = {
            id: data.id,
            name: '任务节点',
            desc: '节点内容'
          }
          this.nodeData.push(obj)
        }
      })
      this.graph.on('node:removed', ({ node }) => {
        const data = node.store.data

        if (data.type === 'taskNode') {
          const posIndex = this.nodeData.findIndex((item) => item.id === data.id)
          this.nodeData.splice(posIndex, 1)
        }
      })
      this.graph.on('selection:changed', (args) => {
        args.added.forEach(cell => {
          console.log(cell)
          this.selectCell = cell
        })
      })
    },
    // 显示连线节点
    showPorts (ports, show) {
      for (let i = 0, len = ports.length; i < len; i = i + 1) {
        ports[i].style.visibility = show ? 'visible' : 'hidden'
      }
    },
    // 拖拽节点新增
    startDragToGraph(type, e) {
      const graph = this.graph
      let node = null
      this.showRight = false
      this.nodeId = ''

      // 验证 startNode endNode 是否已存在,只能添加一个
      if (['startNode', 'endNode'].includes(type)) {
        const graphData = this.graph.toJSON()
        const posIndex = graphData.cells.findIndex((item) => item.type === type)

        if (posIndex >= 0) return
      }

      switch(type) {
        case 'startNode':
          node = graph.createNode({
            type: 'startNode',
            shape: 'vue-shape',
            x: 300,
            y: 300,
            width: 60,
            height: 60,
            ports,
            component: 'start-node-component'
          })
          break
        case 'endNode':
          node = graph.createNode({
            type: 'endNode',
            shape: 'vue-shape',
            x: 300,
            y: 300,
            width: 60,
            height: 60,
            ports,
            component: 'end-node-component'
          })
          break
        case 'taskNode':
        node = graph.createNode({
          type: 'taskNode',
          shape: 'vue-shape',
          x: 300,
          y: 300,
          width: 300,
          height: 121,
          ports,
          data: {
            name: '任务节点',
            desc: '节点内容'
          },
          component: 'task-node-component'
        })
        break
      }

      const dnd = new Addon.Dnd({ target:graph })
      dnd.start(node, e)
    },
    // 获取数据
    getData() {
      console.log(this.nodeData)
      console.log(this.graph)
      console.log(this.graph.toJSON())
    },
    // 更新 RightDrawer visable
    updateVisableFn(val) {
      this.nodeData.taskId = ''
      this.showRight = val
    },
    // 过滤任务节点数据
    filterFn(data) {
      const nodeId = this.nodeId
      let result = {}
      
      if (nodeId && data.length > 0) {
        const posIndex = data.findIndex(item => item.id === nodeId)
        if (posIndex >= 0) {
          result = data[posIndex]
        }
      }

      return result
    },
    // 导出PNG
    exportPNGFn() {
      this.$nextTick(()=>{
        this.graph.toPNG((dataUri) => {
          // 下载
          DataUri.downloadDataUri(dataUri, '资产拓扑图.png')
        },{
          backgroundColor: 'white',
          padding: {
            top: 50,
            right: 50,
            bottom: 50,
            left: 50
          },
          quality: 1,
          copyStyles: false
        })
      })
    },
    // 放大
    zoomInFn() {
      this.graph.zoom(0.1)
      this.canZoomOut = true
    },
    // 缩小
    zoomOutFn() {
      if (!this.canZoomOut) return
      const Num = Number(this.graph.zoom().toFixed(1))

      if(Num > 0.1){
        this.graph.zoom(-0.1)
      } else {
        this.canZoomOut = false
      }
    },
    // 重做
    redoFn() {
      if (!this.canRedo) return
      this.graph.history.redo()
    },
    // 撤消
    undoFn() {
      if (!this.canUndo) return
      this.graph.history.undo()
    }
  }
}
</script>

<style lang="scss" scoped>
.antv-x6 {
  width: 100%;
  height: 100%;
  padding: 0;
  display: flex;
  box-sizing:border-box;
  -moz-box-sizing:border-box;
  -webkit-box-sizing:border-box;
  .node-c {
    width: 200px;
    border-right: 1px solid #eee;
    padding: 20px;
    dl {
      margin-bottom: 20px;
      line-height: 30px;
      display: flex;
      cursor: move;
      dt {
        &.circle {
          width: 30px;
          height: 30px;
          border-radius: 50%;
          &.start {
            border: 1px solid green;
            background: greenyellow;
          }
          &.end {
            border: 1px solid salmon;
            background: red;
          }
        }
        &.rect {
          width: 30px;
          height: 30px;
          border: 1px solid #ccc;
        }
      }
      dd {
        font-size: bold;
        font-size: 14px;
        padding: 0 0 0 10px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
    }
  }
  .container {
    flex: 1;
  }
  .operating {
    position: fixed;
    top: 0;
    left: 241px;
    z-index: 999;
    background-color: #ffffff;
    padding: 10px;
    box-shadow: 1px 1px 4px 0 #0a0a0a2e;
    i {
      font-size: 24px;
      cursor: pointer;
      margin: 0 10px;
      color: #515a6e;
      &:hover {
        color: #2d8cf0;
      }
      &.opacity {
        opacity: .5;
      }
    }
  }
}
.mini-map-container {
  position: fixed;
  z-index: 999;
  bottom: 20px;
  right: 20px;
  box-shadow: 0 0 10px rgba(0, 0, 0, .5);
}
</style>

新增模板保存功能

  • 保存模板
saveFn() {
  const data = this.graph.toJSON()
  const len = this.templateLists.length
  this.templateLists.push({
    name: `模板${len + 1}`,
    data
  })
}
  • 渲染模板
addTemplateNode(templateData) {
  const graph = this.graph
  const { data } = JSON.parse(JSON.stringify(templateData))
  const createLists = {}

  if (data.cells && Array.isArray(data.cells) && data.cells.length > 0) {
    // 新增节点
    const nodeList = data.cells.filter(item => item.shape === 'vue-shape')
    nodeList.forEach(item => {
      const id = item.id
      delete item.id
      delete item.zIndex
      item.ports = ports
      const node = graph.addNode(item)
      createLists[id] = { ...node }
    })
    // 新增边
    const edgeList = data.cells.filter(item => item.shape === 'edge')
    edgeList.forEach(item => {
      delete item.id
      delete item.zIndex
      item.source = {
        ...item.source,
        cell: createLists[item.source.cell].id
      }
      item.target = {
        ...item.target,
        cell: createLists[item.target.cell].id
      }
      graph.addEdge(item)
    })
  }
}

详细Github地址 :https://gitee.com/tangweiwei/vue-antvx6-app

前端开发那点事
微信公众号搜索“前端开发那点事”

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注