5. Toolbar
在画布与属性面板都创建好之后,我们就得到了一个完整的流程图编辑器了。
但是,这个模式下的编辑器没有绑定键盘快捷键,也没有导入导出的按钮和入口,并且也不能支持一键对齐等等功能。所以我们可以在此基础上,实现一个工具栏,来优化用户体验。
5.1 Import And Export
导入
首先,我们先实现文件导入的功能。利用 Modeler
实例本身的 importXML(xmlString)
的方法,可以很简单的完成导入,只需要创建一个 input
和一个 button
即可。
通过 button
的点击事件来模拟文件选择 input
的点击来触发文件选择,在确认文件选取之后初始化一个 FileReader
来读取数据并渲染。
这里使用的组件库是 naive ui
import { defineComponent, ref } from 'vue' import { NButton } from 'naive-ui' import modeler from '@/store/modeler' const Imports = defineComponent({ name: 'Imports', setup() { const modelerStore = modeler() const importRef = ref<HTMLInputElement | null>(null) const openImportWindow = () => { importRef.value && importRef.value.click() } const changeImportFile = () => { if (importRef.value && importRef.value.files) { const file = importRef.value.files[0] const reader = new FileReader() reader.readAsText(file) reader.onload = function () { const xmlStr = this.result modelerStore.getModeler!.importXML(xmlStr as string) } } } return () => ( <span> <NButton type="info" secondary onClick={openImportWindow}> 打开文件 </NButton> <input type="file" ref={importRef} style="display: none" accept=".xml,.bpmn" onChange={changeImportFile} ></input> </span> ) } }) export default Imports
导出
至于文件导出的功能,官方在 BaseViewer
的原型上就提供了 saveXML
和 saveSVG
这两个方法,分别用来获取 xml
字符串与 svg
渲染结果。
import { defineComponent } from 'vue' import { NButton, NPopover } from 'naive-ui' import { downloadFile, setEncoded } from '@/utils/files' import modeler from '@/store/modeler' const Exports = defineComponent({ name: 'Exports', setup() { const moderlerStore = modeler() // 下载流程图到本地 /** * @param {string} type * @param {*} name */ const downloadProcess = async (type: string, name = 'diagram') => { try { const modeler = moderlerStore.getModeler // 按需要类型创建文件并下载 if (type === 'xml') { const { err, xml } = await modeler!.saveXML() // 读取异常时抛出异常 if (err) { console.error(`[Process Designer Warn ]: ${err.message || err}`) } const { href, filename } = setEncoded(type.toUpperCase(), name, xml!) downloadFile(href, filename) } else { const { err, svg } = await modeler!.saveSVG() // 读取异常时抛出异常 if (err) { return console.error(err) } const { href, filename } = setEncoded('SVG', name, svg!) downloadFile(href, filename) } } catch (e: any) { console.error(`[Process Designer Warn ]: ${e.message || e}`) } } const downloadProcessAsXml = () => { downloadProcess('xml') } const downloadProcessAsSvg = () => { downloadProcess('svg') } return () => ( <NPopover v-slots={{ trigger: () => ( <NButton type="info" secondary> 导出为... </NButton> ), default: () => ( <div class="button-list_column"> <NButton type="info" onClick={downloadProcessAsXml}> 导出为XML </NButton> <NButton type="info" onClick={downloadProcessAsSvg}> 导出为SVG </NButton> </div> ) }} ></NPopover> ) } }) export default Exports
// 根据所需类型进行转码并返回下载地址 export function setEncoded(type: string, filename: string, data: string) { const encodedData: string = encodeURIComponent(data) return { filename: `${filename}.${type.toLowerCase()}`, href: `data:application/${ type === 'svg' ? 'text/xml' : 'bpmn20-xml' };charset=UTF-8,${encodedData}`, data: data } } // 文件下载方法 export function downloadFile(href: string, filename: string) { if (href && filename) { const a: HTMLAnchorElement = document.createElement('a') a.download = filename //指定下载的文件名 a.href = href // URL对象 a.click() // 模拟点击 URL.revokeObjectURL(a.href) // 释放URL 对象 } }
5.2 Canvas Zoom
因为没有绑定键盘事件,所以当前情况下想通过键盘和鼠标滚轮来控制画布缩放层级也不行。
但是 diagram.js
的核心模块 Canvas
,就提供了画布的相关控制方法,我们可以通过 Canvas
的实例来实现对画布的控制。
import { defineComponent, ref } from 'vue' import { NButton, NButtonGroup, NPopover } from 'naive-ui' import LucideIcon from '@/components/common/LucideIcon.vue' import EventEmitter from '@/utils/EventEmitter' import type Modeler from 'bpmn-js/lib/Modeler' import type Canvas from 'diagram-js/lib/core/Canvas' import { CanvasEvent } from 'diagram-js/lib/core/EventBus' const Scales = defineComponent({ name: 'Scales', setup() { const currentScale = ref(1) let canvas: Canvas | null = null EventEmitter.on('modeler-init', (modeler: Modeler) => { canvas = modeler.get<Canvas>('canvas') currentScale.value = canvas.zoom() modeler.on('canvas.viewbox.changed', ({ viewbox }: CanvasEvent<any>) => { currentScale.value = viewbox.scale }) }) const zoomOut = (newScale?: number) => { currentScale.value = newScale || Math.floor(currentScale.value * 100 - 0.1 * 100) / 100 zoomReset(currentScale.value) } const zoomIn = (newScale?: number) => { currentScale.value = newScale || Math.floor(currentScale.value * 100 + 0.1 * 100) / 100 zoomReset(currentScale.value) } const zoomReset = (newScale: number | string) => { canvas && canvas.zoom(newScale, newScale === 'fit-viewport' ? undefined : { x: 0, y: 0 }) } return () => ( <NButtonGroup> <NPopover v-slots={{ default: () => '缩小视图', trigger: () => ( <NButton onClick={() => zoomOut()}> <LucideIcon name="ZoomOut" size={16}></LucideIcon> </NButton> ) }} ></NPopover> <NPopover v-slots={{ default: () => '重置缩放', trigger: () => ( <NButton onClick={() => zoomReset('fit-viewport')}> <span style="text-align: center; display: inline-block; width: 40px"> {Math.floor(currentScale.value * 10) * 10 + '%'} </span> </NButton> ) }} ></NPopover> <NPopover v-slots={{ default: () => '放大视图', trigger: () => ( <NButton onClick={() => zoomIn()}> <LucideIcon name="ZoomIn" size={16}></LucideIcon> </NButton> ) }} ></NPopover> </NButtonGroup> ) } }) export default Scales
5.3 Command Stack
撤销恢复个人觉得是最简单的封装之一,毕竟 CommandStack
本身就记录了相关的图形操作以及属性更新。
import { defineComponent } from 'vue' import { NButton, NButtonGroup, NPopover } from 'naive-ui' import EventEmitter from '@/utils/EventEmitter' import type Modeler from 'bpmn-js/lib/Modeler' import type CommandStack from 'diagram-js/lib/command/CommandStack' import { createNewDiagram } from '@/utils' import LucideIcon from '@/components/common/LucideIcon.vue' const Commands = defineComponent({ name: 'Commands', setup() { let command: CommandStack | null = null EventEmitter.on('modeler-init', (modeler: Modeler) => { command = modeler.get<CommandStack>('commandStack') }) const undo = () => { command && command.canUndo() && command.undo() } const redo = () => { command && command.canRedo() && command.redo() } const restart = () => { command && command.clear() createNewDiagram() } return () => ( <NButtonGroup> <NPopover v-slots={{ default: () => '撤销', trigger: () => ( <NButton onClick={undo}> <LucideIcon name="Undo2" size={16}></LucideIcon> </NButton> ) }} ></NPopover> <NPopover v-slots={{ default: () => '恢复', trigger: () => ( <NButton onClick={redo}> <LucideIcon name="Redo2" size={16}></LucideIcon> </NButton> ) }} ></NPopover> <NPopover v-slots={{ default: () => '擦除重做', trigger: () => ( <NButton onClick={restart}> <LucideIcon name="Eraser" size={16}></LucideIcon> </NButton> ) }} ></NPopover> </NButtonGroup> ) } }) export default Commands
5. Module Configuration
在进行深度自定义之前,这里先介绍 bpmn.js Modeler
本身默认引用的 Modules
的一些配置项。
5.1 BpmnRenderer Configuration
控制画布区域的元素渲染
defaultFillColor
:元素填充色,例如任务节点中间的空白部分的填充色,默认为undefined
defaultStrokeColor
:元素边框颜色,也可以理解为路径类元素的颜色,默认为undefined
,显示为黑色
defaultLabelColor
:Label
标签字体颜色,默认为undefined
,显示为黑色
可以通过以下方式更改:
const modeler = new Modeler({ container: 'xx', bpmnRenderer: { defaultFillColor: '#eeeeee', defaultStrokeColor: '#2a2a2a', defaultLabelColor: '#333333' } })
5.2 TextRenderer Configuration
控制画布区域的文字渲染
fontFamily
: 文字字体,默认为'Arial, sans-serif'
fontSize
: 文字大小,默认12px
fontWeight
: 文字粗细,默认为'normal'
lineHeight
: 文本行高,默认为 1.2
size
: 生成的文本标签的大小,默认为{ width: 150, height: 50 }
padding
: 文本标签内间距,默认为 0
style
: 文本标签其他 css 样式
align
: 内部文本对齐方式,默认为center-top
可以通过传入配置项 textRenderer: {}
更改
5.3 ContextPad Configuration
控制元素的上下文菜单位置与大小缩放
autoPlace
:是否调用AutoPlace
模块来实现新元素创建时自动定位,默认为undefined
,如果配置该属性并设置为false
的话,在利用contextPad
创建新元素时需要手动选择新元素位置
scale
:缩放的限制范围,默认为{ min: 1.0, max: 1.5 }
可以通过传入配置项 contextPad: {}
更改
5.4 Canvas Configuration
控制画布区域大小与更新频率
deferUpdate
: 是否配置延迟更新画布改变,默认为undefined
,如果配置该属性并设置为false
的话,则会即时更新画布显示(会消耗大量资源)
width
: 宽度,默认为 '100%'
height
: 高度,默认为 '100%'
5.5 Keyboard Configuration
键盘事件的绑定对象
bindTo
: 设置绑定对象,默认为undefined
,一般会配置为document
或者window
可以通过传入配置项 keyboard: {}
配置,默认快捷键列表如下:
5.6 AutoScroll Configuration
鼠标焦点移动到画布边框位置时开启画布滚动,主要配置触发区域与滚动设置
scrollThresholdIn
:触发滚动的边界距离最大值,默认为[ 20, 20, 20, 20 ]
scrollThresholdOut
:触发滚动的边界距离最小值,默认为[ 0, 0, 0, 0 ]
scrollRepeatTimeout
:滚动间隔,默认为 15 ms
scrollStep
:滚动步长。默认为 6
可以通过传入配置项 autoScroll: {}
配置
5.7 ZoomScroll Configuration
鼠标滚轮缩放的配置
enabled
: 是否启动鼠标滚轮缩放功能,默认为undefined
,如果配置该属性并设置为false
的话,则会禁用鼠标滚动缩放功能
scale
: 缩放倍率,默认为 0.75
可以通过传入配置项 zoomScroll: {}
配置
当然,这部分只是
bpmn.js
与diagram.js
内部的插件模块提供的配置项,在我们的自定义模块也可以通过依赖config
来配置更多的可用配置项,使Modeler
更加灵活
下面,进行 Modeler
的核心插件自定义的讲解
6. Custom Element And Properties
在第四节 Properties Panel
中,大概讲解了自定义元素属性的方式。参照 Bpmn-js自定义描述文件说明-掘金 和 bpmn-io/moddle,这里再重新说明一下。
一个 moddleExtension
描述文件的格式为 json
,或者是一个可以导出 json
对象的 js/ts
文件,该描述文件(对象)包含以下几个属性:
name
: 该部分扩展的名称,一般根据流程引擎来命名,字符串格式
uri
: 统一资源标识符,一般是一个地址字符串
prefix
: 属性或者元素统一前缀,小写字符串格式
xml
: 格式转换时的配置,一般用来配置{ "tagAlias": "lowerCase" }
, 表示会将标签名转换为小写驼峰,可省略
types
: 核心部分,用来声明元素和属性,以及扩展原有属性等,对象数组格式
enumerations
: 枚举值定义部分,可以用来定义types
中某个配置属性的可选值
associations
: 组合定义,暂时作为保留配置
types
作为核心部分,通过一个特定格式的对象数组来描述元素与属性之间的关系,以及每个属性的类型和位置。
type Type = { name: string extends?: string[] superClass?: string[] isAbstract?: boolean meta?: TypeMeta properties: TypeProperty[] } type TypeMeta = { allowedIn?: string[] | ['*'] } type TypeProperty = { name: string type: string // 支持 boolean, string, number 这几个简单类型,此时可以设置 default 默认值;也支持自定义元素作为属性值 isAttr?: boolean // 是否作为一个 xml 标签属性,为 true 时会将该属性值转换为 boolean, string, number 简单类型,对象等类型会转为 '[object Object]' isBody?: boolean // 是否将值插入到 xml 标签内部作为 content,转换方式与 isAttr 一致,但是这两个属性不能共存 isMany?: boolean // 是否支持多个属性,一般这种情况下 type 是一个继承自 Element 的自定义元素,会将子元素插入到 xml 标签的 content 区域中,默认为 false isReference?: boolean // 是否将 type 指定的自定义元素的 id 作为值,体现在 xml 上时该属性为对应的元素 id 字符串,但是通过 modeler 解析后该属性指向对应的元素实例 redefines?: string // 重定义继承元素的某个属性配置,通常与 superClass 配合使用,例如 "redefines": "bpmn:StartEvent#id" default?: string | number | boolean }
example = { // ... // 表示创建属性或者元素时,需要增加的前缀,比如创建 ExampleElement 需要 moddle.create('ex:ExampleElement', {}) prefix: 'ex', types: [ { name: 'ExampleElement', /** * 继承 Element 的默认属性,表示可以创建一个 xml 元素标签更新到 xml 数据中 * 该继承关系类似 js 原型链,如果继承的元素最终都继承自 Element,那么该属性也可以生成 xml 元素标签 */ superClass: ['Element'], /** * 与 superClass 相反,extends 表示扩展原始元素的配置,并不代表继承。 * 使用 extends 之后,该类型定义的 properties 最终都会体现到原始元素上,展示方式为 ex:propertyName='xxx' * (这只代表配置的 propertyName 是一个简单属性,如果是自定义属性的话,需要根据属性类型来区分) */ extends: ['bpmn:StartEvent'], /** * 设置 allowedIn 来定义该属性可以插入到哪些元素内部,可以设置 ['*'] 表示任意元素 */ meta: { allowedIn: ['bpmn:StartEvent'] }, properties: [ { name: 'exProp1', type: 'String', default: '2' } ] } ] }
注意:superClass 与 extends 不能同时使用,两者的区别也可以查看官方回复 issue-21
完整演示见 properties-panel-extension, bpmn-js-example-custom-elements
7. Custom Renderer, Palette and ContextPad
关于如何扩展原始 Renderer
, Palette
(这里其实应该是 PaletteProvider
) 和 ContextPad
(这里其实应该是 ContextPadProvider
),霖呆呆和 bpmn
官方都给出了示例。
- 霖呆呆的文档地址 全网最详bpmn.js教材目录 和示例仓库 bpmn-vue-custom
这里针对核心部分简单讲解一下。
7.1 Renderer
重新自定义元素的渲染逻辑,可以区分为 “部分自定义” 与 “完全自定义”,“部分自定义” 又可以分为 “自定义新增元素类型渲染” 和 “自定义原始类型渲染”,核心逻辑其实就是改变 Renderer
构造函数上的 drawShape
方法。
declare class BpmnRenderer extends BaseRenderer { constructor(config: Object, eventBus: EventBus, styles: Styles, pathMap: PathMap, canvas: Canvas, textRenderer: TextRenderer, priority?: number) handlers: Record<string, RendererHandler> _drawPath(parentGfx: SVGElement, element: Base, attrs?: Object): SVGElement _renderer(type: RendererType): RendererHandler getConnectionPath<E extends Base>(connection: E): string getShapePath<E extends Base>(element: E): string canRender<E extends Base>(element: E): boolean drawShape<E extends Base>(parentGfx: SVGElement, element: E): SVGRectElement }
原生 BpmnRenderer
继承自抽象函数 BaseRenderer
,通过 drawShape
方法来绘制 svg 元素,之后添加到 canvas
画布上。但是 drawShape
的核心逻辑其实就是根据 element
元素类型来调用 handler[element.type]()
实现元素绘制的。
BpmnRenderer.prototype.drawShape = function(parentGfx, element) { var type = element.type; var h = this._renderer(type); return h(parentGfx, element); };
在 “自定义新增元素类型渲染” 或者 “对原始 svg 元素增加细节调整” 的时候,可以通过继承 BaseRenderer
之后实现 drawShape
方法来实现。
class CustomRenderer extends BaseRenderer { constructor(eventBus: EventBus, bpmnRenderer: BpmnRenderer) { super(eventBus, 2000); this.bpmnRenderer = bpmnRenderer; } drawShape(parentNode: SVGElement, element: Base) { // 处理自定义元素 if (is(element, 'ex:ExampleElement')) { const customElementsSVGPath = '这里是自定义元素的 svg path 路径' const path = svgCreate('path') svgAttr(path, { d: customElementsSVGPath }) svgAttr(path, attrs) svgAppend(parentGfx, path) // 需要 return 该 svg 元素 return path } // 调用 bpmnRenderer.drawShape 来实现原始元素的绘制 const shape = this.bpmnRenderer.drawShape(parentNode, element); // 对原有元素 UserTask 增加细节调整 if (is(element, 'bpmn:UserTask')) { svgAttr(shape, { fill: '#eee' }); } return shape } } CustomRenderer.$inject = [ 'eventBus', 'bpmnRenderer' ]; // 使用时,需要注意大小写 export default { __init__: ['customRenderer'], customRenderer: ['type', CustomRenderer] }
当然,上面这种方式基本上很难满足大部分的自定义渲染需求,毕竟有时候需要的不是给原始元素增加细节,而是需要将整个元素全部重新实现(UI同事的审美通常都比我们要“强”不少),虽然可以在调用 this.bpmnRenderer.drawShape()
来绘制剩余类型之前,我们还可以增加很多个元素的处理逻辑,但这样无疑会使得这个方法变得异常臃肿,而且很难通过配置来实现不同的元素样式。
**所以,我们可以在 BpmnRenderer
的源码基础上,重新实现一个 RewriteRenderer
。**不过这部分代码有点长(2000+行),这里暂时就不放出来了🤪
7.2 Palette
与 ContextPad
针对这两个模块,自定义的逻辑其实与 Renderer
类似,只不过是对应的方法不一样。
CustomPaletteProvider
需要依赖 Palette
实例,并实现 getPaletteEntries
方法来将自定义部分的内容插入到 palette
中。
class CustomPaletteProvider { // ... 需要定义 _palette 等属性 constructor(palette, create, elementFactory, spaceTool, lassoTool, handTool, globalConnect) { this._palette = palette this._create = create this._elementFactory = elementFactory this._spaceTool = spaceTool this._lassoTool = lassoTool this._handTool = handTool this._globalConnect = globalConnect // 注册该 Provider palette.registerProvider(this); } getPaletteEntries() { return { 'custom-palette-item': { group: 'custom', // 分组标志,group 值相同的选项会出现在同一个区域 className: 'custom-palette-icon-1', title: '自定义选项1', action: { click: function (event) { alert(1) }, dragstart: function (event) { alert(2) } } }, 'tool-separator': { group: 'tools', separator: true // 指定该配置是显示一个分割线 }, } } } export default { __init__: ['customPaletteProvider'], // 如果要覆盖原有的 paletteProvider, 可以写为 paletteProvider: ['type', CustomPaletteProvider],__init__ 属性此时可以省略 customPaletteProvider: ['type', CustomPaletteProvider] }
CustomContextPadProvider
作为元素选中时会提示的上下文菜单,与 CustomPaletteProvider
的实现逻辑基本一致,但是需要注意 AutoPlace
模块的引用。
class CustomContextPadProvider { constructor( config: Object, injector: Injector, eventBus: EventBus, contextPad: ContextPad, modeling: Modeling, elementFactory: ElementFactory, connect: Connect, create: Create, popupMenu: PopupMenu, canvas: Canvas, rules: Rules ) { if (config.autoPlace !== false) { this._autoPlace = injector.get('autoPlace', false); } contextPad.registerProvider(this); } getContextPadEntries(element: Base) { const actions: Record<string, any> = {} const appendUserTask = (event: Event, element: Shape) => { const shape = this._elementFactory.createShape({ type: 'bpmn:UserTask' }) this._create.start(event, shape, { source: element }) } const append = this._autoPlace ? (event: Event, element: Shape) => { const shape = this._elementFactory.createShape({ type: 'bpmn:UserTask' }) this._autoPlace.append(element, shape) } : appendUserTask // 添加创建用户任务按钮 actions['append.append-user-task'] = { group: 'model', className: 'bpmn-icon-user-task', title: '用户任务', action: { dragstart: appendUserTask, click: append } } // 添加一个与edit一组的按钮 actions['enhancement-op-1'] = { group: 'edit', className: 'enhancement-op', title: '扩展操作1', action: { click: function (e: Event) { alert('点击 扩展操作1') } } } // 添加一个新分组的自定义按钮 actions['enhancement-op'] = { group: 'enhancement', className: 'enhancement-op', title: '扩展操作2', action: { click: function (e: Event) { alert('点击 扩展操作2') } } } return actions } } export default { __init__: ['customContextPadProvider'], // 如果要覆盖原有的 ContextPadProvider, 可以写为 contextPadProvider: ['type', CustomContextPadProvider],__init__ 属性此时可以省略 customContextPadProvider: ['type', CustomContextPadProvider] }
8. Replace Options (PopupMenu)
这部分功能默认是通过 ContextPad
中间的小扳手 🔧 来触发的,主要是用来更改当前元素的类型。很多小伙伴反馈说其实里面的很多选项都不需要,这里对如何实现该部分更改进行说明。
- 通过
css
隐藏dev.djs-popup-body
节点下的多余节点,因为不同的元素类型有不同的css class
类名,可以通过类名设置display: none
隐藏
- 直接修改
ReplaceOptions
的数据
import { TASK } from 'bpmn-js/lib/features/replace/ReplaceOptions'; // 移除多余的选项 GATEWAY.splice(2, GATEWAY.length); // 注意需要在 new Modeler 之前,并且这种方式不支持 cdn 引入
- 修改
ReplaceMenuProvider
, 这里与自定义ContextPadProvider
的逻辑类似。
// 源码位置见 bpmn-js/lib/features/popup-menu/ReplaceMenuProvider.js import * as replaceOptions from '../replace/ReplaceOptions'; class CustomReplaceMenuProvider extends ReplaceMenuProvider { constructor(bpmnFactory, popupMenu, modeling, moddle, bpmnReplace, rules, replaceMenuProvider, translate) { super(bpmnFactory, popupMenu, modeling, moddle, bpmnReplace, rules, translate); this.register(); } getEntries(element) { if (!rules.allowed('shape.replace', { element: element })) { return []; } const differentType = isDifferentType(element); if (is(elemeny, 'bpmn:Gateway')) { entries = filter(replaceOptions.GATEWAY.splice(2, replaceOptions.GATEWAY.length), differentType); return this._createEntries(element, entries); } return replaceMenuProvider.getEntries(element) } } ReplaceMenuProvider.$inject = [ 'bpmnFactory', 'popupMenu', 'modeling', 'moddle', 'bpmnReplace', 'rules', 'replaceMenuProvider', 'translate' ];
9. 自己实现 Properties Panel
虽然根据 第 4.4 小节可以知道,我们可以通过自定义一个属性面板分组,来插入到原生的 Bpmn Properties Panel
中,但是这样实现,第一是基本不符合国内的审美,第二就是写法太复杂,第三则是对控制参数传递的实现十分困难。既然现在的 MVVM
框架都支持 props
数据传递来控制参数改变,并且有很多精美的开源组件库,那可不可以自己实现一个属性面板呢?
答案是当然可以的。
bpmn.js
的属性更新操作都是通过 modeling.updateProperties
与 modeling.updateModdlePropertis
这两个 api 来实现的,实现一个属性面板的核心逻辑就在于监听当前选中元素的变化,来控制对应的属性面板的渲染;并且对属性面板的输出结果通过以上两个 api 更新到元素实例上,从而实现完整的属性更新流程。
后续以
Flowable
流程引擎为例进行讲解。
9.1 第一步:设置监听事件寻找选中元素
如何设置当前的选中元素来控制属性面板的渲染,根据第 4.2 小节,可以结合 BpmnPropertiesPanel
组件的写法,通过监听 selection.changed
, elements.changed
, root.added
(或者 import.done
) 几个事件来设置当前元素。这里大致解释一下为什么是这几个事件:
root.added
(或者import.done
):在根元素(Process
节点)创建完成(或者流程导入结束)时,默认是没有办法通过selection
模块拿到选中元素,所以我们可以默认设置根元素为选中元素来渲染属性面板
selection.changed
:这个事件在鼠标点击选中事件改变时会触发,默认返回一个选中元素数组(可能为空),这里我们取数组第一个元素(为空时设置成根元素)来渲染属性面板
elements.changed
:这个事件则是为了控制属性面板的数据回显,因为数据有可能是通过其他方式更新了属性
我们先创建一个 PropertiesPanel
组件:
import { defineComponent, ref } from 'vue' import debounce from 'lodash.debounce' import EventEmitter from '@/utils/EventEmitter' import modelerStore from '@/store/modeler' const PropertiesPanel = defineComponent({ setup() { // 这里通过 pinia 来共享当前的 modeler 实例和选中元素 const modeler = modelerStore() const penal = ref<HTMLDivElement | null>(null) const currentElementId = ref<string | undefined>(undefined) const currentElementType = ref<string | undefined>(undefined) // 在 modeler 实例化结束之后在创建监听函数 (也可以监听 modeler().getModeler 的值来创建) EventEmitter.on('modeler-init', (modeler) => { // 导入完成后默认选中 process 节点 modeler.on('import.done', () => setCurrentElement(null)) // 监听选择事件,修改当前激活的元素以及表单 modeler.on('selection.changed', ({ newSelection }) => setCurrentElement(newSelection[0] || null)) // 监听元素改变事件 modeler.on('element.changed', ({ element }) => { // 保证 修改 "默认流转路径" 等类似需要修改多个元素的事件发生的时候,更新表单的元素与原选中元素不一致。 if (element && element.id === currentElementId.value) setCurrentElement(element) }) }) // 设置选中元素,更新 store;这里做了防抖处理,避免重复触发(可以取消) const setCurrentElement = debounce((element: Shape | Base | Connection | Label | null) => { let activatedElement: BpmnElement | null | undefined = element if (!activatedElement) { activatedElement = modeler.getElRegistry?.find((el) => el.type === 'bpmn:Process') || modeler.getElRegistry?.find((el) => el.type === 'bpmn:Collaboration') if (!activatedElement) { return Logger.prettyError('No Element found!') } } modeler.setElement(markRaw(activatedElement), activatedElement.id) currentElementId.value = activatedElement.id currentElementType.value = activatedElement.type.split(':')[1] }, 100) return () => (<div ref={penal} class="penal"></div>) } })
9.2 第二步:判断元素类型和数据来控制属性面板
在获取到选中元素之后,我们需要根据元素类型来控制显示不同的属性面板组件(这里建议参考官方的属性面板的写法,将判断方法和属性值的更新读取拆分成不同的 hooks
函数)。
比如几个异步属性(asyncBefore
, asyncAfter
, exclusive
),这几个属性只有在选中元素的 superClass
继承链路中有继承 flowable:AsyncCapable
才会体现。所以我们编写一个判断函数:
import { is } from 'bpmn-js/lib/util/ModelUtil' export function isAsynchronous(element: Base): boolean { return is(element, 'flowable:AsyncCapable') }
在 PropertiesPanel
组件中,就可以通过调用该函数判断是否显示对应部分的属性面板
import { defineComponent, ref } from 'vue' const PropertiesPanel = defineComponent({ setup() { // ... return () => ( <div ref={penal} class="penal"> <NCollapse arrow-placement="right"> <ElementGenerations></ElementGenerations> <ElementDocumentations></ElementDocumentations> {isAsynchronous(modeler.getActive!) && ( <ElementAsyncContinuations></ElementAsyncContinuations> )} </NCollapse> </div> ) } }) export default PropertiesPanel
9.3 第三步:实现对应的属性面板更新组件
上一步,我们通过判断元素时候满足异步属性来显示了 ElementAsyncContinuations
组件,但是 ElementAsyncContinuations
组件内部如何实现元素的读取和更新呢?
具体包含哪些属性,可以查看
flowable.json
首先,我们先实现 ElementAsyncContinuations
组件,包含 template
模板和基础的更新方法。
<template> <n-collapse-item name="element-async-continuations"> <template #header> <collapse-title title="异步属性"> <lucide-icon name="Shuffle" /> </collapse-title> </template> <edit-item label="Before" :label-width="120"> <n-switch v-model:value="acBefore" @update:value="updateElementACBefore" /> </edit-item> <edit-item label="After" :label-width="120"> <n-switch v-model:value="acAfter" @update:value="updateElementACAfter" /> </edit-item> <edit-item v-if="showExclusive" label="Exclusive" :label-width="120"> <n-switch v-model:value="acExclusive" @update:value="updateElementACExclusive" /> </edit-item> </n-collapse-item> </template> <script lang="ts"> import { defineComponent } from 'vue' import { mapState } from 'pinia' import modelerStore from '@/store/modeler' import { getACAfter, getACBefore, getACExclusive, setACAfter, setACBefore, setACExclusive } from '@/bo-utils/asynchronousContinuationsUtil' export default defineComponent({ name: 'ElementAsyncContinuations', data() { return { acBefore: false, acAfter: false, acExclusive: false } }, computed: { ...mapState(modelerStore, ['getActive', 'getActiveId']), showExclusive() { return this.acBefore || this.acAfter } }, watch: { getActiveId: { immediate: true, handler() { this.reloadACStatus() } } }, methods: { reloadACStatus() { this.acBefore = getACBefore(this!.getActive) this.acAfter = getACAfter(this!.getActive) this.acExclusive = getACExclusive(this!.getActive) }, updateElementACBefore(value: boolean) { setACBefore(this!.getActive, value) this.reloadACStatus() }, updateElementACAfter(value: boolean) { setACAfter(this!.getActive, value) this.reloadACStatus() }, updateElementACExclusive(value: boolean) { setACExclusive(this!.getActive, value) this.reloadACStatus() } } }) </script>
这里基本实现了根据元素 id 的变化,来更新元素的异步属性配置,并且在属性面板的表单项发生改变时更新该元素的属性。
这里对几个属性的获取和更新方法提取了出来。
import { Base, ModdleElement } from 'diagram-js/lib/model' import editor from '@/store/editor' import modeler from '@/store/modeler' import { is } from 'bpmn-js/lib/util/ModelUtil' ////////// only in element extends bpmn:Task export function getACBefore(element: Base): boolean { return isAsyncBefore(element.businessObject, 'flowable') } export function setACBefore(element: Base, value: boolean) { const modeling = modeler().getModeling // overwrite the legacy `async` property, we will use the more explicit `asyncBefore` modeling.updateModdleProperties(element, element.businessObject, { [`flowable:asyncBefore`]: value, [`flowable:async`]: undefined }) } export function getACAfter(element: Base): boolean { return isAsyncAfter(element.businessObject, 'flowable') } export function setACAfter(element: Base, value: boolean) { const prefix = editor().getProcessEngine const modeling = modeler().getModeling modeling.updateModdleProperties(element, element.businessObject, { [`flowable:asyncAfter`]: value }) } export function getACExclusive(element: Base): boolean { return isExclusive(element.businessObject, 'flowable') } export function setACExclusive(element: Base, value: boolean) { const prefix = editor().getProcessEngine const modeling = modeler().getModeling modeling.updateModdleProperties(element, element.businessObject, { [`flowable:exclusive`]: value }) } //////////////////// helper // 是否支持异步属性 export function isAsynchronous(element: Base): boolean { const prefix = editor().getProcessEngine return is(element, `flowable:AsyncCapable`) } // Returns true if the attribute 'asyncBefore' is set to true. function isAsyncBefore(bo: ModdleElement, prefix: string): boolean { return !!(bo.get(`flowable:asyncBefore`) || bo.get('flowable:async')) } // Returns true if the attribute 'asyncAfter' is set to true. function isAsyncAfter(bo: ModdleElement, prefix: string): boolean { return !!bo.get(`flowable:asyncAfter`) } // Returns true if the attribute 'exclusive' is set to true. function isExclusive(bo: ModdleElement, prefix: string): boolean { return !!bo.get(`flowable:exclusive`) }
这样,我们就得到了一个基础的属性面板。
当前模式只能在 id 更新时才更新数据,不是十分完美。建议在
element.changed
事件发生时通过EventEmitter
来触发业务组件内部的数据更新。
9.4 复杂属性的更新
上一节提到的属性都是作为很简单的属性,可以直接通过 updateModdleProperties(element, moddleElement, { key: value})
的形式来更新,不需要其他步骤。
但是如果这个属性不是一个简单属性,需要如何创建?这里我们以在 Process
节点下创建 ExecutionListener
为例。
首先,我们在 flowable.json
中查看 ExecutionListener
的属性配置。
{ "name": "ExecutionListener", "superClass": ["Element"], "meta": { "allowedIn": [ // ... "bpmn:Process" ] }, "properties": [ { "name": "expression", "isAttr": true, "type": "String" }, { "name": "class", "isAttr": true, "type": "String" }, { "name": "delegateExpression", "isAttr": true, "type": "String" }, { "name": "event", "isAttr": true, "type": "String" }, { "name": "script", "type": "Script" }, { "name": "fields", "type": "Field", "isMany": true } ] }
可以看到这个属性继承了 Element
属性,所以肯定可以创建一个 xml 标签;meta
配置里面表示它允许被插入到 Process
节点中。
但是 Process
节点的定义下并没有支持 ExecutionListener
属性的相关配置,所以我们接着查看 bpmn.json
,发现也没有相关的定义。这时候怎么办呢?
我们仔细研究一下两个文件里面关于 Process
元素的配置:
// flowable.json { "name": "Process", "isAbstract": true, "extends": ["bpmn:Process"], "properties": [ { "name": "candidateStarterGroups", "isAttr": true, "type": "String" }, { "name": "candidateStarterUsers", "isAttr": true, "type": "String" }, { "name": "versionTag", "isAttr": true, "type": "String" }, { "name": "historyTimeToLive", "isAttr": true, "type": "String" }, { "name": "isStartableInTasklist", "isAttr": true, "type": "Boolean", "default": true } ] } // bpmn.json { "name": "Process", "superClass": ["FlowElementsContainer", "CallableElement"], "properties": [ // ... ] } // 向上查找 FlowElementsContainer { "name": "FlowElementsContainer", "isAbstract": true, "superClass": ["BaseElement"], "properties": [ //. .. ] } // 向上查找 BaseElement { "name": "BaseElement", "isAbstract": true, "properties": [ { "name": "id", "isAttr": true, "type": "String", "isId": true }, { "name": "documentation", "type": "Documentation", "isMany": true }, { "name": "extensionDefinitions", "type": "ExtensionDefinition", "isMany": true, "isReference": true }, { "name": "extensionElements", "type": "ExtensionElements" } ] } // 接着查找 ExtensionDefinition 和 ExtensionElements { "name": "ExtensionElements", "properties": [ { "name": "valueRef", "isAttr": true, "isReference": true, "type": "Element" }, { "name": "values", "type": "Element", "isMany": true }, { "name": "extensionAttributeDefinition", "type": "ExtensionAttributeDefinition", "isAttr": true, "isReference": true } ] }
这里可以找到 Process
节点继承的 BaseElement
, 有定义 ExtensionElements
,并且 ExtensionElements
的 values
属性支持配置多个 Element
。所以这里大概就是我们需要关注的地方了。他们之间的大致关系如下:
BaseElement (superClass)--> FlowElementsContainer (superClass)--> Process ↓ hasProperty extensionElements(ExtensionElements) ↓ hasProperty values(Element[]) ↓ hasProperty Element (superClass)--> ExecutionListener
虽然 ExtensionElements
没有声明是继承的 Element
的,但是因为 values
属性是配置的多属性,所以也会在 xml 中插入一个 extensionElements
标签。
既然现在已经找到了这几个元素和属性直接的关系,那么如何给 Process
节点添加 ExecutionListener
就很明了了。
🚀 因为这些属性虽然会在 xml 上体现为一个标签,但是并不会显示在图形界面上,所以一般不能用
BpmnFactory
来创建。
这里我们可以通过
Moddle
模块来创建这类属性实例(包含自定义的其他属性也可以用这种方式)
const canvas = modeler.get<Canvas>('canvas'); const moddle = modeler.get<Moddle>('moddle'); const modeling = modeler.get<Modeling>('modeling'); // 1. 获取 Process 节点 const process: Base = canvas.getRootElement(); const businessObject = process.businessObject // 2. 获取或者创建一个 ExtensionElements 并更新节点业务属性 let extensionElements: ModdleElement & ExtensionElements = businessObject.get('extensionElements') if (!extensionElements) { extensionElements = moddle.create('bpmn:ExtensionElements', { values: [] }) // 设置 $parent, 指向 业务节点实例的 businessObject extensionElements.$parent = process.businessObject // 将 extensionElements 更新到节点上 modeling.updateModdleProperties(process, businessObject, { extensionElements }) } // 3. 创建一个 ExecutionListener 并更新到 ExtensionElements 上 const listener = moddle.create(`flowable:ExecutionListener`, { // ... 这里是相关的属性 // 如果是 Script, Field 这些属性类型,需要像创建 ExecutionListener 这样创建对应的 script, field 实例,并更新到 listener 上 }) listener.$parent = extensionElements // 这里注意 values 数组里面需要把原来的数据填充进来 modeling.updateModdleProperties(element, extensionElements, { values: [...extensionElements.get('values'), listener] })
上文说到更新元素属性可以通过
modeling.updateProperties
与modeling.updateModdlePropertis
来处理,但是这两个方法有一点点细微差别。
updateProperties
:接收两个参数 Element
和 properties
,内部会获取当前 Element
的所有属性配置,进行以下操作:
- 比较
id
是否改变,如果改变则通过elementRegistry.updateId
来更新索引表中的元素 Id,同时更新该对象的 Id 和对象对应的 DI 图形元素的 id
- 如果元素具有
default
属性(用于设置默认路径),则比较该属性的变化并更新
- 遍历
properties
对象,更新element.businessObject
业务属性(如果properties
中有key
等于DI
的,则会更新对应属性到图形配置属性上)
- 如果有
name
属性,或者发生了改变,则会更新Element
对应的Label
标签。
- 计算更新后的元素大小并重新调整位置
updateModdlePropertis
:接收三个参数 Element
, ModdleElement
和 properties
,这个方法内部逻辑比较单一,通过遍历 properties
来读取 ModdleElement
的原始数据,之后再次遍历 properties
将配置的属性更新到 ModdleElement
中。
9.5 快速定位属性类型和更新方式
上面这种方式,需要对 moddleExtension
和 xml
规范比较熟悉才能比较快速找到需要的元素对应的逻辑关系,这种方式无疑耗时巨大。虽然我建议通过阅读 bpmn-js-peroperties-panel
的源码,但是可能很多小伙伴的时间也比较短,没有办法去仔细阅读。
所以这里介绍另外一种方式。
注意,这种方式最好找后端的朋友提供一个配置比较全面的xml,然后将这个 xml 导入到我们的项目中。 之后配置一下 element.click
点击事件的监听,将回调参数打印一下。其中 element.businessObject
的值大致如下:
因为浏览器控制台打印对象时,会提示该对象对应的构造函数名称,我们可以通过这个来判断该使用什么方式。
比如上图中打印的 element.businessObject
提示的类型是 ModdleElement
,所以才可以作为 updateModdleProperties
的第二个参数。
后续的 extensionElements
和 extensionElements.values[0]
都是 ModdleElement
,所以这种类型的数据都需要通过 moddle.create
来创建,其中以 $
符号开头的属性更新或者创建的时候可以忽略,主要是用来表示这个 ModdleElement
实例具体属于那种自定义类型,在 moddle.create
创建时第一个参数就是这个 $type
属性。
在创建好对应的属性实例之后,一步一步更新到 element.businessObject
上就大功告成啦。
这里还有一点需要注意:如果
flowable.json
或者bpmn.json
中定义了某个自定义元素的属性isReference: true
(例如元素的默认流转路径default
),这个体现在 xml 中是作为自定义元素标签的一个 attribute 属性,但是在控制台打印出来则是一个指向该 id 对应的元素的businessObject
对象,这里需要特别注意。
并且在更新该属性的时候,也需要设置为
default: element
,不能直接使用default: 'elementId'
。
10. 自己实现 Palette
因为原生的 Palette
模块不支持手风琴式操作,想显示元素类型名称或者改变面板显示效果,都需要进行比较大的改动。如果要配合自定义的 Renderer
渲染方式,可能改动更大,这个时候就需要我们自己来实现一个 Palette
组件了。
首先,我们先研究一下 bpmn.js
的 PaletteProvider
里面的显示入口配置(这里省略其他内容,主要查看 getPaletteEntries
的返回数据)。
function createAction(type, group, className, title, options) { function createListener(event) { var shape = elementFactory.createShape(assign({ type: type }, options)); if (options) { var di = getDi(shape); di.isExpanded = options.isExpanded; } create.start(event, shape); } var shortType = type.replace(/^bpmn:/, ''); return { group: group, className: className, title: title || translate('Create {type}', { type: shortType }), action: { dragstart: createListener, click: createListener } }; } PaletteProvider.prototype.getPaletteEntries = function(element) { // ... return { 'hand-tool': { group: 'tools', className: 'bpmn-icon-hand-tool', title: translate('Activate the hand tool'), action: { click: function(event) { handTool.activateHand(event); } } }, 'lasso-tool': { group: 'tools', className: 'bpmn-icon-lasso-tool', title: translate('Activate the lasso tool'), action: { click: function(event) { lassoTool.activateSelection(event); } } }, // ... 'create.start-event': createAction( 'bpmn:StartEvent', 'event', 'bpmn-icon-start-event-none', translate('Create StartEvent') ) // ... } }
通过以上代码,可以发现 PaletteProvider
里面的按钮入口主要实现两个类型的功能:
- 开启其他工具模块
- 创建对应类型的元素
既然已经明白了里面的功能了逻辑,那么实现这样的功能就比较简单了
import { defineComponent } from 'vue' import { assign } from 'min-dash' import modelerStore from '@/store/modeler' const Palette = defineComponent({ name: 'Palette', setup() { const store = modelerStore() const createElement = (ev: Event, type: string, options?: any) => { const ElementFactory: ElementFactory = store.getModeler!.get('elementFactory') const create: Create = store.getModeler!.get('create') const shape = ElementFactory.createShape(assign({ type: `bpmn:${type}` }, options)) if (options) { shape.businessObject.di.isExpanded = options.isExpanded } create.start(ev, shape) } const toggleTool = (ev: Event, toolName: string) => { const tool = store.getModeler!.get(toolName) // 工具基本上都有 toggle 方法,用来改变启用状态 tool?.toggle() } return () => ( <div class="palette"> <NCollapse> <NCollapseItem title="工具" name="tools"> 工具部分 <div class="palette-el-item start-event" onClick={(e) => toggleTool(e, 'handTool')} > <i class="bpmn-icon-hand-tool"></i> <span>开始</span> </div> </NCollapseItem> <NCollapseItem title="事件" name="events"> <div class="palette-el-list"> <div class="palette-el-item start-event" onClick={(e) => createElement(e, 'StartEvent')} > <i class="bpmn-icon-start-event-none"></i> <span>开始</span> </div> </div> </NCollapseItem> <NCollapseItem title="任务" name="tasks"> 任务部分 </NCollapseItem> <NCollapseItem title="网关" name="gateways"> 网关部分 </NCollapseItem> </NCollapse> </div> ) } }) export default Palette
11. 官方的增强版元素创建与元素更新插件
在 bpmn.js 9.0
版本之后,官方提供了一个增强版的元素选择器,对 PaletteProvider
和 ContextPad
触发的 PopupMenu (ReplaceProvider)
进行了二次配置。具体使用效果如下:
🚀 这个插件与使用的流程引擎无关,都可以使用。不过需要注意
bpmn.js
的版本
这个插件的主要依赖是 @bpmn-io/element-template-chooser。
我们先进入 element-template-chooser
插件的入口文件。
import ElementTemplateChooserModule from './element-template-chooser'; import ChangeMenuModule from './change-menu'; export default { __depends__: [ ElementTemplateChooserModule, ChangeMenuModule ] };
这里可以看到默认是需要依赖两个插件 ElementTemplateChooserModule
和 ChangeMenuModule
。
export default function ChangeMenu(injector, eventBus) { // ... } ChangeMenu.$inject = [ 'injector', 'eventBus' ]; export default function ElementTemplateChooser( config, eventBus, elementTemplates, changeMenu) { // ... } ElementTemplateChooser.$inject = [ 'config.connectorsExtension', 'eventBus', 'elementTemplates', 'changeMenu' ];
这里需要特别注意,ElementTemplateChooserModule
会依赖 elementTemplates
模块,所以在实例化 Modeler
时也需要引用该插件。
不过因为这个部分会影响 Palette
和 PopupMenu
,所以我们根据官方示例代码使用即可(这里可以不需要 zeebe
模块)。
import BpmnModeler from 'bpmn-js/lib/Modeler'; import { BpmnPropertiesPanelModule, BpmnPropertiesProviderModule, ZeebePropertiesProviderModule, CloudElementTemplatesPropertiesProviderModule } from 'bpmn-js-properties-panel'; import ElementTemplateChooserModule from '@bpmn-io/element-template-chooser'; const modeler = new BpmnModeler({ container: '#canvas', additionalModules: [ ElementTemplateChooserModule, BpmnPropertiesPanelModule, BpmnPropertiesProviderModule, CloudElementTemplatesPropertiesProviderModule ], exporter: { name: 'element-template-chooser-demo', version: '0.0.0' } });