Skip to content

01-开篇

开始

一个图可以简单的解释一个复杂的思想,同样,一个流程图可以清晰的可视化展示组织、工作流等复杂层次结构。在后面的一系列文章中,将会从 0 到 1 完成一个流程图应用的开发。如果对你的工作有帮助或者你对图编辑引擎感兴趣,请静下心来读完这一系列文章,相信会有惊喜。

技术选型

提到绘图能力,web主要有两种实现方式:Canvas 和 SVG,我们看看这两种有什么差异:

方案图形定制能力上手难度性能
Canvas定制图形比较复杂较高大数据量场景性能突出
SVG基于 DOM,图形定制能力强较低大数据量场景性能较差

再来看看前端社区开源解决方案:

jTopo

简介:jTopo 是一款完全基于HTML5 Canvas的关系、拓扑图形化界面开发工具包 官网:http://www.jtopo.com/ 优点:国产,文档简单,性能优越 缺点:没有开放源代码,2015 年已经停止更新,在 Vue/React/Angular 等现代框架中使用成本高

Vis.js

简介:Vis.js 是基于 HTML5 Canvas 开发的动态可视化库。该库被设计为易于使用,处理大量的动态数据 官网:https://visjs.org/ 优点:开源免费,性能优越,功能比较丰富 缺点:英文文档,上手成本较高,节点定制能力较弱

LogicFlow

简介:专注流程可视化的前端解决方案 官网:http://logic-flow.org/ 优点:具备流程图应用的常用功能,扩展性强 缺点:内置连线功能较弱,定制复杂节点比较麻烦

Jsplumb

简介:jsPlumb 是一个比较强大的绘图组件 官网:https://jsplumbtoolkit.com/ 优点:功能丰富,支持各种自定义操作 缺点:分收费版和社区版,社区版功能较弱

Joint

简介:JointJS 是一个开源前端框架,支持绘制各种各样的流程图、工作流图等。Rappid 是 Joint 的商业版,提供了一些更强的插件 官网:https://www.jointjs.com/ 优点:功能十分完善,上手比较容易 缺点:布局能力较弱,节点定制能力一般

AntV G6

简介:G6 是一个图可视化引擎。它提供了图的绘制、布局、分析、交互、动画等图可视化的基础能力 官网:https://g6.antv.vision/zh/ 优点:功能完备,扩展性强,性能好,支持大量布局算法 缺点:节点定制能力一般

AntV X6

简介:X6 是 AntV 旗下的图编辑引擎,提供了一系列开箱即用的交互组件和简单易用的节点定制能力,方便我们快速搭建 DAG 图、ER 图、流程图等应用 官网:https://x6.antv.vision/zh 优点:节点极易定制,支持 React/Vue 节点,上手成本低,支持大量布局算法 缺点:基于 SVG,性能一般

最后

图形定制能力强、上手成本低是我们需要重点考虑的地方,另外一方面流程图场景,不需要渲染大量的节点,所以 X6 更适合来完成我们的流程图应用。

  1. 源码:传送门

  2. 记得给 X6 仓库加星

02-画布篇

开始

下面我们开始实现流程图的画布功能,从两个方面着手,第一个是画布的样式,包括网格和背景,第二个是画布的操作,包括平移和缩放。首先来看看两个经典的流程图应用 DrawioBPMN editor

img

img

从上图我们可以看到两种不同的画布,带滚动条的和不带滚动条的,两种类型画布都有各自的优缺点,比如带滚动条的画布可以根据滚动条位置清晰的了解当前画面处于整个画布的位置,但是滚动条在 windows 下样式不够美观。不带滚动条的画布也能实现和滚动条画布一样的无限拖拽、缩放等效果,但是如果画布上图形较分散,容易丢失视野,比较难以查找。

X6 同时支持上面两种类型画布,在这里我们还是看重画布的颜值,所以选择不带滚动条的,如果需要实现带滚动条的画布,可以参考这里

实现

初始化

首先进入安装步骤,如果再 Vue/React/Angular 等项目中使用,可以使用 npm 或者 yarn 进行安装,如果使用 script 标签引入,可以使用 CDN 地址。

bash
# npm
$ npm install @antv/x6 --save

# yarn
$ yarn add @antv/x6

# cdn
# <script href="https://unpkg.com/@antv/x6/dist/x6.js"></script>

