成熟的花卷 · 如何在shell脚本中请求curl ...· 1 年前 · |
寂寞的手术刀 · 当 WPF 混搭上 WinForm,这个 ...· 1 年前 · |
英俊的桔子 · IOS unity游戏中的随机崩溃 - ...· 1 年前 · |
开朗的骆驼 · 在导出csv时,如何删除SQL ...· 1 年前 · |
伤情的香菜 · Vite2 + Vue3 + ...· 1 年前 · |
在画布与属性面板都创建好之后,我们就得到了一个完整的流程图编辑器了。
但是,这个模式下的编辑器没有绑定键盘快捷键,也没有导入导出的按钮和入口,并且也不能支持一键对齐等等功能。所以我们可以在此基础上,实现一个工具栏,来优化用户体验。
首先,我们先实现文件导入的功能。利用
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 () => (
<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>
></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 对象
因为没有绑定键盘事件,所以当前情况下想通过键盘和鼠标滚轮来控制画布缩放层级也不行。
但是
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
撤销恢复个人觉得是最简单的封装之一,毕竟
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
在进行深度自定义之前,这里先介绍
bpmn.js Modeler
本身默认引用的
Modules
的一些配置项。
控制画布区域的元素渲染
defaultFillColor
:元素填充色,例如任务节点中间的空白部分的填充色,默认为
undefined
defaultStrokeColor
:元素边框颜色,也可以理解为路径类元素的颜色,默认为
undefined
,显示为黑色
defaultLabelColor
:
Label
标签字体颜色,默认为
undefined
,显示为黑色
可以通过以下方式更改:
const modeler = new Modeler({
container: 'xx',
bpmnRenderer: {
defaultFillColor: '#eeeeee',
defaultStrokeColor: '#2a2a2a',
defaultLabelColor: '#333333'
控制画布区域的文字渲染
fontFamily
: 文字字体,默认为
'Arial, sans-serif'
fontSize
: 文字大小,默认
12px
fontWeight
: 文字粗细,默认为
'normal'
lineHeight
: 文本行高,默认为 1.2
size
: 生成的文本标签的大小,默认为
{ width: 150, height: 50 }
padding
: 文本标签内间距,默认为 0
style
: 文本标签其他 css 样式
align
: 内部文本对齐方式,默认为
center-top
可以通过传入配置项
textRenderer: {}
更改
控制元素的上下文菜单位置与大小缩放
autoPlace
:是否调用
AutoPlace
模块来实现新元素创建时自动定位,默认为
undefined
,如果配置该属性并设置为
false
的话,在利用
contextPad
创建新元素时需要手动选择新元素位置
scale
:缩放的限制范围,默认为
{ min: 1.0, max: 1.5 }
可以通过传入配置项
contextPad: {}
更改
控制画布区域大小与更新频率
deferUpdate
: 是否配置延迟更新画布改变,默认为
undefined
,如果配置该属性并设置为
false
的话,则会即时更新画布显示(会消耗大量资源)
width
: 宽度,默认为 '100%'
height
: 高度,默认为 '100%'
键盘事件的绑定对象
bindTo
: 设置绑定对象,默认为
undefined
,一般会配置为
document
或者
window
可以通过传入配置项
keyboard: {}
配置,默认快捷键列表如下:
鼠标焦点移动到画布边框位置时开启画布滚动,主要配置触发区域与滚动设置
scrollThresholdIn
:触发滚动的边界距离最大值,默认为
[ 20, 20, 20, 20 ]
scrollThresholdOut
:触发滚动的边界距离最小值,默认为
[ 0, 0, 0, 0 ]
scrollRepeatTimeout
:滚动间隔,默认为 15 ms
scrollStep
:滚动步长。默认为 6
可以通过传入配置项
autoScroll: {}
配置
鼠标滚轮缩放的配置
enabled
: 是否启动鼠标滚轮缩放功能,默认为
undefined
,如果配置该属性并设置为
false
的话,则会禁用鼠标滚动缩放功能
scale
: 缩放倍率,默认为 0.75
可以通过传入配置项
zoomScroll: {}
配置
当然,这部分只是
bpmn.js
与diagram.js
内部的插件模块提供的配置项,在我们的自定义模块也可以通过依赖config
来配置更多的可用配置项,使Modeler
更加灵活
Modeler
的核心插件自定义的讲解
在第四节
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
关于如何扩展原始
Renderer
,
Palette
(这里其实应该是
PaletteProvider
) 和
ContextPad
(这里其实应该是
ContextPadProvider
),霖呆呆和
bpmn
官方都给出了示例。
这里针对核心部分简单讲解一下。
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+行),这里暂时就不放出来了🤪
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]
这部分功能默认是通过
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'
虽然根据 第 4.4 小节可以知道,我们可以通过自定义一个属性面板分组,来插入到原生的
Bpmn Properties Panel
中,但是这样实现,第一是基本不符合国内的审美,第二就是写法太复杂,第三则是对控制参数传递的实现十分困难。既然现在的
MVVM
框架都支持
props
数据传递来控制参数改变,并且有很多精美的开源组件库,那可不可以自己实现一个属性面板呢?
答案是当然可以的。
bpmn.js
的属性更新操作都是通过
modeling.updateProperties
与
modeling.updateModdlePropertis
这两个 api 来实现的,实现一个属性面板的核心逻辑就在于监听当前选中元素的变化,来控制对应的属性面板的渲染;并且对属性面板的输出结果通过以上两个 api 更新到元素实例上,从而实现完整的属性更新流程。
后续以
Flowable
流程引擎为例进行讲解。
如何设置当前的选中元素来控制属性面板的渲染,根据第 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>)
在获取到选中元素之后,我们需要根据元素类型来控制显示不同的属性面板组件(这里建议参考官方的属性面板的写法,将判断方法和属性值的更新读取拆分成不同的
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>
export default PropertiesPanel
上一步,我们通过判断元素时候满足异步属性来显示了
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
来触发业务组件内部的数据更新。
上一节提到的属性都是作为很简单的属性,可以直接通过
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
中。
上面这种方式,需要对
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'
。
因为原生的
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">
class="palette-el-item start-event"
onClick={(e) => toggleTool(e, 'handTool')}
<i class="bpmn-icon-hand-tool"></i>
<span>开始</span>
</NCollapseItem>
<NCollapseItem title="事件" name="events">
<div class="palette-el-list">
class="palette-el-item start-event"
onClick={(e) => createElement(e, 'StartEvent')}
<i class="bpmn-icon-start-event-none"></i>
<span>开始</span>
</NCollapseItem>
<NCollapseItem title="任务" name="tasks">
</NCollapseItem>
<NCollapseItem title="网关" name="gateways">
</NCollapseItem>
</NCollapse>
export default Palette
在
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'
Prompt learning 教学[进阶篇]:简介Prompt框架并给出自然语言处理技术:Few-Shot Prompting、Self-Consistency等;项目实战搭建知识库内容机器人