手记

基于G6的流程编辑器

人人都会做系列之流程编辑器

前言

很长一段时间以来,一些诸如 BPM 啊,或者所谓的 xxx 自动化的产品,在介绍亮点的时候,其中都会有一条是可视化编辑流程图。
正好最近有个契机,就实现了一下基础功能。
预览地址
code

技术栈

  1. React + Hook + TypeScript,这个是未来至少一两年最主流的(之一)

  2. AntV 的G6(3.8.0)

  3. 基于Cra脚手架初始化的项目,基于customize-crawebpack做了一些基础配置变更,比如支持less module,支持alias这些就不赘述了。

  4. react-dnd实现从工具栏拖拽至画布

项目地址

G6 简介

G6是一个图可视化引擎,简单的说就是用来展示关系的。既然是关系,那数据中必不可少的就是nodesedges了,其中nodes用来描述节点,edges用来描述边,最基础的如下所示:

const graphData = {
  nodes: [
    {
      id: 'node-1',
      label: 'node1'
    },
    {
      id: 'node-2',
      label: 'node2'
    }
  ],
  edges: [
    {
      source: 'node-1',
      target: 'node-2',
      label: 'edge1'
    }
  ]
}

有了以上格式的数据,G6就会自动生成关系图(以“图”的形式,展示主体与关系)。

思路

流程图本质上来说,其实也就是“关系图”,每一个过程就是一个节点,过程之间的关系就是边。
有了这个认知,再基于现在的数据驱动的思路,如何基于G6生成流程图就很简单了。

  • 从工具栏拖动工具到画布上,触发增加node的事件,为图数据增加一个node
  • nodeanchorPointer开始拖拽时,触发增加edge的事件,为图数据增加一条edge
  • 当鼠标拖拽着node移动时,触发更新edge坐标事件,实时修改edge的坐标(x 和 y 的值)
  • 当鼠标松开时,判断当前鼠标位置。如果在某个 node 上,增将当前edgetarget指定为当前node,否则删除当前edge
  • 选中某个node或者edge时,获取其属性(label),当修改label值并按下保存后,将新的label的值更新至图数据
  • node或者edge上使用鼠标右键点击时,呼出contextMenu,点击删除后,删除node或者edge。需要注意的是,如果删除的是edge,直接删除即可。如果是node的话,则需要同时将起点(source)和终点(target)为该nodeedge也同时删除

具体实现

工具栏拖拽

React中拖拽组件有很多,最终选择了react-dnd,具体原因不赘述了。
引入react-dnd后,创建两个容器组件:drag-itemdrag-container,顾名思义,一个是用来包裹可拖拽对象的,另一个用来包裹接受被拖拽对象的容器。
关键就是在拖拽对象拖拽结束时,向外派发当前对象以及坐标。

// drag-item.tsx
const DragItem: FC<DragItemProps> = ({ name, children, onDragEnd }) => {
  const [{ isDragging }, dragRef] = useDrag({
    item: { name, type: 'DragItem' },
    collect: (monitor) => ({
      isDragging: monitor.isDragging()
    }),
    end: (item, monitor: DragSourceMonitor) => {
      const dropResult = monitor.getDropResult();
      if (item && dropResult) {
        onDragEnd && onDragEnd(item, dropResult.position);
      }
    }
  });
  const opacity = isDragging ? 0.4 : 1;
  return (
    <li ref={dragRef} style={{ ...style, opacity }}>
      {children}
    </li>
  );
};

拖拽完成后添加节点

G6是有一套完善的坐标体系的:G6 坐标系深度解析
提供了将浏览器坐标转换为画布坐标的 API,但是后期在实现将内容居中显示时遇到了问题,本来以为直接使用浏览器坐标可以解决,就又用回了浏览器坐标,结果发现还是有问题。(在初始化画布时,如果使用了自动居中,画布的坐标原点会发生变化。)
代码很简单,就是判断如果拖拽元素落点处于画布中,添加一个对应的node,坐标就是落点,这样画布中就会在这个位置出现这个node
值得一提的是,可以为node设置anchorPoint来指定node的哪些位置可以作为连接点:节点的连接点 anchorPoint
另外还有一个属性叫linkPoint