然后我们在页面上创建一个容纳画布的容器:

html
<div id="container"></div>

接下来我们就可以初始化一个画布了:

javascript
new Graph({
  container: document.getElementById('container'),
  width: 800,
  height: 800,
})

网格与背景

X6 可以在 Grpah 中全局配置网格形态与样式,例如下面配置了双线条网格,主网格尺寸为 10px * 10px,主网格线条颜色为 #E7E8EA,宽度为 1px,次网格线条颜色为 #CBCED3,宽度为 1px,次网格线条之间间隔 4 个主网格。同样可以在 Graph 中全局配置画布的背景颜色和背景图片,如果需要配置,可以参考官网

javascript
new Graph({
  grid: {
    size: 10,
    visible: true,
    type: 'doubleMesh',
    args: [
      {
        color: '#E7E8EA',
        thickness: 1,
      },
      {
        color: '#CBCED3',
        thickness: 1,
        factor: 4,
      },
    ],
  },
})

画布呈现的样式如下图:

img

平移与缩放

画布的拖拽平移与滚轮缩放是高频操作,是画布需要具备的基本功能。首先来看拖拽平移,基本用法:

javascript
new Graph({
  // 等价于 panning: true
  panning: {
    enabled: true,
  }
})

这样在按下鼠标左键,移动鼠标就可以拖拽画布,有些用户习惯用右键或者触摸板来进行画布的平移操作,X6 也是支持的,其中左键移动和右键移动有一个小小的区别:在图形上按下左键不会触发画布平移,但是右键可以。

javascript
new Graph({
  panning: {
    enabled: true,
    eventTypes: ['leftMouseDown', 'rightMouseDown', 'mouseWheel']
  }
})

在 Graph 上配置 mousewheel 可以实现画布缩放功能,基本用法:

javascript
new Graph({
  // 等价于 mousewheel: true
  mousewheel: {
    enabled: true,
  }
})

实验后发现三个问题:

  1. 缩放和平移冲突,滚动滚轮或者滑动触摸板的时候,画布既会缩放,同时也会平移
  2. 画布总是按照画布中心点进行缩放,想要的是按照鼠标位置进行缩放
  3. 没法控制缩放的最小和最大级别

阅读官网文档后发现,这些问题 X6 都有考虑到,可以用以下方式来解决:

  1. 设置修饰键 modifiers 为 ctrl,这样在触摸板上使用双指缩放或者按下 Ctrl 键再滚动鼠标才会触发画布缩放,就不会和拖拽平移冲突
  2. 设置 zoomAtMousePosition 为 true,这样画布会以鼠标位置为中心点进行缩放
  3. 设置 minScale 和 maxScale 可以控制画布可缩放的最小和最大级别

最终的配置和效果如下:

javascript
new Graph({
  // 等价于 mousewheel: true
  mousewheel: {
    enabled: true,
    zoomAtMousePosition: true,
    modifiers: 'ctrl',
    minScale: 0.5,
    maxScale: 3,
  }
})

img

最后

从上面实现的过程来看,X6 不仅有完备的功能,而且在每个功能上考虑得很细致,通过组合一些配置项就能完成基本的功能,体现了 X6 开箱即用的特点。

  1. 源码:传送门
  2. 记得给 X6 仓库加星

03-图形篇

开始

在流程图中我们需要通过拖拽交互往画布中添加节点,X6 不仅内置了强大的拖拽功能,还内置了很多常用的图形,接下来我们一起实现基础的流程图图形以及图形拖拽功能。

实现

图形定义

首先来看下一个简单的矩形节点的基础配置:

javascript
graph.addNode({
  shape: 'rect',
  x: 100,
  y: 100,
  width: 80,
  height: 40,
  attrs: {
    body: {
      stroke: 'red'
    }
  }
})

img

shape:定义图形的形状,X6 内置了 rect、circle、ellipse、polygon、polyline、image、html 等基础形状 x/y:定义图形的左上角坐标 width/height:定义图形的尺寸 看到 attrs 大家可能比较奇怪,这是什么东西?其实可以将 attrs 看做 css 样式集合,其中 body 类似于 css 选择器,body 的值是被选中元素的属性。那 body 又是哪来的呢?这里就要说的另一个重要的配置项markup,markup 表示的是图形的 DOM 结构,内置的 rect 的默认 markup 为:

javascript
[
  {
    tagName: 'rect',
    selector: 'body',
  },
  {
    tagName: 'text',
    selector: 'label',
  },
]

渲染完成后,实际生效的 DOM 为:

html
<g data-cell-id="ca715562-8faf-4c88-a242-2b18d4ce47a6" data-shape="rect" class="x6-cell x6-node" transform="translate(100,100)">
  <rect fill="#ffffff" stroke="red" stroke-width="2" width="80" height="40"></rect>
  <text font-size="14" fill="#000000" text-anchor="middle" text-vertical-anchor="middle" font-family="Arial, helvetica, sans-serif" transform="matrix(1,0,0,1,40,20)"></text>
</g>

所以说一个图形是由 markup 和 attrs 来决定结构和样式,我们可以通过设置 markup 和 attrs 来定义自己业务中的图形,看下面的例子:

javascript
graph.addNode({
  shape: 'rect',
  x: 60,
  y: 60,
  width: 70,
  height: 70,
  markup: [
    {
      tagName: 'rect',
      selector: 'r1'
    },
    {
      tagName: 'circle',
      selector: 'c1'
    },
    {
      tagName: 'circle',
      selector: 'c2'
    },
    {
      tagName: 'circle',
      selector: 'c3'
    },
    {
      tagName: 'circle',
      selector: 'c4'
    }
  ],
  attrs: {
    r1: {
      width: 70,
      height: 70,
      stroke: '#ccc',
      rx: 12,
      ry: 12,
    },
    c1: {
      r: 10,
      cx: 20,
      cy: 20,
      fill: '#000'
    },
    c2: {
      r: 10,
      cx: 50,
      cy: 20,
      fill: '#000'
    },
    c3: {
      r: 10,
      cx: 20,
      cy: 50,
      fill: '#000'
    },
    c4: {
      r: 10,
      cx: 50,
      cy: 50,
      fill: '#000'
    },
  }
})

img

上面可以看到定义图形的时候,属性是固定写死的,在业务场景中,经常需要动态修改节点的样式,X6 中也提供了非常便利的方法:

javascript
const node = graph.addNode({
  shape: 'rect',
  x: 100,
  y: 100,
  width: 80,
  height: 40,
  attrs: {
    body: {
      stroke: 'red'
    }
  }
})
node.attr('body/stroke', 'green')
node.attr('body/fill', 'yellow')

我们的图形将会变成下面这样:

img

那么问题来了,如果多个图形的结构和样式相似度很高,每次定义图形都要写很多类似的代码,有没有一种方式可以将图形之间的公共属性抽象出来呢?X6 提供了很优雅的方式来解决这个问题,首先注册自定义的节点类型,在这里配置公共的属性,然后在添加节点的时候指定 shape 值为刚才注册的节点类型。

javascript
Graph.registerNode('custom-rect', {
  inherit: 'rect', // 继承自 Shape.Rect
  width: 300, // 默认宽度
  height: 40, // 默认高度
  attrs: {
    body: {
      rx: 10, // 圆角矩形
      ry: 10,
      strokeWidth: 1,
      fill: '#5755a1',
      stroke: '#5755a1',
    },
    label: {
      fill: '#fff',
      fontSize: 18,
      refX: 10, // x 轴偏移,类似 css 中的 margin-left
      textAnchor: 'left', // 左对齐
    }
  },
})

graph.addNode({
  shape: 'custom-rect',
  x: 50,
  y: 50,
  width: 100,
  height: 50,
  label: 'rect1'
})
graph.addNode({
  shape: 'custom-rect',
  x: 200,
  y: 50,
  width: 100,
  height: 50,
  label: 'rect2',
  attrs: {
    body: {
      fill: '#ccc'
    }
  }
})

img

图形拖拽

图形定义好了,接下来我们要实现图形拖拽功能,X6 提供了 Dnd 插件来提供基础的拖拽能力,并在 Dnd 基础上的进一步封装,提供了一个类似侧边栏的 UI 组件 Stencil,支持分组、折叠、搜索等能力。 首先提供一个 Stencil 的容器:

html
<!-- stencil 容器需要设置 position:relative 的样式 -->
<div id="stencil" style="position:relative"></div>

然后进行初始化(具体配置见官网):

