相关文章推荐
伤情的香菜  ·  Vite2 + Vue3 + ...·  1 年前    · 

5. Toolbar


在画布与属性面板都创建好之后,我们就得到了一个完整的流程图编辑器了。


image


但是,这个模式下的编辑器没有绑定键盘快捷键,也没有导入导出的按钮和入口,并且也不能支持一键对齐等等功能。所以我们可以在此基础上,实现一个工具栏,来优化用户体验。


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 () => (
        <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 对象


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


控制画布区域的元素渲染


  1. defaultFillColor :元素填充色,例如任务节点中间的空白部分的填充色,默认为 undefined


  1. defaultStrokeColor :元素边框颜色,也可以理解为路径类元素的颜色,默认为 undefined ,显示为黑色


  1. defaultLabelColor Label 标签字体颜色,默认为 undefined ,显示为黑色


可以通过以下方式更改:


const modeler = new Modeler({
    container: 'xx',
    bpmnRenderer: {
        defaultFillColor: '#eeeeee',
        defaultStrokeColor: '#2a2a2a',
        defaultLabelColor: '#333333'


5.2 TextRenderer Configuration


控制画布区域的文字渲染


  1. fontFamily : 文字字体,默认为 'Arial, sans-serif'


  1. fontSize : 文字大小,默认 12px


  1. fontWeight : 文字粗细,默认为 'normal'


  1. lineHeight : 文本行高,默认为 1.2


  1. size : 生成的文本标签的大小,默认为 { width: 150, height: 50 }


  1. padding : 文本标签内间距,默认为 0


  1. style : 文本标签其他 css 样式


  1. align : 内部文本对齐方式,默认为 center-top


可以通过传入配置项 textRenderer: {} 更改


5.3 ContextPad Configuration


控制元素的上下文菜单位置与大小缩放


  1. autoPlace :是否调用 AutoPlace 模块来实现新元素创建时自动定位,默认为 undefined ,如果配置该属性并设置为 false 的话,在利用 contextPad 创建新元素时需要手动选择新元素位置


  1. scale :缩放的限制范围,默认为 { min: 1.0, max: 1.5 }


可以通过传入配置项 contextPad: {} 更改


5.4 Canvas Configuration


控制画布区域大小与更新频率


  1. deferUpdate : 是否配置延迟更新画布改变,默认为 undefined ,如果配置该属性并设置为 false 的话,则会即时更新画布显示(会消耗大量资源)


  1. width : 宽度,默认为 '100%'


  1. height : 高度,默认为 '100%'


5.5 Keyboard Configuration


键盘事件的绑定对象


  1. bindTo : 设置绑定对象,默认为 undefined ,一般会配置为 document 或者 window


可以通过传入配置项 keyboard: {} 配置,默认快捷键列表如下:


image


5.6 AutoScroll Configuration


鼠标焦点移动到画布边框位置时开启画布滚动,主要配置触发区域与滚动设置


  1. scrollThresholdIn :触发滚动的边界距离最大值,默认为 [ 20, 20, 20, 20 ]


  1. scrollThresholdOut :触发滚动的边界距离最小值,默认为 [ 0, 0, 0, 0 ]


  1. scrollRepeatTimeout :滚动间隔,默认为 15 ms


  1. scrollStep :滚动步长。默认为 6


可以通过传入配置项 autoScroll: {} 配置


5.7 ZoomScroll Configuration


鼠标滚轮缩放的配置


  1. enabled : 是否启动鼠标滚轮缩放功能,默认为 undefined ,如果配置该属性并设置为 false 的话,则会禁用鼠标滚动缩放功能


  1. 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 文件,该描述文件(对象)包含以下几个属性:


  1. name : 该部分扩展的名称,一般根据流程引擎来命名,字符串格式


  1. uri : 统一资源标识符,一般是一个地址字符串


  1. prefix : 属性或者元素统一前缀,小写字符串格式


  1. xml : 格式转换时的配置,一般用来配置 { "tagAlias": "lowerCase" } , 表示会将标签名转换为小写驼峰,可省略


  1. types : 核心部分,用来声明元素和属性,以及扩展原有属性等,对象数组格式


  1. enumerations : 枚举值定义部分,可以用来定义 types 中某个配置属性的可选值


  1. 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 官方都给出了示例。


  1. 官方示例/bpmn-js-example-custom-elements


  1. 霖呆呆的文档地址 全网最详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 中间的小扳手 🔧 来触发的,主要是用来更改当前元素的类型。很多小伙伴反馈说其实里面的很多选项都不需要,这里对如何实现该部分更改进行说明。


  1. 通过 css 隐藏 dev.djs-popup-body 节点下的多余节点,因为不同的元素类型有不同的 css class 类名,可以通过类名设置 display: none 隐藏


  1. 直接修改 ReplaceOptions 的数据


import { TASK } from 'bpmn-js/lib/features/replace/ReplaceOptions';
// 移除多余的选项
GATEWAY.splice(2, GATEWAY.length);
// 注意需要在 new Modeler 之前,并且这种方式不支持 cdn 引入


  1. 修改 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 ) 几个事件来设置当前元素。这里大致解释一下为什么是这几个事件:


  1. root.added (或者 import.done ):在根元素( Process 节点)创建完成(或者流程导入结束)时,默认是没有办法通过 selection 模块拿到选中元素,所以我们可以默认设置根元素为选中元素来渲染属性面板


  1. selection.changed :这个事件在鼠标点击选中事件改变时会触发,默认返回一个选中元素数组(可能为空),这里我们取数组第一个元素(为空时设置成根元素)来渲染属性面板


  1. 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>
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 的所有属性配置,进行以下操作:


  1. 比较 id 是否改变,如果改变则通过 elementRegistry.updateId 来更新索引表中的元素 Id,同时更新该对象的 Id 和对象对应的 DI 图形元素的 id


  1. 如果元素具有 default 属性(用于设置默认路径),则比较该属性的变化并更新


  1. 遍历 properties 对象,更新 element.businessObject 业务属性(如果 properties 中有 key 等于 DI 的,则会更新对应属性到图形配置属性上)


  1. 如果有 name 属性,或者发生了改变,则会更新 Element 对应的 Label 标签。


  1. 计算更新后的元素大小并重新调整位置


updateModdlePropertis :接收三个参数 Element , ModdleElement properties ,这个方法内部逻辑比较单一,通过遍历 properties 来读取 ModdleElement 的原始数据,之后再次遍历 properties 将配置的属性更新到 ModdleElement 中。


9.5 快速定位属性类型和更新方式


上面这种方式,需要对 moddleExtension xml 规范比较熟悉才能比较快速找到需要的元素对应的逻辑关系,这种方式无疑耗时巨大。虽然我建议通过阅读 bpmn-js-peroperties-panel 的源码,但是可能很多小伙伴的时间也比较短,没有办法去仔细阅读。


所以这里介绍另外一种方式。


注意,这种方式最好找后端的朋友提供一个配置比较全面的xml,然后将这个 xml 导入到我们的项目中。 之后配置一下 element.click 点击事件的监听,将回调参数打印一下。其中 element.businessObject 的值大致如下:


image


因为浏览器控制台打印对象时,会提示该对象对应的构造函数名称,我们可以通过这个来判断该使用什么方式。


比如上图中打印的 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 里面的按钮入口主要实现两个类型的功能:


  1. 开启其他工具模块


  1. 创建对应类型的元素


既然已经明白了里面的功能了逻辑,那么实现这样的功能就比较简单了


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


11. 官方的增强版元素创建与元素更新插件


bpmn.js 9.0 版本之后,官方提供了一个增强版的元素选择器,对 PaletteProvider ContextPad 触发的 PopupMenu (ReplaceProvider) 进行了二次配置。具体使用效果如下:


image

image


🚀 这个插件与使用的流程引擎无关,都可以使用。不过需要注意 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等;项目实战搭建知识库内容机器人