在BPMN规范中,事件可以分为有3种,分别为只能发到流程开头的开始事件、只能放到结尾的结束事件和可以放到中间的中间事件。在中间事件中,有一种可以附加到任务或者子流程边缘的的事件,叫做边界事件。虽然边界事件还可以细分为中断和非中断事件,但是对于我们前端来说,其交互体验都一致,所以只需要实现一种就行。这篇文章记录如何使用LogicFlow从0到1实现一个边界事件,希望能帮到有类似需求的同学。至于为什么要自己实现而不是使用bpmn-js, 请参考我之前的文章换掉bpmn-js,让前端更熟悉工作流业务。
下图为bpmn-js中边界事件的效果图和我实现的demo示例效果图:
bpmn-js中的效果
最终实现的效果
参考bpmn-js中边界事件的交互,结合我们项目中实际的需求,这里先分析边界事件需要的一些通用的交互功能点。
交互1: 目标节点的高亮提示
当拖动边界事件到可以附加的节点上时,这个节点的变高亮,给出智能提示。
交互2: 边界事件跟随目标节点
边界事件节点附加到目标节点上后,拖动目标节点边界事件可以跟随目标节点一起移动。
交互3: 边界事件脱离目标节点
在bpmn-js交互中,如果一个已经附件到目标节点上的边界事件节点是"原始的"(未设置边界事件的具体功能), 则可以再拖动边界事件节点离开附加节点,然后作为流程中独立的中间事件或者附加到其他节点上。
交互4:保留其他节点交互
边界事件节点仍然具有节点菜单,锚点,选中状态等功能。
交互5:边界事件节点永远在附加节点上方
由于我们项目是使用LogicFlow开发,下面的实现代码也是使用LogicFlow提供的API来实现。如果大家使用的其他框架,可以参考这个思路来自己实现。通过边界事件的功能分析和LogicFlow本身提供的API,我们可以大致把这些交互分配到不同的模块实现。我们可以大致把边界事件这个功能分为3个模块:
以上思路参考了LogicFlow Group插件源码,整个边界事件封装为一个插件整个代码结构确实更加清晰了。
在交互4中提到,边界事件节点仍然具有节点的通用功能,所有这里我们新建一个文件boundary-events-node.js,通过自定义节点来实现边界事件节点。
import { CircleNode, CircleNodeModel, h } from "@logicflow/core"
class BoundaryEventModel extends CircleNodeModel {
getNodeStyle () {
const style = super.getNodeStyle()
style.strokeWidth = 1
style.stroke = '#EFEA9A'
return style
}
getOutlineStyle() {
const style = super.getOutlineStyle()
style.stroke = 'transparent'
style.hover.stroke = 'transparent'
return style
}
initNodeData (data) {
super.initNodeData(data)
this.r = 20
this.zIndex = 9999 // 保证边界事件节点用于在最上方
}
}
class BoundaryEvent extends CircleNode {
// 自定义边界事件的外观,后面细分的边界事件节点可以重写该方法来实现自定义图标
getIcon () {
const { model } = this.props;
const style = model.getNodeStyle();
const { x, y, width, height } = model;
return h(
'svg',
{
x: x - width / 2 + 2,
y: y - height / 2 + 2,
},
h('path', {
stroke: style.stroke,
fill: style.fill,
d: 'M 10.5,8.5 l 14.5,0 l 0,18 l -14.5,0 Z M 12.5,11.5 l 10.5,0 M 12.5,14.5 l 10.5,0 M 12.5,17.5 l 10.5,0 M 12.5,20.5 l 10.5,0 M 12.5,23.5 l 10.5,0 M 12.5,26.5 l 10.5,0',
}),
)
}
getShape() {
const { model } = this.props;
const style = model.getNodeStyle();
const { x, y, r } = model;
const outCircle = super.getShape();
return h(
'g',
{},
outCircle,
h('circle', {
...style,
cx: x,
cy: y,
r: r - 5,
}),
this.getIcon()
);
}
// 重写toFront为空来防止边界事件层级因为交互而改变
// 改变节点在失去选中状态后被zIndex重置为0的问题
toFront () {}
}
export default {
type: "boundary-event",
view: BoundaryEvent,
model: BoundaryEventModel,
}
从上面的代码中可以看到,利用LogicFlow的继承重写思路,重写toFront来强制改变节点失去选中状态后被重置为0的问题,来实现交互5。
自定义节点主要是定义边界事件节点的外观,这里就不详细解释了。值得注意的一点是,边界事件节点本身会有很多种类型,不同的类型图标是不一样的,所以这里我们把生成图标的功能单独提出来做成一个getIcon函数,后面实际应用时只需要重此方法即可。
为了让使用体验更好,我们需要对目标节点进行一定的改造,使其在边界节点被拖拽靠近的时候,节点自身做出高亮的动作来提示用户。同样利用LogicFlow的自定义节点,给节点增加增加方法setIsCloseToBoundary,这个方法传入参数true就表现为高亮,false就表现为默认外观。
import { RectNode, RectNodeModel, h } from '@logicflow/core'
class TaskModel extends RectNodeModel {
initNodeData (data) {
super.initNodeData(data)
this.width = 100
this.height = 60
this.isTaskNode = true // 标识此节点是任务节点,可以被附件边界事件
this.boundaryEvents = [] // 记录自己附加的边界事件
}
getNodeStyle() {
const style = super.getNodeStyle()
// isCloseToBoundary属性用于标识拖动边界节点是否靠近此节点
// 如果靠近,则高亮提示
const { isCloseToBoundary } = this.properties
if (isCloseToBoundary) {
style.stroke = '#FF0000'
style.strokeWidth = 2
} else {
style.stroke = '#EFEA9A'
style.strokeWidth = 1
}
return style
}
getOutlineStyle() {
const style = super.getOutlineStyle()
style.stroke = 'transparent'
style.hover.stroke = 'transparent'
return style
}
/**
* 提供方法给插件在判断此节点被拖动边界事件节点靠近时调用,从而触发高亮
*/
setIsCloseToBoundary (flag) {
this.setProperty('isCloseToBoundary', flag)
}
/**
* 附加后记录被附加的边界事件节点Id
*/
addBoundaryEvent (nodeId) {
if (this.boundaryEvents.find(item => item === nodeId)) {
return false
}
this.boundaryEvents.push(nodeId)
return true
}
/**
* 被附加的边界事件节点被删除时,移除记录
*/
deleteBoundaryEvent (nodeId) {
this.boundaryEvents = this.boundaryEvents.filter(item => item !== nodeId)
}
}
class Task extends RectNode {
getShape() {
const { model } = this.props;
const style = model.getNodeStyle();
const { x, y, width, height } = model;
const outCircle = super.getShape();
return h(
'g',
{},
outCircle,
h('rect', {
...style,
x: x - width / 2,
y: y - height / 2,
width,
height,
}),
);
}
}
export default {
type: 'task-node',
view: Task,
model: TaskModel,
}
有了边界事件节点和目标节点后,下面我们用一个插件来组合边界事件节点和目标节点,实现剩余的交互效果。
import BoundaryEvent from './boundary-events-node'
import TaskNode from './task-node';
class BoundaryEventPlugin {
constructor({ lf }) {
this.lf = lf;
this.nodeBoundaryMap = new Map()
lf.register(BoundaryEvent);
lf.register(TaskNode);
lf.on('node:drag,node:dnd-drag', this.checkAppendBoundaryEvent);
lf.on('node:drop,node:dnd-add', this.appendBoundaryEvent);
lf.graphModel.addNodeMoveRules((model, deltaX, deltaY) => {
if (model.isTaskNode) { // 如果移动的是分组,那么分组的子节点也跟着移动。
const nodeIds = model.boundaryEvents;
lf.graphModel.moveNodes(nodeIds, deltaX, deltaY, true);
return true;
}
return true
});
}
private appendBoundaryEvent = ({ data }) => {
const preBoundaryNodeId = this.nodeBoundaryMap.get(data.id);
const closeNodeId = this.checkAppendBoundaryEvent({ data })
if (closeNodeId) {
const nodeModel = this.lf.graphModel.getNodeModelById(closeNodeId)
nodeModel.setIsCloseToBoundary(false)
nodeModel.addBoundaryEvent(data.id)
this.nodeBoundaryMap.set(data.id, closeNodeId)
}
if (preBoundaryNodeId !== closeNodeId) {
const preNodeModel = this.lf.graphModel.getNodeModelById(preBoundaryNodeId)
if (preNodeModel) {
preNodeModel.deleteBoundaryEvent(data.id)
}
}
}
// 判断此节点是否在某个节点的边界上
// 如果在,且这个节点model存在属性isTaskNode,则调用这个方法
private checkAppendBoundaryEvent = ({ data }) => {
const { x, y, id } = data;
const { nodes } = this.lf.graphModel;
let closeNodeId = '';
for (let i = 0; i < nodes.length; i++) {
const nodeModel = nodes[i];
if (nodeModel.isTaskNode && nodeModel.id !== id) {
if (this.isCloseNodeEdge(nodeModel, x, y) && !closeNodeId) { // 同时只允许在一个节点的边界上
nodeModel.setIsCloseToBoundary(true);
closeNodeId = nodeModel.id;
} else {
nodeModel.setIsCloseToBoundary(false);
}
}
}
return closeNodeId;
}
private isCloseNodeEdge (nodeModel, x, y) {
if (Math.abs(Math.abs(nodeModel.x - x) - nodeModel.width / 2) < 10 &&
y >= nodeModel.y - nodeModel.height / 2 - 10 &&
y <= nodeModel.y + nodeModel.height / 2 + 10) {
return true
}
if (Math.abs(Math.abs(nodeModel.y - y) - nodeModel.height / 2) < 10 &&
x >= nodeModel.x - nodeModel.width / 2 - 10 &&
x <= nodeModel.x + nodeModel.width / 2 + 10) {
return true
}
return false
}
}
export {
BoundaryEventPlugin,
};
效果演示
github地址:https://github.com/hsole/BoundaryEvents
在线演示地址:自定义边界事件
最后
说一下我对LogicFlow的看法。在我们最开始调研的时候,选型用LogicFlow的原因主要是因为其源码“简单”,相比于其他流程图编辑框架,遇到问题我们改源码成本会低很多。后来在实际项目发现,LogicFlow的插件机制和基于继承重写的自定义方式具有非常高的可扩展性,基本上svg能实现的LogicFlow都能实现。不需要去改源码来实现我们项目各种特殊的需求,最多是不用LogicFlow内置的插件,而是参考其插件重新实现一个自己的插件罢了。目前我们用了LogicFlow基本上不会出现产品有需求我们做不了的情况了。
虽然流程图是一个相对小众的方向,LogicFlow在社区也没啥人知道,用不到的人基本上都不会去看。但是LogicFlow本身对svg的应用挺好,在加上其源码是用preact和mobx写的,基本上懂react的人就很容易看懂,大家有空可以去尝试一下,就当学SVG了。
LogicFlow github地址:https://github.com/didi/LogicFlow