继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

基于G6的流程编辑器

nickylau82
关注TA
已关注
手记 25
粉丝 105
获赞 597

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

前言

很长一段时间以来,一些诸如 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即可。

小结

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

打开App,阅读手记
4人推荐
发表评论
随时随地看视频慕课网APP