// 根据不同工具类型,添加不同样式node
const getNodeStyle = (name: string) => {
  if (name === 'common') {
    return {
      type: 'circle',
      size: 80,
      style: {
        stroke: 'blue',
        fill: '#FFF'
      }
    };
  } else if (name === 'start') {
    return {
      type: 'rect',
      size: [80, 40],
      style: {
        fill: '#FFF',
        stroke: 'red'
      }
    };
  } else if (name === 'juge') {
    return {
      type: 'diamond',
      size: 80,
      style: {
        fill: '#FFF',
        stroke: 'yellow'
      }
    };
  }
};

const onDragEnd = (item: { name: string }, position: { x: number, y: number }) => {
  // const point = editor.current?.getPointByClient(position.x, position.y);
  // console.log(point);
  if (position && position.x > 160 && position.y > 50) {
    // 完全进入画布,则生成一个节点
    let key = `id-${id++}`;
    const style = getNodeStyle(item.name);
    const newNode = {
      ...style,
      id: key,
      x: position.x - (160 - NODE_WIDTH / 2),
      y: position.y - (50 - NODE_HEIGHT / 2),
      anchorPoints: [
        [0.5, 0],
        [1, 0.5],
        [0.5, 1],
        [0, 0.5]
      ],
      label: item.name
    };
    editor.current?.addItem('node', newNode);
  }
};

模拟拖拽实现生成连线

  • 使用G6自带的Behavior来为两个node生成edge
    官网示例
    缺点是只能通过click事件来触发。

  • 自己通过模拟拖拽的方式实现
    因为node本身也是可以拖拽的,这样就和拖拽连线产生来冲突。
    因此拖拽连线的起点,就需要做特殊出来,判断在linkPoint上才触发创建edge的事件。
    本来没有什么头绪,后来发现官网又一个类似的示例
    大致思路是当鼠标按下时,判断如果当前位置处于linkPoint上,则创建一个sourcetarget均为当前nodeedge,然后当mousemove的时候,去更新edge的位置(即xy的值更新为当前鼠标的坐标),当鼠标松开时,则判断是否处于node范围,如果处于某个node范围中,则将edgetarget更新为该node,否则删除该edge

    比较好的实现方式是和示例一样,以registerBehavior的方式将相关事件都注册在一起。

G6.registerBehavior('drag-point-add-edge', {
    getEvents() {
      return {
        click: 'onMouseClick',
        mousedown: 'onMouseDown',
        mousemove: 'onMouseMove',
        mouseup: 'onMouseUp',
        'node:click': 'onNodeClick',
        'edge:click': 'onEdgeClick'
      };
    },
    onMouseDown(ev: any) {
      ev.preventDefault();
      const self = this;
      const node = ev.item;
      if (node && ev.target.get('className').startsWith('link-point')) {
        const graph = self.graph as Graph;
        const model = node.getModel();

        if (!self.addingEdge && !self.edge) {
          self.edge = graph.addItem('edge', {
            source: model.id,
            target: model.id
          });
          self.addingEdge = true;
        }
      }
    },
    onMouseMove(ev: any) {
      ev.preventDefault();
      const self = this;
      const point = { x: ev.x, y: ev.y };
      if (self.addingEdge && self.edge) {
        (self.graph as Graph).updateItem(self.edge as IEdge, {
          target: point
        });
      }
    },
    onMouseUp(ev: any) {
      ev.preventDefault();
      const self = this;
      const node = ev.item;
      const graph = self.graph as Graph;
      // 这里会走两次,第二次destroyed为true
      // 因此增加判断
      if (node && !node.destroyed && node.getType() === 'node') {
        const model = node.getModel();
        console.log(model);
        if (self.addingEdge && self.edge) {
          graph.updateItem(self.edge as IEdge, {
            target: model.id
          });
          self.edge = null;
          self.addingEdge = false;
        }
      } else {
        if (self.addingEdge && self.edge) {
          graph.removeItem(self.edge as IEdge);
          self.edge = null;
          self.addingEdge = false;
        }
      }
    }
  });

选中Node或者Edge后编辑

G6提供了状态,以及状态样式:State
因此可以很方便实现选中后变更状态

右键菜单、底部 grid、minimap

这些都是G6提供的组件,只需要在初始化时传入plugins即可。

小结

预览地址
至此,一个满足基础功能的流程编辑器就完成了。
(其实还有很多可以优化的点,比如拖拽时显示辅助线,对齐到网格时如果设置为居中会有坐标起点不在原点的问题等等。)

4人推荐
随时随地看视频
慕课网APP