javascript
const stencil = new Addon.Stencil({
  title: 'Flowchart',
  target: this.graph,
  stencilGraphWidth: 214,
  stencilGraphHeight: document.body.offsetHeight - 105,
  layoutOptions: {
    columns: 4,
    columnWidth: 48,
    rowHeight: 30,
    marginY: 30,
  },
})
const stencilContainer = document.querySelector('#stencil')
if (stencilContainer) {
  stencilContainer.appendChild(stencil.container)
}

然后将我们定义的图形加载到 stencil 中:

javascript
const r1 = graph.createNode({
  shape: 'rect',
  width: 30,
  height: 15,
})
stencil.load([r1])

img

当我们将图形拖拽到画布的时候,想将 Stencil 中的图形等比例放大。查看官网,拖拽的流程为:

  1. 拖拽过程中可以在 getDragNode 中返回新的节点来自定义拖拽节点样式
  2. 拖拽结束可以在 getDropNode 中返回新的节点来自定义放置在画布中节点样式

下面的代码就是将拖拽节点等比例放大 3 倍后放置到画布中:

javascript
const stencil = new Addon.Stencil({
  getDropNode(node) {
    const size = node.size()
    return node.clone().size(size.width * 3, size.height * 3)
  }
})

最终效果:

img

最后

X6 不仅支持上文提到的基础的 SVG 节点,还具备在节点中渲染 React、Vue 组件的能力。在实际业务场景中,如果对 SVG 不熟悉或者节点内容复杂,我们可以根据技术栈选择 React/Vue 渲染,这样在节点内部,我们可以用熟悉的方式绘制各种复杂的内容,可谓为所欲为。

  1. 源码:传送门
  2. 记得给 X6 仓库加星

04-连线篇

开始

连线是 X6 中非常重要的一部分,X6 内置了很多实用的连线功能,也提供了优雅的扩展机制 ,这是相比于其他流程图框架占据绝对优势的地方。本文通过实现流程图的连线功能来一步步介绍 X6 的连线机制。

实现

图形定义

X6 中连线分两种形式,代码生成的和用户手动拖拽而成,首先来看怎么通过代码建立连线:

javascript
// source 或 target 是坐标点
graph.addEdge({
  source: [0, 0],
  target: [100, 100]
})


// source 或 target 是节点对象
graph.addEdge({
  source: sourceNode,
  target: targetNode,
})

// source 或 target 是节点 ID
graph.addEdge({
  source: 'sourceId',
  target: 'targetId',
})

// source 或 target 是连接桩
graph.addEdge({
  source: { cell: 'cellId1', port: 'portId1' },
  target: { cell: 'cellId2', port: 'portId2' }
})

如果想通过手动操作来创建连线,需要有两个条件:

  1. 需要从具有 magnet: true 属性的元素上才能手动拖拽出连线
  2. 需要在全局 connecting 配置中自定义 createEdge 方法
javascript
import { Graph, Shape } from '@antv/x6'

const graph = new Graph({
  connecting: {
    createEdge() {
      return new Shape.Edge()
    },
  },
})
graph.addNode({
  shape: 'rect',
  x: 100,
  y: 100,
  width: 80,
  height: 40,
  attrs: {
    body: {
      stroke: 'red',
      magnet: true
    }
  }
})
graph.addNode({
  shape: 'rect',
  x: 400,
  y: 100,
  width: 80,
  height: 40,
  attrs: {
    body: {
      stroke: 'red',
      magnet: true
    }
  }
})

img

默认的连线样式并不好看,我们经常需要自己来定义连线的样式,其实连线和图形都是由 markup 和 attrs 来定义的,默认连线的 markup 为:

javascript
[
  {
    tagName: 'path',
    selector: 'wrap',
    groupSelector: 'lines',
    attrs: {
      fill: 'none',
      cursor: 'pointer',
      stroke: 'transparent',
      strokeLinecap: 'round',
    },
  },
  {
    tagName: 'path',
    selector: 'line',
    groupSelector: 'lines',
    attrs: {
      fill: 'none',
      pointerEvents: 'none',
    },
  },
]

其中 line 为实际展示的连线,wrap 是为了方便响应交互的占位元素。一般场景我们不需要去自定义连线的 markup,但是我们经常需要配置 attrs 来定制连线样式:

javascript
graph.addEdge({
  source: 'sourceId',
  target: 'targetId',
  attrs: {
    line: {
      stroke: '#000',  // 连线颜色
      strokeWidth: 1,  // 连线宽度
    },
  },
})

img

连线的箭头也是非常容易配置,比如我们想将上面箭头修改为实心箭头,并且显示的修长一点儿:

javascript
graph.addEdge({
  source: 'sourceId',
  target: 'targetId',
  attrs: {
    line: {
      stroke: '#000',
      strokeWidth: 1,
      targetMarker: {
        name: 'block',
        width: 12,
        height: 8,
      }
    },
  },
})

img

和节点类似,动态修改连线的属性方式如下:

javascript
const edge = graph.addEdge({
  source: 'sourceId',
  target: 'targetId',
  attrs: {
    line: {
      stroke: '#000',
      strokeWidth: 1,
    },
  },
})

edge.attr('line/stroke', '#ccc')

同样,连线也提供了注册自定义连线的方式来解决重复定义问题:

javascript
Graph.registerEdge('custom-edge', {
  inherit: 'edge',
  attrs: {
    line: {
      strokeWidth: 1,
      stroke: '#5755a1',
      targetMarker: {
        name: 'path',
        d: 'M5.5,15.499,15.8,21.447,15.8,15.846,25.5,21.447,25.5,9.552,15.8,15.152,15.8,9.552z',
      }
    },
  },
})

graph.addEdge({
  shape: 'custom-edge',
  source: [50, 50],
  target: [300, 50],
})

graph.addEdge({
  shape: 'custom-edge',
  source: [50, 150],
  target: [300, 150],
  attrs: {
    line: {
      stroke: '#31d0c6'
    }
  }
})

img

路由和连接器

在每一种图场景中,连线形态有所不同,比如流程图和思维导图的连线样式:

img

img

流程图的连线是横平竖直的,思维导图的连线是一种树状,X6 提供了路由和连接器两个概念来实现不同形态的连线。路由就是在原有连线基础上增加一些固定点,让连线符合某种规则,比如 orth 路由,增加固定点后,边的每一条线段都是水平或垂直的正交线段。连接器就是将连线所有点经过一定规则加工后,产生一定形状的连线,比如 smooth 连接器,是用三次贝塞尔曲线线连接起点、路由点和终点。

在我们的流程图中,需要横平竖直的连线,并且能避开重合的图形,所以选择 manhattan 路由,然后连线的拐角处需要平滑处理,这里选择 rounded 连接器。路由和连接器都可以在 connecting 配置中全局配置(对所有连线有效),也可以在一条连线中单独配置:

javascript
// 全局配置
const graph = new Graph({
  connecting: {
    router: 'manhattan',
    connector: {
      name: 'rounded',
      args: {
        radius: 8,
      }
    }
  }
})

// 单独针对一条边配置
graph.addEdge({
  source: 'sourceId',
  target: 'targetId',
  router: 'manhattan',
  connector: {
    name: 'rounded',
    args: {
      radius: 8,
    }
  }
})

img

起点和终点

连线的起点和终点是由 NodeAnchorConnectionPoint 共同确定。

  1. 起点:从第一个路径点或目标节点的中心(没有路径点时)画一条参考线到源节点的锚点,然后根据 connectionPoint 指定的交点计算方法,计算参考线与图形的交点,该交点就是边的起点。
  2. 终点:从最后一个路径点或源节点的中心(没有路径点时)画一条参考线到目标节点的锚点,然后根据 connectionPoint 指定的交点计算方法,计算参考线与图形的交点,该交点就是边的终点。

上面的描述可能比较难以理解,可以看下面的例子,图形 1 和 图形 2 的 NodeAnchor 默认在图形中心,ConnectionPoint 默认是 boundary,也就是图形边框,从锚点 1 拉出一条线来连接到锚点 2,这条线与图形边框的交点就是连线的起点和终点。

img

经常会遇到这种情况,起点和终点在连接桩的中心,那么 NodeAnchor 和 ConnectionPoint 该怎么设置呢?

img

javascript
const graph = new Graph({
  // 锚点在连接桩的中心,连接点是锚点本身
  connecting: {
    anchor: 'center',
    connectionPoint: 'anchor',
  }
})

工具

工具是渲染在节点/边上的小部件,用于增强节点/边的交互能力,现在我们需要在连线上增加一个线段工具,在边的每条线段的中心渲染一个工具条,可以拖动工具条调整线段两端的路径点的位置。

javascript
graph.addEdge({
  tools: [
    {
      name: 'segments',
      args: {
        attrs: {
          fill: '#459CDB',
        }
      }
    }
  ]
})

img

最后

连线是 X6 的核心功能之一,通过本文可以了解到连线的基础用法,我们可以完成大部分场景下连线的配置,在一些复杂场景下,还需要在连线上定制标签的需求,可以参考 edge-label

  1. 源码:传送门
  2. 记得给 X6 仓库加星

05-连接桩篇

开始

很多图形都有连接桩的概念,连接桩是节点上固定的点,常用于连线。连接桩的使用方式非常灵活,本文只介绍最佳实践。

实现

连接桩定义

首先来看一个节点四周中心各一个连接桩的节点配置:

javascript
graph.addNode({
  ports:{
    groups: {
      group1: {
        position: 'top',
        attrs: {
          circle: {
            stroke: '#D06269',
            strokeWidth: 1,
            r: 4,
            magnet: true
          }
        }
      },
      group2: {
        position: 'right',
        attrs: {
          circle: {
            stroke: '#D06269',
            strokeWidth: 1,
            r: 4,
            magnet: true
          }
        }
      },
      group3: {
        position: 'bottom',
        attrs: {
          circle: {
            stroke: '#D06269',
            strokeWidth: 1,
            r: 4,
            magnet: true
          }
        }
      },
      group4: {
        position: 'left',
        attrs: {
          circle: {
            stroke: '#D06269',
            strokeWidth: 1,
            r: 4,
            magnet: true
          }
        }
      }
    },
    items: [
      { group: 'group1' },
      { group: 'group2' },
      { group: 'group3' },
      { group: 'group4' }
    ]
  }
})

img

下面详细解释上面关键配置项:

  1. group:group 选项来设置链接桩分组,使该组中的链接桩具有相同的行为和样式,在 items 里面添加连接桩的时候,可以指定该连接桩属于哪个分组。
  2. position:每一个分组内可以配置连接桩的位置属性,位置支持绝对定位以及均匀分布定位,详细配置可参考 PortLayout
  3. attrs:这里又出现 attrs 了,那么必定有与其配对的 markup,连接桩默认的 markup 为:
javascript
{
  tagName: 'circle',
  selector: 'circle',
  attrs: {
    r: 10,
    fill: '#fff',
    stroke: '#000',
  },
}

markup 定制连接桩的结构,attrs 定制连接桩的样式,注意,只有设置 magnet: true 属性的连接桩才能手动连线。

  1. items:group 只是定义了连接桩分组,也就是 group1 、group2、group3、group4 分别长成什么样子,真正配置需要哪些连接桩的是 items 属性,items 的配置如下:
javascript
{
  id: 'id1',
  group: 'group1',
  markup: undefined,
  attrs: undefined,
}

如果 id 不设置,会自动生成唯一 id,如果手动设置,记住,必须保证全局唯一,否则会产生不可预期的错误。在 items 中配置的 markup 或 attrs 会和所属 group 中的配置深度合并。

动态修改属性

那么怎么动态修改连接桩的属性呢?在前面的节点和连线中我们知道,动态修改样式使用的是 attr 方法,那么 attr 方法的原理是什么呢? 通过阅读源码知道,attr 方法其实是一个修改节点或边中 store/data/attrs 中数据的快捷方法。

img

从上图可知,ports 相关的配置并不处于 attrs 属性内部,所以修改连接桩的配置不能使用 attr 方法,那只有使用更通用的 prop 方法, prop 可以用来修改 store/data 中的任意数据 ,例如,需要修改第一个连接桩的边框颜色为红色:

javascript
node.prop('ports/items/0/attrs/circle', { stroke: 'red' })

这么写会显得特别冗长,X6 提供了更简单一点的方法:

javascript
node.portProp('portId', 'attrs/circle', { stroke: 'red' })

显示/隐藏

在业务场景中,经常需要鼠标移入节点时显示连接桩、鼠标移出节点时隐藏连接桩的功能。这里就要涉及到 X6 的事件系统,X6 中基本上所有操作都会触发对应的事件,方便用户处理自己的逻辑,详细内容见 Events 。实现这个功能,我们可以:监听节点的鼠标移入和移出事件,然后通过样式来显示/隐藏连接桩,这就是基于 SVG 的一个非常明显的优势,我们可以使用熟悉的 DOM 操作来操作 SVG 元素。

javascript
function showPorts(ports, show) {
  for (let i = 0, len = ports.length; i < len; i = i + 1) {
    ports[i].style.visibility = show ? 'visible' : 'hidden'
  }
}

graph.on('node:mouseenter', () => {
  const ports = container.querySelectorAll('.x6-port-body')
  this.showPorts(ports, true)
})

graph.on('node:mouseleave', () => {
  const ports = container.querySelectorAll('.x6-port-body')
  this.showPorts(ports, false)
})

img

最后

基于 SVG 的图框架最大的优势在于我们可以使用熟悉的处理 HTML 元素的思维方式来处理 SVG 元素的交互,因为可以使用 DOM API 来操作 SVG 元素,我们就可以实现很多交互细节,而图编辑应用的难点往往就在于这些交互细节。

  1. 源码:传送门
  2. 记得给 X6 仓库加星

06-连接状态篇

开始

在手动连线的过程中,还需要处理一些交互细节,比如到达连接桩附近自动吸附、可连接元素的高亮显示等,幸运的是这些都不需要我们自己去实现,X6 提供了便利的配置方式帮我们解决这些问题。

实现

连接配置

当连线到达连接桩附近时,我们希望连线会自动吸附到连接桩上面,这样交互体验会更好:

javascript
const graph = new Graph({
  connecting: {
    snap: {
      radius: 20, // 离目标 20px 的时候自动吸附
    },
  }
})

现在拖动连线到空白处放开后,会生成一条终点在空白处的连线,这种连线在流程图中是无效的连线,所以希望如果终点在空白处时,该连线自动消失:

javascript
const graph = new Graph({
  connecting: {
    allowBlank: false
  }
})

img

在实际业务中还需要用户根据业务逻辑来定义连线有效性,X6 根据判断的先后顺序提供了三个方法:

方法触发时机
validateMagnet点击 magnet 元素时根据 validateMagnet 返回值判断是否新增边
validateConnection拖拽连线的过程中根据 validateConnection 返回值判断是否可以连接
validateEdge当停止拖动边的时候根据 validateEdge 返回值来判断边是否生效,如果返回 false, 该边会被清除
javascript
const graph = new Graph({
  connecting: {
    validateConnection({ targetMagnet }) {
      if (targetMagnet) {
        // 只能连接 group 为 top 的连接桩
        return targetMagnet!.getAttribute('port-group') === 'top'
      }
      return false
    }
  }
})

img

高亮配置

从上图我们可以发现,当连接桩可以被连接的时候,当连线到达它附近时,会出现高亮的效果,这个效果其实也是可以自定义的:

javascript
const graph = new Graph({
  highlighting: {
    magnetAdsorbed: {
      name: 'stroke',
      args: {
        attrs: {
          fill: '#D06269',
          stroke: '#D06269',
        },
      },
    }
  }
})

img

最后

X6 的很多功能都是从实际业务场景中沉淀出来的,在一些交互细节的地方打磨的已经足够光滑,但是还有一些不够好的地方,希望能得到大家的反馈。

  1. 源码:传送门

  2. 记得给 X6 仓库加星

07-工具篇

开始

一个开箱即用的技术框架,一定会有完备的配套工具。X6 提供了大量的图编辑功能,它们基本上都是基于配置式,而且配置参数是经过大量业务实践出来的,开发过程中充分考虑可扩展性以及兼容性,能覆盖到大部分的功能场景。

实现

节点缩放

通过拖拽修改节点的尺寸和旋转角度是常见功能,在 X6 中只需要做简单的配置:

typescript
const graph = new Graph({
  resizing: true,
  rotating: true,
})

还可以通过 css 来修改操作元素的默认样式:

css
.x6-widget-transform {
  margin: -1px 0 0 -1px;
  padding: 0px;
  border: 1px solid #239EDD;
  > div {
    border: 1px solid #239EDD;
  }
  > div:hover {
    background-color: #3DAFE4;
  }
  .x6-widget-transform-active-handle {
    background-color: #3DAFE4;
  }
}
.x6-widget-transform-resize {
  border-radius: 0;
}

img

选择

选择功能提供了快捷的方式批量移动、删除节点,是使用频次非常高的功能。

typescript
const graph = new Graph({
  selecting: {
    enabled: true,
    rubberband: true,
    showNodeSelectionBox: true
  }
})

img

对齐线

对齐线是用来帮助我们实现节点对齐的重要辅助手段:

typescript
const graph = new Graph({
  snapline: true,
})

同样也可以通过修改 css 来自定义对齐线样式:

css
.x6-widget-snapline-vertical {
  border-right-color: #239EDD;
}
.x6-widget-snapline-horizontal {
  border-bottom-color: #239EDD;
}

img

撤销重做

X6 会实时记录节点添加、删除、属性修改的变动,通过撤销操作可以返回到历史记录中的任意位置。

typescript
const graph = new Graph({
  history: true,
})

graph.addNode(...)
graph.history.undo() // 删除新增节点
graph.history.redo()  // 恢复新增节点

有时候我们希望多个操作能一次性撤销,X6 提供了批量更新的方法 batchUpdate,一次批量操作只会被记录一次。

typescript
graph.batchUpdate('rename', () => {
  rect.prop('zIndex', 10)
  rect.attr('label/text', 'hello')
  rect.attr('label/fill', '#ff0000')  
})

小地图

在无限画布场景,需要关注当前视口处于画布的什么位置并能快速定位到远处的画布元素,这时就需要小地图功能。小地图其实是画布的一个缩略图,可以通过小地图快速平移和缩放画布。

typescript
const graph = new Graph({
  minimap: {
    enabled: true,
    container: document.getElementById('minimap')!,
    width: 198,
    height: 198,
    padding: 10,
  },
})

img

最后

工欲善其事必先利其器,选择一个好的框架能极大程度上提高开发效率,X6 通过几个简单的配置提供了丰富的开箱即用的功能,对于新手来说是非常友好的。

  1. 源码:传送门

  2. 记得给 X6 仓库加星

08-快捷键篇

开始

在使用软件的过程中,我们经常会使用一些快捷键来提高效率,比如 Ctrl +C、Ctrl + V,同样,在流程图应用中,也需要一些快捷键来提高编辑效率。

实现

X6 提供了 clipboard 来实现画布上节点的复制、剪切、粘贴功能,经常和快捷键搭使用。

typescript
const graph = new Graph({
  clipboard: true,
})

// copy
graph.bindKey(['meta+c', 'ctrl+c'], () => {
  const cells = graph.getSelectedCells()
  if (cells.length) {
    graph.copy(cells)
  }
  return false
})

//cut
graph.bindKey(['meta+x', 'ctrl+x'], () => {
  const cells = graph.getSelectedCells()
  if (cells.length) {
    graph.cut(cells)
  }
  return false
})

// paste
graph.bindKey(['meta+v', 'ctrl+v'], () => {
  if (!graph.isClipboardEmpty()) {
    const cells = graph.paste({ offset: 32 })
    graph.cleanSelection()
    graph.select(cells)
  }
  return false
})

快捷键还可以搭配选择功能使用,实现常见的 Ctrl + A 全选、Backspace 删除选择元素。

typescript
// select all
graph.bindKey(['meta+a', 'ctrl+a'], () => {
  const nodes = graph.getNodes()
  if (nodes) {
    graph.select(nodes)
  }
})

//delete
graph.bindKey('backspace', () => {
  const cells = graph.getSelectedCells()
  if (cells.length) {
    graph.removeCells(cells)
  }
})

在流程图编辑过程中经常要使用撤销、恢复功能,将这两个操作和 Ctrl + Z、Ctrl + Shift + Z 绑定在一起是常见的需求。

typescript
//undo redo
graph.bindKey(['meta+z', 'ctrl+z'], () => {
  if (graph.history.canUndo()) {
    graph.history.undo()
  }
  return false
})
graph.bindKey(['meta+shift+z', 'ctrl+shift+z'], () => {
  if (graph.history.canRedo()) {
    graph.history.redo()
  }
  return false
})

最后

快捷键对于一款应用来说是非常重要的一部分,但是设计不好的快捷键会让用户抓狂,所以在设计快捷键的时候一般与传统的软件对齐,而不是自己随心所欲的设定。如果为一个全新的功能配置快捷键,可能需要好好花心思研究用户的使用习惯。

  1. 源码:传送门
  2. 记得给 X6 仓库加星

用心去做高质量的内容网站,欢迎 star ⭐ 让更多人发现