上一节中有提到过,features 目录中,主要是一些 功能扩展模块 ,包含了 palette 画板、contextPad 上下文菜单等,此次就用两章的篇幅来讲一下这里面的一些常用模块(bpmn-js 中也有 features 目录,其中有一部分是对 diagram-js 中的 features 的功能的具体实现,也有针对 BPMN 特定的处理模块)。
features中的功能大多数都和其他模块互相嵌套,有的会通过依赖注入的形式使用别的模块的方法,有的则是通过EventBus来激活或者依赖事件的上下文对象。所以对这部分的内容描述可能会比较散乱,希望大家多多包涵,也可以提出意见,我及时修改或者补充。
Palette 画板工具栏
在之前的 Canvas 和 Factory 两章中,我们知道可以通过 API 来向画布中添加元素,但是这种方式显然无法正常提供给用户使用。
所以,我们需要一个类似 PS 中的"工具栏"之类的角色,来提供给用户 创建元素和操作元素 的能力。
Palette直译过来是 "调色板",这里根据作用做了一些改动。
在 bpmn-js 的默认 Modeler 编辑模式下,体现为:
也就是一个固定在画布左侧的元素区域,在整个 DOM 树结构中,体现为一个和 SVG 标签同级的 DIV 元素,样式类名为 djs-palette。
基于
diagram-js实现的功能,大部分元素的类名都会带有djs-*的前缀。
但是在 diagram-js 的设计中,Palette 只是作为一个 固定在左侧用来显示已注册的工具 的区域,具体的工具则需要开发者通过向 Palette 中注册相应的 Provider 来完成。
作为工具栏提供的能力
虽然 Palette 本身不提供任何工具,但是作为显示所有注册工具的区域,它自然会提供对该区域的 "显示控制"以及对注册工具的显示样式处理。
整个 Palette 类包含以下方法:
registerProvider:注册工具getEntries:返回所有已注册的工具trigger:触发指定工具的事件triggerEntry:实际上的工具事件触发方法close:关闭(隐藏)工具栏open:打开(显示)工具栏toggle:切换工具栏区域的显示隐藏状态updateToolHighlight:调整工具栏中的 "工具" 的高亮状态(即是否在使用中)isOpen:获取工具栏的显示隐藏状态isActiveTool:判断指定工具是否在使用中_getParentContainer:获取父级元素_rebuild:重建工具栏_init:初始化工具栏,并为工具栏中的每个元素注册代理事件_getProviders:获取所有已注册的工具元素定义_toggleState:实际的工具栏显示状态调整逻辑_update:根据已注册的工具元素定义,创建对应的 DOM 元素;并调用open方法显示工具栏_layoutChanged:触发一次工具栏的重新布局_needsCollapse:根据画布高度和注册的工具元素个数,来判断是否需要显示为 两行
带
_前缀的可以认为是 私有方法,但是可以调用。
还包含一个静态属性 HTML_MARKUP,用来作为工具栏的整体框架。
js
Palette.HTML_MARKUP =
'<div class="djs-palette">' +
'<div class="djs-palette-entries"></div>' +
'<div class="djs-palette-toggle"></div>' +
'</div>';
其中 djs-palette-entries 部分是在正常显示的时候,用来放置所有工具元素的部分,而 djs-palette-toggle,则是在工具栏处于关闭(隐藏)状态下,用来重新打开工具栏。
当然,除了元素和类名,Palette 还有对应的 CSS 样式部分,感兴趣的同学可以直接在 github:diagram-js.css 查看源码。
这里只对一些特殊部分进行说明:
css
/*工具栏默认是一个用绝对定位固定在左上角的元素,宽度为 46px */
.djs-palette {
position: absolute;
left: 20px;
top: 20px;
box-sizing: border-box;
width: 48px;
}
/* 特殊的分割线元素,用来标识不同分组 */
.djs-palette .separator {
margin: 5px;
padding-top: 5px;
border: none;
border-bottom: solid 1px var(--palette-separator-color);
clear: both;
}
/* 每个工具元素默认高宽都是 46 像素 */
.djs-palette .entry,
.djs-palette .djs-palette-toggle {
width: 46px;
height: 46px;
line-height: 46px;
cursor: default;
}
/* 高亮工具,改变字体颜色 */
.djs-palette .highlighted-entry {
color: var(--palette-entry-selected-color) !important;
}
/* 当改变成双栏布局时,会切换宽度为 94px */
.djs-palette.two-column.open {
width: 94px;
}
从上面可以看出,Palette 在这里只是作为控制画板工具栏的角色,为内部的元素提供了一个标准的事件处理和样式规范。
在 diagram.init 事件发生时,Palette 会进行一次初始化,整个过程会进行如下的函数调用:
text
_rebuild() -> _getProviders() -> _init() -> _update() -> getEntries() -> open()
其中 _getProviders() 就是获取所有已注册的工具元素定义,但是为了能处理多个 Provider 注册的情况,这里会通过 EventBus 来处理。
js
Palette.prototype.registerProvider = function(priority, provider) {
if (!provider) {
provider = priority;
priority = DEFAULT_PRIORITY;
}
this._eventBus.on('palette.getProviders', priority, function(event) {
event.providers.push(provider);
});
this._rebuild();
};
Palette.prototype._getProviders = function(id) {
var event = this._eventBus.createEvent({
type: 'palette.getProviders',
providers: []
});
this._eventBus.fire(event);
return event.providers;
};
当我们需要在画板工具栏里面添加工具元素的时候,可以通过 registerProvider 来注册一个 palette.getProviders 的监听事件,向 事件对象中的 providers 数组 push 我们需要增加的内容。
当工具栏重建时,则通过发送这个 palette.getProviders 事件得到的返回值中的 providers,来进行渲染。
这里也正是借助了
EventBus的同一个事件同一次触发过程中共用一个事件对象的特性来实现的。
PaletteProvider 的规范
通过 registerProvider 方法可以得知,我们在向 Palette 中注册工具元素时,传递进去的 providers 应该是一个对象实例,但是这个对象实例具体应该是什么格式,就需要通过 getProviders() 后面的逻辑来判断了。
通过上文对 Palette 的所有原型方法的说明,可以看出 _init 主要是处理外层的DOM框架,而 _update 才是通过所有 providers 来渲染工具元素。
js
Palette.prototype._update = function() {
var entriesContainer = domQuery('.djs-palette-entries', this._container),
entries = this._entries = this.getEntries();
// 清空原有的工具元素
domClear(entriesContainer);
// 遍历所有工具元素,重新生成工具元素
forEach(entries, function(entry, id) {
var grouping = entry.group || 'default';
var container = domQuery('[data-group=' + escapeCSS(grouping) + ']', entriesContainer);
if (!container) {
container = domify('<div class="group"></div>');
domAttr(container, 'data-group', grouping);
entriesContainer.appendChild(container);
}
var html = entry.html || (
entry.separator ?
'<hr class="separator" />' :
'<div class="entry" draggable="true"></div>');
var control = domify(html);
container.appendChild(control);
if (!entry.separator) {
domAttr(control, 'data-action', id);
if (entry.title) {
domAttr(control, 'title', entry.title);
}
if (entry.className) {
addClasses(control, entry.className);
}
if (entry.imageUrl) {
var image = domify('<img>');
domAttr(image, 'src', entry.imageUrl);
control.appendChild(image);
}
}
});
this.open();
};
Palette.prototype.getEntries = function() {
var providers = this._getProviders();
return providers.reduce(addPaletteEntries, {});
};
function addPaletteEntries(entries, provider) {
var entriesOrUpdater = provider.getPaletteEntries();
if (isFunction(entriesOrUpdater)) {
return entriesOrUpdater(entries);
}
forEach(entriesOrUpdater, function(entry, id) {
entries[id] = entry;
});
return entries;
}
在 _update() 的开始阶段,就会通过遍历所有 provider,将 provider.getPaletteEntries() 方法的返回值组合成一个完整对象 entries,然后再遍历 entires 的属性,来生成相应的工具元素,并显示到工具栏中。
所以,每个 provider 必须包含一个 getPaletteEntries 方法,并且该方法返回的是一个对象。
通过遍历生成工具元素的过程中,又不难看出,getPaletteEntries 返回的对象中,属性名会在生成工具元素时作为 唯一ID,而它的属性则是控制其在工具栏中的显示状态和时间响应。
其中又可以根据 separator 属性来判断其是否是一个 分割线 ,或者根据 group 属性来确定哪些工具元素是属于 "同一分组"。
即工具元素的显示顺序,需要根据对象属性顺序以及
group指定的分组顺序来确定,并且group的注册顺序优先级更高。
综合一下,PaletteProvider 的格式必须符合以下类型要求:
typescript
export type PaletteEntryAction = (event: Event, autoActivate?: boolean) => any
export type PaletteEntry = {
action: PaletteEntryAction | Record<'click' | 'dragstart' | 'hover', PaletteEntryAction>;
className?: string;
group?: string;
html?: string;
imageUrl?: string;
separator?: boolean;
title?: string;
};
export type PaletteEntries = Record<string, PaletteEntry>;
export type PaletteEntriesCallback = (entries: PaletteEntries) => PaletteEntries;
export default interface PaletteProvider {
getPaletteEntries: () => PaletteEntriesCallback | PaletteEntries;
}
编写一个 PaletteProvider
虽然官方给出的 PaletteProvider 的定义是一个 实例类型 interface,但是为了更加契合 diagram-js 中 DIDI 的设计思想,这里还是推荐编写一个 类 来实现。
所以我们可以写出这样一个演示的 Provider:
js
class DemoPaletteProvider {
constructor(palette) {
palette.registerProvider(this)
}
getPaletteEntries() {
return {
'tool-one': {
group: 'tools',
className: 'tool-item',
title: '工具 1',
action: {
click() {
window.alert('使用工具1')
}
}
},
'tool-separator': {
group: 'tools',
separator: true
},
'element-one': {
group: 'elements',
className: 'element-creator',
title: '元素 1',
action: {
click() {
window.alert('创建元素1')
}
}
},
'element-separator': {
group: 'elements',
separator: true
}
}
}
}
DemoPaletteProvider.$inject = ['palette']
然后在创建 Diagram 实例时将该模块引入:
js
const djs = new Diagram({
canvas: { container: document.getElementById('canvas') },
modules: [
Palette,
{
__init__: ['demoPaletteProvider'],
demoPaletteProvider: ['type', DemoPaletteProvider]
}
]
})
此时网页上会显示这样的内容:
当我们点击对应的 div.entry 元素时,就会弹出相应的提示:
然后,我们只需要在对应的 action 中编写相应的逻辑,就可以了。
多个 PaletteProvider 的处理方式
那如果我希望覆盖原来的某一个工具元素,或者需要新增工具元素,需要怎么做呢?
其实从上文 Palette 渲染工具元素的部分就可以很容易想到解决方式了。
因为最终生成的 entries 是每一个 provider 的 getPaletteEntries() 方法返回值的 "对象集合",所以只需要通过编写一个新的 provider,通过同名属性来覆盖原有的工具元素定义即可;而其他非同名属性,则会根据 group 指定的分组,插入到工具栏中。
例如:
js
export class DemoPaletteProvider2 {
constructor(palette) {
palette.registerProvider(this)
}
getPaletteEntries() {
return {
'tool-one': {
group: 'tools',
className: 'tool-item',
title: '新工具 1',
action: {
click() {
window.alert('使用 新的工具1')
}
}
},
'element-two': {
group: 'elements',
className: 'element-creator',
title: '元素 2',
action: {
click() {
window.alert('创建元素2')
}
}
}
}
}
}
DemoPaletteProvider2.$inject = ['palette']
其中我们定义了一个新的工具1,用 tool-one 来顶替原来的工具1,并且在 elements 分组中增加了一个 元素2 的创建按钮,此时界面会显示为:
但是
separator分割线并不会显示在分组最后,这也是因为Palette没有对每一个分组限制其分割线的数量,也没有调整其位置的逻辑,而是按照定义的顺序来进行显示。
但是,这种方式仅仅只能 替换或者插入新的工具元素,如果我们需要删除某个工具或者某一系列的工具,该怎么处理呢?
这就需要借助 diagram-js 依赖的 DIDI 模式,也就是 Injector 来实现。
在 "Injector 依赖注入模式实现" 一章中,我们知道在 new Diagram 时传入的 modules 数组,最终会通过 Injector 来完成各个模块之间的依赖处理和实例化,并且 modules 数组中的内容会被遍历解析成一个 对象 形式。
所以,在需要 调整并移除原有的 PaletteProvider 中的某些元素时,只需要重新编写一个 PaletteProvider 并将其在 modules 数组中设置为与目标 Provider 一样的属性名。
例如我们要使用上文的 DemoPaletteProvider2 去完全覆盖 DemoPaletteProvider,只需要将代码改成如下形式:
js
const djs = new Diagram({
canvas: { container: document.getElementById('canvas') },
modules: [
Palette,
{
__init__: ['demoPaletteProvider'],
demoPaletteProvider: ['type', DemoPaletteProvider]
},
{
demoPaletteProvider: ['type', DemoPaletteProvider2] // 同名顶替
}
]
})
此时页面显示如下效果:
当然,这种效果属于 完全替换,因为 DemoPaletteProvider 与 DemoPaletteProvider2 两者注册的工具元素完全不同 ,如果需要 删除部分工具元素 的话,除了 复制原始 PaletteProvider 的代码,删除不需要的部分 之外,也可以使用继承的方式来实现。
当然在使用时依然需要使用同名的模块定义来替换原来的部分
例如:
js
export class DemoPaletteProvider3 extends DemoPaletteProvider {
constructor(palette) {
super(palette)
}
getPaletteEntries() {
const actions = super.getPaletteEntries()
delete actions['element-separator'] // 删除指定的元素
return actions
}
}
DemoPaletteProvider3.$inject = ['palette']
然后再重新进行引用:
js
const djs = new Diagram({
canvas: { container: document.getElementById('canvas') },
modules: [
Palette,
{
__init__: ['demoPaletteProvider'],
demoPaletteProvider: ['type', DemoPaletteProvider]
},
{
__init__: ['demoPaletteProvider2'],
demoPaletteProvider: ['type', DemoPaletteProvider3], // 同名替换
demoPaletteProvider2: ['type', DemoPaletteProvider2] // 注册新的
}
]
})
此时会变成如下效果:
当然,这几种方式都是使用 官方提供的能力,通过基础的 JS 代码来实现最简单的应用 ,如果我们需要在官方的 Palette 中调用某些组件库的方法,就需要通过下面这种途径了。
借助 Injector 完成其他交互
在之前的内容中,有提到过 diagram-js 的核心是依赖的 Injector 来实现依赖注入的,而在依赖声明时,如果是 type 作为 关键字 声明的模块,默认会当成一个 构造函数(类) 来进行实例化,而这个类的 静态属性 $inject 则用来声明这个模块所依赖的其他模块实例,会按照声明顺序作为参数提供给构造函数实例化的时候进行使用。
而在 new Diagram(options) 时,传入的参数 options 会作为一个 基础对象 绑定到 config 属性上,作为整个依赖系统的一个核心依赖。
所以,如果我们 在编写某个模块的构造函数(类)时,可以通过 $inject 属性添加对 config 的引用,从而实现对外部参数或者对象的调用。
例如,我们将上文的 DemoPaletteProvider2 修改为下述内容:
js
export class DemoPaletteProvider2 {
constructor(config, palette) {
this._config = config
palette.registerProvider(this)
}
getPaletteEntries() {
const config = this._config
return {
'tool-one': {
group: 'tools',
className: 'tool-item',
title: '新工具 1',
action: {
click() {
window.alert('使用 新的工具1')
}
}
},
'element-two': {
group: 'elements',
className: 'element-creator',
title: '元素 2',
action: {
click() {
window.alert('创建元素2')
}
}
},
'event-one': {
group: 'events',
className: 'events',
title: '事件1',
action: {
click() {
console.log(config)
}
}
}
}
}
}
DemoPaletteProvider2.$inject = ['config', 'palette']
然后页面显示以及打印结果如下:
这样,我们就可以通过 config 对象来实现对外部方法的调用了。
例如,现在外部有一个 ElementPlus 的弹窗,需要通过 Palette 中的某个工具来打开,则可以对上述的代码进行修改:
vue
// SFC 文件
<script setup>
import { onMounted, ref } from 'vue'
import Diagram from 'diagram-js'
import Palette from 'diagram-js/lib/features/palette'
import { DemoPaletteProvider, DemoPaletteProvider2 } from '../modules/paletteProviders.js'
const dialogVisible = ref(false)
const toggleDialog = () => {
dialogVisible.value = !dialogVisible.value
}
onMounted(() => {
const djs = new Diagram({
canvas: { container: document.getElementById('canvas') },
modules: [
Palette,
{
__init__: ['demoPaletteProvider', 'demoPaletteProvider2'],
demoPaletteProvider: ['type', DemoPaletteProvider],
demoPaletteProvider2: ['type', DemoPaletteProvider2]
}
],
componentMethods: {
toggleDialog
}
]
})
})
</script>
<template>
<div id="canvas" class="canvas"></div>
<el-dialog v-model="dialogVisible" title="Tips" width="30%">
<span>This is a message</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="dialogVisible = false"> Confirm </el-button>
</span>
</template>
</el-dialog>
</template>
<style>
.canvas {
width: 100%;
height: 100%;
}
.bg-gray {
background-color: #888888; // 凸显一下
}
</style>
然后我们再在 demoPaletteProvider2 中进行使用:
js
export class DemoPaletteProvider2 {
constructor(config, palette) {
this._config = config
palette.registerProvider(this)
}
getPaletteEntries() {
const config = this._config
return {
// ...
'event-one': {
group: 'events',
className: 'events bg-gray',
title: '事件1',
action: {
click() {
if (config.componentMethods) {
config.componentMethods.toggleDialog()
}
}
}
}
}
}
}
DemoPaletteProvider2.$inject = ['config', 'palette']
则我们可以得到这样的效果:
当然,我们也可以通过 模块或者闭包 的方式,在
paletteProvider中直接引用,也能达到这样的效果;但是这样会缺少一定的安全性,并且闭包变量直接绑定,也很难做到及时清理。
另外还有一点就是,如果这个方法里面有this的话,在将其配置到options参数中时,还需要绑定对应的this指向,不然可能会引发其他错误。
ContextPad 元素上下文菜单
与 Palette 的作用类似,ContextPad 的作用就是为 当前指定元素提供修改或者便捷操作,让用户能够更加方便快速的完成对这个元素的相关操作。
除了作用之外,在代码设计上,ContextPad 也保持了和 Palette 一样的设计思路。本身 ContextPad 模块仅提供一个控制上下文菜单状态与基础布局的功能,通过注册对应的 ContextPadProvider 来实现不同情况下的菜单选项。
作为上下文菜单提供的能力
与 Palette 类似,ContextPad 对外提供了这些方法:
registerProvider:注册元素对应的菜单项getEntries:获取某个元素/元素组对应的菜单项trigger:触发事件triggerEntry:实际的事件执行函数getPad:获取当前菜单所在的覆盖物图层,创建菜单元素并进行事件代理open:打开/显示菜单close:关闭/隐藏菜单isOpen:是否是打开/显示状态isShown:是否是关闭/隐藏状态_init:初始化依赖,注册相应的事件_getProviders:实际上的菜单项获取方法_updateAndOpen:更新位置并显示菜单图层_getPosition:获取菜单对应的显示位置
带
_前缀的可以认为是 私有方法,但是可以调用。
当然,与 Palette 不同的是,ContextPad 只会 "在有需要的时候"显示,而不是一直固定显示在左侧。所以 ContextPad 在初始化时会注册相应的事件,在事件触发时才会调用 open 方法重新打开菜单面板。
js
export default function ContextPad(canvas, config, eventBus, overlays) {
this._canvas = canvas;
this._eventBus = eventBus;
this._overlays = overlays;
var scale = isDefined(config && config.scale) ? config.scale : {
min: 1,
max: 1.5
};
this._overlaysConfig = {
scale: scale
};
this._current = null;
this._init();
}
ContextPad.$inject = [
'canvas',
'config.contextPad',
'eventBus',
'overlays'
];
ContextPad.prototype._init = function() {
var self = this;
this._eventBus.on('selection.changed', function(event) {
var selection = event.newSelection;
var target = selection.length
? selection.length === 1
? selection[0]
: selection
: null;
if (target) {
self.open(target, true);
} else {
self.close();
}
});
this._eventBus.on('elements.changed', function(event) {
var elements = event.elements,
current = self._current;
if (!current) {
return;
}
var currentTarget = current.target;
var currentChanged = some(
isArray(currentTarget) ? currentTarget : [ currentTarget ],
function(element) {
return includes(elements, element);
}
);
if (currentChanged) {
self.open(currentTarget, true);
}
});
};
以上就是 ContextPad 的初始化部分,可见它在初始化时 只是记录了 canvas、overlays 与 eventBus 三个模块实例,并且设置了 selection.changed 与 elements.changed 两个监听事件;当 选中元素变化或者元素属性改变时,则会在第一个选择元素或者第一个发生属性更新的元素这里,打开上下文菜单。
_current属性,是一个对象属性;在菜单需要打开时,会记录三个属性:
target:当前菜单打开时的目标选择元素entries:通过getEntries得到的该元素对应的菜单项pad:通过getPad得到的该元素对应的菜单面板所在的overlay图层
当菜单需要被打开时(调用 open 方法),会先判断新的 target 元素与 _crrent.target 是否一致,不一致则会 先关闭再创建新的菜单。
js
ContextPad.prototype.open = function(target, force) {
if (!force && this.isOpen(target)) {
return;
}
this.close();
this._updateAndOpen(target);
};
ContextPad.prototype._updateAndOpen = function(target) {
var entries = this.getEntries(target),
pad = this.getPad(target),
html = pad.html,
image;
forEach(entries, function(entry, id) {
var grouping = entry.group || 'default',
control = domify(entry.html || '<div class="entry" draggable="true"></div>'),
container;
domAttr(control, 'data-action', id);
container = domQuery('[data-group=' + escapeCSS(grouping) + ']', html);
if (!container) {
container = domify('<div class="group"></div>');
domAttr(container, 'data-group', grouping);
html.appendChild(container);
}
// ...
});
domClasses(html).add('open');
this._current = {
target: target,
entries: entries,
pad: pad
};
this._eventBus.fire('contextPad.open', { current: this._current });
};
ContextPad.prototype.getPad = function(target) {
if (this.isOpen()) {
return this._current.pad;
}
var self = this;
var overlays = this._overlays;
var html = domify('<div class="djs-context-pad"></div>');
var position = this._getPosition(target);
var overlaysConfig = assign({
html: html
}, this._overlaysConfig, position);
domDelegate.bind(html, entrySelector, 'click', function(event) {
self.trigger('click', event);
});
domDelegate.bind(html, entrySelector, 'dragstart', function(event) {
self.trigger('dragstart', event);
});
domEvent.bind(html, 'mousedown', function(event) {
event.stopPropagation();
});
var activeRootElement = this._canvas.getRootElement();
this._overlayId = overlays.add(activeRootElement, 'context-pad', overlaysConfig);
var pad = overlays.get(this._overlayId);
this._eventBus.fire('contextPad.create', {
target: target,
pad: pad
});
return pad;
};
这部分代码,则是体现了 上下文菜单的 dom 结构和菜单项的处理方式。
与 Palette 一样,菜单的 dom 结构都是固定的,也有自带的默认样式;并且也通过代理的形式,来完成每个菜单项事件的触发。这里就不再赘述。
但是,由于 ContextPad 的菜单项需要与当前选中元素相关联,所以在 getEntries 方法中,与 Palette 有所区别。
getEntries 方法与 Palette 的不同
回顾上文 "PaletteProvider 的规范",Palette 的 getEntries 方法,只需要将所有注册的 Provider 中的 getPaletteEntries() 方法的返回值组合成一个 entries 对象即可,渲染的时候会根据对象的 key 来进行遍历渲染。
而 ContextPad 的 Entries 则需要关联当前对象 Shape/Connection;并且针对 单个元素和多个元素选中,还需要显示不同的菜单。
所以,ContextPad 的 getEntries 方法在此基础上,进行了细微改动:
js
ContextPad.prototype.getEntries = function(target) {
var providers = this._getProviders();
// 定义不同的菜单项获取方法
var provideFn = isArray(target)
? 'getMultiElementContextPadEntries'
: 'getContextPadEntries';
var entries = {};
forEach(providers, function(provider) {
if (!isFunction(provider[provideFn])) {
return;
}
// 传递当前的 target 元素到每个 provider 的菜单项方法中
var entriesOrUpdater = provider[provideFn](target);
if (isFunction(entriesOrUpdater)) {
entries = entriesOrUpdater(entries);
} else {
forEach(entriesOrUpdater, function(entry, id) {
entries[id] = entry;
});
}
});
return entries;
};
// 与 palette 一致
ContextPad.prototype._getProviders = function() {
var event = this._eventBus.createEvent({
type: 'contextPad.getProviders',
providers: []
});
this._eventBus.fire(event);
return event.providers;
};
即:
- 针对单个元素和多个元素选中时,
ContextPadProvider需要有不同的菜单项方法 - 每个
provider的getXXXEntries方法可以接收一个target参数,进行不同的菜单项返回
所以,我们可以得到一个 ContextPadProvider 的构造函数规范。
ContextPadProvider 的规范
typescript
import { Element } from 'diagram-js/lib/features/model/Types';
import { ContextPadTarget } from './ContextPad';
export type ContextPadEntryAction = (event: Event, target: ContextPadTarget<ElementType>, autoActivate: boolean) => void;
export type ContextPadEntry<ElementType extends Element = Element> = {
action: ContextPadEntryAction | Record<'click' | 'dragstart', ContextPadEntryAction>;
className?: string;
group?: string;
html?: string;
imageUrl?: string;
title?: string;
};
export type ContextPadEntries<ElementType extends Element = Element> = Record<string, ContextPadEntry<ElementType>>;
export type ContextPadEntriesCallback<ElementType extends Element = Element> = (entries: ContextPadEntries<ElementType>) => ContextPadEntries<ElementType>;
export default interface ContextPadProvider<ElementType extends Element = Element> {
// 单个元素选中时会调用的方法
getContextPadEntries?: (element: ElementType) => ContextPadEntriesCallback<ElementType> | ContextPadEntries<ElementType>;
// 多个元素选中时会调用的方法
getMultiElementContextPadEntries?: (elements: ElementType[]) => ContextPadEntriesCallback<ElementType> | ContextPadEntries<ElementType>;
}
在实际使用中,ContextPadProvider 与 PaletteProvider 十分相似,甚至可以看做 ContextPadProvider 只是比 PaletteProvider 多了一个 getMultiElementContextPadEntries 方法。
所以,我们在编写自定义的 ContextPadProvider 时,也可以参照之前 PaletteProvider 的写法。
实现两个 ContextPadProvider
与 PaletteProvider 还有的一点区别就是,ContextPad 对 group 的处理,并不是使用分割线,而是通过换行进行处理。
js
export class DemoContextPadProvider {
constructor(contextPad) {
contextPad.registerProvider(this)
}
getContextPadEntries() {
return {
'tool-one': {
group: 'tools',
className: 'tool-item LikeActive',
title: '工具 1',
action: {
click() {
window.alert('使用工具1')
}
}
},
'element-one': {
group: 'elements',
className: 'element-creator CutePetReportActive',
title: '元素 1',
action: {
click() {
window.alert('创建元素1')
}
}
}
}
}
}
DemoContextPadProvider.$inject = ['contextPad']
export class DemoContextPadProvider2 {
constructor(config, contextPad) {
this._config = config
contextPad.registerProvider(this)
}
getContextPadEntries() {
const config = this._config
return {
'tool-one': {
group: 'tools',
className: 'tool-item EmotionalMutualAssistanceActive',
title: '新工具 1',
action: {
click() {
window.alert('使用 新的工具1')
}
}
},
'element-two': {
group: 'elements',
className: 'element-creator FinancialExchangeActive',
title: '元素 2',
action: {
click() {
window.alert('创建元素2')
}
}
},
'element-three': {
group: 'elements',
className: 'element-creator FinancialExchangeActive',
title: '元素 3',
action: {
click() {
window.alert('创建元素3')
}
}
},
'element-four': {
group: 'elements',
className: 'element-creator FinancialExchangeActive',
title: '元素 4',
action: {
click() {
window.alert('创建元素4')
}
}
},
'event-one': {
group: 'events',
className: 'events FishingAtWorkActive',
title: '事件1',
action: {
click() {
if (config.componentMethods) {
config.componentMethods.toggleDialog()
}
}
}
}
}
}
}
DemoContextPadProvider2.$inject = ['config', 'contextPad']
然后一样在 vue 组件中进行引用和初始化。
vue
<script setup>
import { onMounted, ref } from 'vue'
import Diagram from 'diagram-js'
import ContextPad from 'diagram-js/lib/features/context-pad/index.js'
import TouchModule from 'diagram-js/lib/features/touch'
import SelectionModule from 'diagram-js/lib/features/selection'
import { DemoContextPadProvider, DemoContextPadProvider2 } from '../modules/contextPadProvider.js'
const dialogVisible = ref(false)
const toggleDialog = () => {
dialogVisible.value = !dialogVisible.value
}
const bootstrapDiagram = () => {
const djs = new Diagram({
canvas: { container: document.getElementById('canvas') },
modules: [
TouchModule,
SelectionModule,
ContextPad,
{
__init__: ['demoContextPadProvider', 'demoContextPadProvider2'],
demoContextPadProvider: ['type', DemoContextPadProvider],
demoContextPadProvider2: ['type', DemoContextPadProvider2]
}
],
componentMethods: {
toggleDialog
}
})
return djs
}
const bootstrapShapes = (canvas) => {
canvas.addShape({ id: 's1', width: 100, height: 100, x: 10, y: 10 })
canvas.addShape({ id: 's2', width: 50, height: 50, x: 200, y: 10 })
canvas.addShape({ id: 's3', width: 150, height: 150, x: 300, y: 300 })
}
onMounted(() => {
const modeler = bootstrapDiagram()
bootstrapShapes(modeler.get('canvas'))
})
</script>
<template>
<div id="canvas" class="canvas"></div>
<el-dialog v-model="dialogVisible" title="Tips" width="30%">
<span>This is a message</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="dialogVisible = false"> Confirm </el-button>
</span>
</template>
</el-dialog>
</template>
<style>
.canvas {
width: 100%;
height: 100%;
}
.bg-gray {
background-color: #888888;
}
</style>
当然,因为
ContextPad依赖Selection模块来进行显示,而默认的元素点击等事件需要Touch模块来进行注册和代理,所以在使用时一定要确保已经引入了selection与touch模块。
此时,当我们选中元素时,就能得到对应的菜单项。
这里的图标来自掘金的沸点点赞图标~
当然,与 PaletteProvider 的处理方式类似,在注册了多个 ContextPadProvider 之后,所有 Provider 中定义的每一个工具入口 ContextPadEntry,也会整合成一个完成的工具集合对象。
所以,如果我们需要修改原来的某个工具,或者替换掉某个 Provider 中的所有内容,也可以采用与 PaletteProvider 一样的方法。
修改 ContextPad 的样式
上文虽然已经完成了 Provider 的编写,但是有时候,UI 并不会满足于当前的上下文菜单 "样式",所以,偶尔还需要我们对 ContextPad 本身进行修改。
在介绍 ContextPad 的方法时,有介绍这么两个方法:
getPad:获取当前菜单所在的覆盖物图层,创建菜单元素并进行事件代理_updateAndOpen:更新位置并显示菜单图层
在 getPad 这个方法中,主要代码和逻辑如下:
js
ContextPad.prototype.getPad = function(target) {
// 如果已经是打开状态,直接返回这个覆盖物图层实例
if (this.isOpen()) {
return this._current.pad;
}
// 代理 this
var self = this;
var overlays = this._overlays;
// 定义和创建菜单所在的 dom 元素
var html = domify('<div class="djs-context-pad"></div>');
// 获取目标元素的位置坐标
var position = this._getPosition(target);
// 整合默认配置(this._overlaysConfig 包含缩放配置等)
var overlaysConfig = assign({
html: html
}, this._overlaysConfig, position);
// 代理点击与拖拽事件
domDelegate.bind(html, entrySelector, 'click', function(event) {
self.trigger('click', event);
});
domDelegate.bind(html, entrySelector, 'dragstart', function(event) {
self.trigger('dragstart', event);
});
// 阻止 mousedown 事件
domEvent.bind(html, 'mousedown', function(event) {
event.stopPropagation();
});
// 添加一个顶级覆盖物图层,并保存图层id
var activeRootElement = this._canvas.getRootElement();
this._overlayId = overlays.add(activeRootElement, 'context-pad', overlaysConfig);
var pad = overlays.get(this._overlayId);
// 发送菜单已创建的事件
this._eventBus.fire('contextPad.create', {
target: target,
pad: pad
});
// 返回覆盖物实例
return pad;
};
_updateAndOpen 方法主要逻辑如下:
js
ContextPad.prototype._updateAndOpen = function(target) {
// 获取所有 Provider 组成的 entries 对象与 菜单所在的 dom 节点(html 变量)
var entries = this.getEntries(target),
pad = this.getPad(target),
html = pad.html,
image;
// 遍历对象,创建菜单项元素
forEach(entries, function(entry, id) {
// 获取每个工具的分组标识 grouping,创建工具对应的dom元素
var grouping = entry.group || 'default',
control = domify(entry.html || '<div class="entry" draggable="true"></div>'),
container;
// 为工具元素添加一个 data-action 自定义属性,值为工具 id(也就是对象 key)
domAttr(control, 'data-action', id);
// 找到这个分组对应的 dom 元素
container = domQuery('[data-group=' + escapeCSS(grouping) + ']', html);
// 不存在分组元素的话,创建一个 class 为 group 的元素,并添加 data-group 标识,用于下次查询
if (!container) {
container = domify('<div class="group"></div>');
domAttr(container, 'data-group', grouping);
// 插入到菜单中
html.appendChild(container);
}
// 将工具元素插入到分组
container.appendChild(control);
// 绑定自定义样式名
if (entry.className) {
addClasses(control, entry.className);
}
// 绑定 title 属性
if (entry.title) {
domAttr(control, 'title', entry.title);
}
// 如果有对应的图片地址,将插入该图片
if (entry.imageUrl) {
image = domify('<img>');
domAttr(image, 'src', entry.imageUrl);
image.style.width = '100%';
image.style.height = '100%';
control.appendChild(image);
}
});
// 修改为打开状态
domClasses(html).add('open');
this._current = {
target: target,
entries: entries,
pad: pad
};
// 发送菜单已打开事件
this._eventBus.fire('contextPad.open', { current: this._current });
};
以上两个方法,涉及到了整个菜单的 dom 结构,以及每个工具的渲染逻辑。
例如上面的例子中,所对应的 dom 结构如下图:
虽然这个原来的结构,也可以通过修改默认的 CSS 样式来进行调整,但是某些情况下很难满足我们的需求,此时就需要对原有的 dom 结构进行修改。
所以,如果需要改变原有的上下文菜单结构,就需要从以上两个方法入手。
假设,我们此时拿到了一个如下的设计图:
来自某个热心群友的需求,已稍作调整。
首先,先分析一下这个设计图对应的 dom 结构应该是什么样的。
- 菜单最外层一样是一个父节点,可以沿用
djs-context-pad这个节点,但是它 "更宽" - 依然具有分组的概念,所以可以沿用
group的结构,但是需要增加一个group label的节点用来显示每个分组的名字 - 具有完整背景色的工具元素父节点
- 每个工具节点,需要显示图标
icon与工具名称
所以,我们可以得到这样一个大致结构:
html
<div class="djs-context-pad">
<div class="group">
<div class="group-label">${groupId}</div>
<div class="group-content">
<div class="entry">
<div class="entry-icon">${entry1.icon}</div>
<div class="entry-title">${entry1.title}</div>
</div>
</div>
</div>
</div>
现在,让我们开始着手 ContextPad 的修改。
上文说过,当菜单调用
open方法打开时,会通过_updateAndOpen来创建整个菜单结构,所以入口方法就是_updateAndOpen。
而这个方法的第一步,就是通过 getPad 获取这个菜单所对应的父元素 pad html。
我们注意 getPad 中有这样三行行代码:
js
var html = domify('<div class="djs-context-pad"></div>');
var position = this._getPosition(target);
var overlaysConfig = assign({ html: html }, this._overlaysConfig, position);
即将坐标 position、生成的 dom 节点 html 与 this._overlaysConfig 一起合并到一个变量 overlaysConfig,而这个 html 对应的元素很明显就是菜单的父级元素。
所以,我们有没有可能不重写 getPad 方法也能改变这个父元素呢?
答案是可以的,只需要在 this._overlaysConfig 中重新声明一个 html 属性,即可覆盖官方定义的节点。
那么我们就需要在 _updateAndOpen 方法加上这么几句代码:
js
import { default as BaseContextPad } from 'diagram-js/lib/features/context-pad/ContextPad.js'
// 继承原来的上下文菜单
export default class ContextPad extends BaseContextPad {
// 重写方法
_updateAndOpen(target) {
// 根据 getPad 方法,定义新的菜单节点;为了适配原来的菜单并避免冲突,增加了一个 class 类名
const padHtml = domify(`<div class="djs-context-pad wider-pad"></div>`)
const entrySelector = '.entry'
const self = this
// 一样的事件代理
domDelegate.bind(padHtml, entrySelector, 'click', function (event) {
self.trigger('click', event)
})
domDelegate.bind(padHtml, entrySelector, 'dragstart', function (event) {
self.trigger('dragstart', event)
})
// 一样的事件阻止
domEvent.bind(padHtml, 'mousedown', function (event) {
event.stopPropagation()
})
// 绑定到 this._overlaysConfig 上
this._overlaysConfig.html = padHtml
// ...
}
}
这样,我们就完成了最外层的菜单节点的改造。
剩下的,则是处理每一个 group 以及里面的工具元素。
js
_updateAndOpen(target) {
// ...
// 一样的参数声明和获取
let entries = this.getEntries(target),
pad = this.getPad(target), // 一样需要调用该方法,触发里面的其他逻辑和事件
html = pad.html,
image
// 遍历 entries 对象,插入 group 节点和工具节点
forEach(entries, function (entry, id) {
let grouping = entry.group || 'default',
icon = domify('<div class="entry__icon"></div>'), // 定义 icon 对应的dom元素
control = domify(entry.html || '<div class="entry" draggable="true"></div>'), // 定义每个工具对应的dom元素
container // 每个 group 对应的 dom 元素
// 将 icon 插入的工具元素中,并设置工具元素的 data-action 属性
control.appendChild(icon)
domAttr(control, 'data-action', id)
// 查找当前分组是否已经生成了 dom 节点
container = domQuery('[data-group=' + escapeCSS(grouping) + ']', html)
// 不存在则创建一个节点,并插入一个 group__label 节点来显示分组名称
if (!container) {
container = domify(`<div class="group"><div class="group__label">${grouping}</div></div>`)
domAttr(container, 'data-group', grouping)
// 插入到菜单中
html.appendChild(container)
}
// 向分组中插入该工具
container.appendChild(control)
if (entry.className) {
addClasses(icon, entry.className)
}
if (entry.title) {
domAttr(control, 'title', entry.title)
// 插入工具名称节点
const title = domify(`<div class="entry__title">${entry.title}</div>`)
control.appendChild(title)
}
if (entry.imageUrl) {
image = domify('<img>')
domAttr(image, 'src', entry.imageUrl)
image.style.width = '100%'
image.style.height = '100%'
icon.appendChild(image) // 图片作为图标时,只能插入到 icon 节点下
}
})
domClasses(html).add('open')
this._current = {
target: target,
entries: entries,
pad: pad
}
this._eventBus.fire('contextPad.open', { current: this._current })
}
这样,当菜单打开时,我们就能得到这样一个 dom 结构:
然后,配合上对应的 CSS 样式代码,就完成一个另外一种风格的上下文菜单:
css
/* context pad */
.djs-context-pad.wider-pad {
width: max-content;
max-width: 240px;
border-radius: 4px;
padding: 4px 8px;
box-sizing: border-box;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
}
.djs-context-pad.wider-pad .group {
background-color: var(--color-grey-225-10-97);
}
.djs-context-pad.wider-pad .group__label {
width: 100%;
line-height: 32px;
font-weight: bold;
background-color: #ffffff;
}
.djs-context-pad.wider-pad .entry {
width: auto;
display: inline-flex;
align-items: center;
padding: 4px 8px;
background-color: unset;
box-shadow: none;
}
.djs-context-pad.wider-pad .entry__icon {
width: 22px;
height: 22px;
}
.djs-context-pad.wider-pad .entry__title {
font-size: 12px;
}
使用
.djs-context-pad.wider-pad作为限制条件,可以避免污染原有的上下文菜单样式。
Overlays 覆盖物图层
上文 ContextPad 说到,上下文菜单的显示是通过创建一个 Overlay 图层来挂载菜单的,那么现在就接着说一下另一个十分常用的功能 ------ Overlays 覆盖物图层。
官方对这个模块的定义是:A service that allows users to attach overlays to diagram elements. The overlay service will take care of overlay positioning during updates.
即"一个允许用户添加覆盖物到图元素的服务,并且会在图层更新期间负责更新图层的位置"。
虽然该模块在 bpmn-js 和 diagram-js 中很少使用,但是却是 bpmn-js-token-simulation、bpmn-js-bpmnlint 等多个扩展功能必不可少的依赖之一,也是为我们提供交互优化(hover 显示节点信息等)的实现方式之一。
它所提供的配置与能力
作为覆盖物,一般来说会与对应的画布元素或者坐标进行绑定,并且跟随画布缩放或者移动发生相应的改变。
所以在 diagram-js 中,覆盖物的添加必须要绑定一个元素作为 定位元素 ,并且会 监听 canvas 画布改变与元素改变和移除等事件。
但是为了避免在画布缩放过程中,缩放比过大或者过小造成的覆盖物显示不清楚/不完整的情况,Overlays 提供了一个缩放范围来进行限制,并且允许用户修改这个范围。
Overlays 模块的定义如下:
typescript
type OverlaysConfig = {
defaults?: OverlaysConfigDefault
}
type OverlaysConfigDefault = {
show?: OverlaysConfigShow
scale?: OverlaysConfigScale | boolean
}
type OverlaysConfigShow = {
minZoom?: number
maxZoom?: number
}
type OverlaysConfigScale = {
min?: number
max?: number
}
type OverlayContainer = {
html: HTMLElement
element: Element
overlays: Overlay[]
}
type OverlayAttrs = {
html: HTMLElement | string
position: {
top?: number
right?: number
bottom?: number
left?: number
}
} & OverlaysConfigDefault
type Overlay = {
id: string
type: string | null
element: Element | string
} & OverlayAttrs }
class Overlays {
_overlayDefaults: OverlaysConfigDefault;
_overlays: Record<string, Overlay>;
_overlayContainers: OverlayContainer[];
_overlayRoot: HTMLElement;
constractor(config: OverlaysConfig, eventBus: EventBus, canvas: Canvas, elementRegistry: EventRegistry) {}
}
Overlays.$inject = [ 'config.overlays', 'eventBus', 'canvas', 'elementRegistry' ];
除了 diagram-js 本身提供的 eventBus 等几个模块之外,Overlays 还接受一个 OverlaysConfig 的参数,用来限制覆盖物的缩放范围。
每个覆盖物,都是一个 Overlay 格式的对象,创建之后都会保存在 _overlays 属性中;而 _overlayContainers,则是记录了每个元素的所有绑定覆盖物实例和 dom 节点。
在 Overlays 模块初始化时,除了初始化以上属性之外,还会通过 _init 方法,注册上文说到的相关事件。
js
export default function Overlays(config, eventBus, canvas, elementRegistry) {
this._eventBus = eventBus;
this._canvas = canvas;
this._elementRegistry = elementRegistry;
this._ids = ids;
this._overlayDefaults = assign({
show: null,
scale: true
}, config && config.defaults);
this._overlays = {};
this._overlayContainers = [];
this._overlayRoot = createRoot(canvas.getContainer());
this._init();
}
Overlays.prototype._init = function() {
var eventBus = this._eventBus;
var self = this;
function updateViewbox(viewbox) {
self._updateRoot(viewbox);
self._updateOverlaysVisibilty(viewbox);
self.show();
}
eventBus.on('canvas.viewbox.changing', function(event) {
self.hide();
});
eventBus.on('canvas.viewbox.changed', function(event) {
updateViewbox(event.viewbox);
});
eventBus.on([ 'shape.remove', 'connection.remove' ], function(e) {
var element = e.element;
var overlays = self.get({ element: element });
forEach(overlays, function(o) {
self.remove(o.id);
});
var container = self._getOverlayContainer(element);
if (container) {
domRemove(container.html);
var i = self._overlayContainers.indexOf(container);
if (i !== -1) {
self._overlayContainers.splice(i, 1);
}
}
});
eventBus.on('element.changed', LOW_PRIORITY, function(e) {
var element = e.element;
var container = self._getOverlayContainer(element, true);
if (container) {
forEach(container.overlays, function(overlay) {
self._updateOverlay(overlay);
});
self._updateOverlayContainer(container);
}
});
eventBus.on('element.marker.update', function(e) {
var container = self._getOverlayContainer(e.element, true);
if (container) {
domClasses(container.html)[e.add ? 'add' : 'remove'](e.marker);
}
});
eventBus.on('root.set', function() {
self._updateOverlaysVisibilty(self._canvas.viewbox());
});
eventBus.on('diagram.clear', this.clear, this);
};
针对不同的事件,有不同的处理方式:
canvas.viewbox.changing:视图变化过程中,需要隐藏所有覆盖物,减少性能开销canvas.viewbox.changed:视图改变结果,重新计算和更新覆盖物的显示[ 'shape.remove', 'connection.remove' ]:元素移除时,需要移除该元素对应的覆盖物element.changed:元素改变时,需要更新该元素对应的覆盖物element.marker.update:更新元素class类名时,需要一同更新覆盖物root.set:根节点更新时,调整覆盖物显示diagram.clear:画布清空时,同时清空所有覆盖物
当然,针对覆盖物的管理,也有对应的方法:
typescript
export type OverlaysFilter = {
id?: string;
element?: Element | string;
type?: string;
} | string;
export class Overlays {
/**
* 返回具有指定ID的覆盖物(单数)或具有给定类型的元素的覆盖物列表(数组)。
* @param search The filter to be used to find the overlay(s).
* @return The overlay(s).
*/
get(search: OverlaysFilter): Overlay | Overlay[];
/**
* 将HTML覆盖添加到元素中作为一个覆盖物,返回生成的覆盖物对象实例。
* @param element 元素id或者元素对象
* @param type 可选参数,用来给覆盖物增加一个类型.
* @param overlay 覆盖物的配置属性.
*
* @return The overlay's ID that can be used to get or remove it.
*/
add(element: Element | string, overlay: OverlayAttrs): string;
add(element: Element | string, type: string, overlay: OverlayAttrs): string;
/**
* 删除具有给定ID的覆盖物或者匹配给定条件的所有覆盖物图层
*/
remove(filter: OverlaysFilter): void;
/**
* 验证所有覆盖物是不是都处于显示状态
*/
isShown(): boolean;
/**
* 显示所有覆盖物
*/
show(): void;
/**
* 隐藏所有覆盖物
*/
hide(): void;
/**
* 清除所有覆盖物
*/
clear(): void;
}
覆盖物的 dom 结构与特征
当了解了以上方法之后,我们就可以尝试给元素添加覆盖物了。
通过 add 方法的源码来看,为一个元素添加覆盖物会经过以下过程(方法):
add(): 参数校验与格式化,生成覆盖物 ID,并将格式化之后的参数传递给_addOverlay(),最后返回 ID_addOverlay():生成覆盖物的dom节点,并找到这个元素对应的覆盖物图层的 根dom节点 (没有则会创建一个新的节点);然后创建当前条件下的覆盖物图层添加到根节点下,同时插入我们参数中定义的html元素;最后将该图层实例保存到_overlayContainers与_overlays中,调用_updateOverlay方法_updateOverlay():在_addOverlay方法创建了对应的图层dom节点之后,会通过该方法计算参数中的position定位与当前元素的位置,通过 绝对定位 的方式更新覆盖物坐标_updateOverlayVisibilty():在坐标更新之后,需要通过当前激活的根元素以及当前的视图缩放比例,来判断覆盖物的显示隐藏状态_updateOverlayScale():如果上一个方法判断之后需要显示该覆盖物,则通过该方法计算覆盖物的缩放比例,通过transform来改变元素
简化后过程如下:
scss
add() // 生成id,格式化参数
⇩
_addOverlay() // 创建 dom 并挂载
⇩
_updateOverlay() // 更新覆盖物坐标
⇩
_updateOverlayVisibilty() // 判断是否显示
⇩
_updateOverlayScale() // 计算缩放比例
但是为了更加方便管理每一个覆盖物,在覆盖物的 dom 结构上,由上至下分成了
- 所有覆盖物的根节点
- 单个元素的所有覆盖物的根节点
- 单个元素的单个覆盖物的根节点
- 单个元素的单个覆盖物的实际定义节点
层级结构如下:
在上图中,一共有 s1, s2, s3 三个元素,并为 s1 添加了一个覆盖物,为 s2 添加了两个覆盖物,则最终的 dom 结构就体现为上图所示的 4层结构。
❗注意:
- 所有覆盖物的根节点
div.djs-overlay-container,通过 绝对定位的方式定义了所有覆盖的定位基准元素,并且没有设置高宽,来避免影响画布本身的其他鼠标事件 - 每一个元素的覆盖物根节点
div.djs-overlays,通过实时计算 元素在画布中的相对位置,来设置这个元素的覆盖物对应的定位基准元素 - 每一个覆盖物
div.djs-overlay,位置相对于div.djs-overlays固定,坐标由创建覆盖物的position参数确定 - 当画布被缩放时,会通过改变
div.djs-overlay-container的 CSS 样式中的transform属性来实现覆盖物的同步缩放
通过 Overlays 实现 Tooltip 效果
除了 bpmn 团队自己实现的一些插件需要依赖 Overlays 模块之外,在平时的业务中,也有可能需要使用 Overlays 来实现一些业务需求。
假设现在有这样一个场景:
截图来自小伙伴的开源项目:(蒜蓉辣椒酱)github.com/L1yp/van
即:在鼠标移动到 已通过的任务节点 时,显示该流程的当前流转状态。
这个效果如果是在普通的 dom 节点中,我们可以很轻松的实现,毕竟现在各大组件库都有提供这样的组件(Tooltip 或者 Popover)。但是在 diagram-js 或者 bpmn-js 中,由于渲染出来的元素都是 svg 节点且默认不受用户直接控制,所以要实现这样的交互还是有些难度的。
但是 Vue 3 对应的组件库
Element Plus,对Popover提供了一种 虚拟触发 的方式,所以后面会介绍使用第三方组件库的方式。
首先,我们先编写一个基础的页面文件:
vue
<script setup>
import { onMounted, ref, shallowRef } from 'vue'
import Diagram from 'diagram-js'
import { bootstrapShapes } from '../../utils/bootstrap.js'
import TouchModule from 'diagram-js/lib/features/touch'
import SelectionModule from 'diagram-js/lib/features/selection'
import OverlaysModule from 'diagram-js/lib/features/overlays'
let overlays, shapes, modeler
const htmlRef = ref(null)
const bootstrapDiagram = () => {
return new Diagram({
canvas: { container: document.getElementById('overlay-canvas') },
modules: [TouchModule, SelectionModule, OverlaysModule]
})
}
onMounted(() => {
modeler = bootstrapDiagram()
overlays = modeler.get('overlays')
shapes = bootstrapShapes(modeler.get('canvas'))
})
</script>
<template>
<div class="overlays-box">
<div id="overlay-canvas" class="canvas"></div>
<div class="box">
<div class="overlay-box-mask">
<div ref="htmlRef" class="djs-popover">
<div class="djs-popover__content">
<p>This is a popover</p>
<p>使用 div 手动实现</p>
</div>
<div class="djs-popover__arrow-wrapper">
<div class="djs-popover__arrow"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
.overlays-box {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.canvas {
width: 100%;
height: 100%;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
}
.djs-overlay {
pointer-events: none;
}
.overlay-box-mask {
// display: none; 预先注释,可以查看显示效果
margin: 20vh auto;
}
.djs-popover {
transform: translateX(-50%) translateY(-100%); // 使用 transform 实现居中
// ...
}
// ...省略部分样式
</style>
div.djs-popover的结构参照了Naive UI的Popover组件 的dom结构与CSS样式,具体代码见小册关联仓库。
此时,页面显示如下:
但是为了不影响本身的页面结构,我们可以将这个信息弹窗 div.djs-popover 放到一个不显示(设置为 display: none,这里为了演示效果注释了这行代码)的节点中,也就是上文中的 div.overlay-box-mask,这样一来,这个弹窗就可以完全不影响页面的其他布局。
当然,实际业务中可能还需要显示 hover 时的元素信息,所以我们可以对上面的模板加以修改,增加一个 shallowRef 变量保存当前 hover 的元素,并在页面上显示该元素的id。
javascript
const hoverEl = shallowRef(null)
// ...
<div class="djs-popover__content">
<p>This is a popover</p>
<p>使用 div 手动实现</p>
<p v-if="hoverEl">Hover 元素 ID: {{ hoverEl.id }}</p>
</div>
然后,我们就需要想办法 将定义好的 tooltip template 显示到我们的这个元素上。这里需要涉及到以下内容:
- 既然是
hover时显示,那么我们需要实现对element.hover事件的监听,这个事件在我们引入的TouchModule中有实现该事件的注册 Overlays中并没有提供 针对单个覆盖物的显示状态管理(只能添加和移除) ,所以我们需要 清空原有的覆盖物再重新添加(逻辑更加简单)- 在
Vue中,使用ref属性绑定的dom元素,会保存该节点到这个对应变量中,所以可以使用这个dom ref变量作为添加覆盖物时的html属性 - 在
Vue模板中编写的内容,即使挂载到其他位置,当数据更新时一样会更新对应dom元素(因为VNode与实际dom的对应关系一直存在) - 既然是模拟的
tooltip的效果,在移动到其他需要显示覆盖物的元素时需要立即 "移动" 过去,而其他情况,则需要等待一段时候后从最后一个hover的元素上移除该覆盖物
这样,就可以编写后面的代码了。
js
const hoverEl = shallowRef(null) // 选中的元素
const htmlRef = ref(null) // template 模板
let timer = null // 记录定时器 id
// 重置定时器
const stopTimer = () => {
timer && clearTimeout(timer)
}
// 开始定时器
const startTimer = () => {
stopTimer()
timer = setTimeout(() => {
// 隐藏时需要将原来的元素索引清除掉
hoverEl.value = null
// 然后清空覆盖物
overlays && overlays.clear()
}, 2000)
}
// 初始化 element.hover 事件
const initHoverEvent = (eventBus) => {
eventBus.on('element.hover', ({ element }) => {
// 需要显示 overlay 的元素
if (element && activeElementIds.indexOf(element.id) >= 0) {
// 需要关闭之前的定时器
stopTimer()
if (!hoverEl.value || hoverEl.value !== element) {
// 元素不同时,清空原来的图层元素
overlays && overlays.clear()
// 保存索引,并创建新图层
hoverEl.value = element
overlays.add(hoverEl.value, { html: htmlRef.value, position: { left: element.width / 2, top: 0 } })
}
}
// 不需要时,则开启定时器
else {
startTimer()
}
})
}
onMounted(() => {
modeler = bootstrapDiagram()
overlays = modeler.get('overlays')
shapes = bootstrapShapes(modeler.get('canvas'))
initHoverEvent(modeler.get('eventBus'))
})
此时,我们已经完成了大部分
tooltip的效果,但是依然还有不足。
- 箭头位置和显示位置固定,当处于边界位置时无法调整位置(可以根据元素的坐标来设置模板的动态类名和调整覆盖物坐标,改变箭头方向等)
- 当鼠标移动到覆盖物上时,依然会触发定时器开启,导致 2s 后覆盖物被移除(可以给覆盖物元素增加鼠标事件,来开启或者关闭定时器)
这两个问题可以当做思考题,大家可以尝试解决~
借助组件库中具有虚拟触发功能的 Popover 来实现(不需要依赖 Overlays)
首先,我们先来了解一下 Element Plus 中的 Popover 组件:
Popover 气泡卡片:与 Tooltip 相似,Popover 也是基于ElPopper的构建的;支持 hover、click、focus 或 contextmenu 四种触发方式,默认是 hover 触发;支持 virtual-ref 结合 virtual-triggering 实现外部元素触发。
virtual-ref与virtual-triggering属性的描述在Tooltip的属性文档中,virtual-triggering是一个标识符,virtual-ref则绑定外部的HTMLElement元素。
那么此时我们就可以编写以下代码:
vue
<script setup>
import { onMounted, ref, unref } from 'vue'
import Diagram from 'diagram-js'
import { bootstrapShapes } from '../utils/bootstrap.js'
import TouchModule from 'diagram-js/lib/features/touch'
import SelectionModule from 'diagram-js/lib/features/selection'
let shapes, modeler
const activeElementIds = ['s1', 's3']
const bootstrapDiagram = () => {
return new Diagram({
canvas: { container: document.getElementById('overlay-canvas') },
modules: [TouchModule, SelectionModule, OverlaysModule]
})
}
const activeSvgEl = shallowRef(null)
const popoverRef = shallowRef(null)
const hoverEl = shallowRef(null)
onMounted(() => {
modeler = bootstrapDiagram()
overlays = modeler.get('overlays')
shapes = bootstrapShapes(modeler.get('canvas'))
})
</script>
<template>
<div class="overlays-box">
<div id="overlay-canvas" class="canvas"></div>
<div class="box">
<el-popover ref="popoverRef" :virtual-ref="activeSvgEl" trigger="hover" title="With title" virtual-triggering>
<p>This is a ElPopover</p>
<p>使用 element-plus 实现</p>
<p v-if="hoverEl">Hover 元素 ID: {{ hoverEl.id }}</p>
</el-popover>
</div>
</div>
</template>
在这部分代码中,我们将添加到画布中的三个元素都进行了保存 (shapes 对象),并在 template 模板中添加了一个 虚拟触发 的 ElPopover 组件,将触发对象绑定到 activeEl 对象中。
这种情况下是通过动态去改变
activeEl来调整popover的显示位置的;当然,如果确定有
此时页面如下,并且没有任何交互效果。
然后,我们一样需要设置 element.hover 事件来实现 ElPopover 的显示:
js
// 实现 类 tooltip
const activeSvgEl = shallowRef(null)
const popoverRef = shallowRef(null)
const hoverEl = shallowRef(null)
const initHoverEvent = (eventBus) => {
eventBus.on('element.hover', ({ element }) => {
if (element && activeElementIds.indexOf(element.id) >= 0) {
if (!hoverEl.value || hoverEl.value !== element) {
hoverEl.value = element
activeSvgEl.value = modeler.get('elementRegistry').getGraphics(element.id)
}
}
})
}
onMounted(() => {
modeler = bootstrapDiagram()
shapes = bootstrapShapes(modeler.get('canvas'))
initHoverEvent(modeler.get('eventBus'))
})
从代码量上来看,由于使用了第三方组件库的原因,我们编写的代码不管是
js部分,还是css、html部分,都减少了很多内容。
由于 ElPopover 已经实现了 移出元素后自动隐藏、popover 不会隐藏 的效果,所以我们只需要注意 在需要显示的时候更新虚拟触发绑定的元素变量 activeSvgEl。
所以我们只需要在 hover 元素进行切换时,更新对应的虚拟触发元素即可。
这里与
Overlays不一样的是,ElPopover绑定的virtual-ref必须是一个dom元素,所以需要通过ElementRegistry模块获取到这个元素对应的dom节点。不过,实际上
element.hover事件中,回调函数参数中其实还会包含一个gfx属性,这个属性默认就是该元素对应的dom节点。所以上面的代码还可以改为:
jsconst initHoverEvent = (eventBus) => { eventBus.on('element.hover', ({ element, gfx }) => { if (element && activeElementIds.indexOf(element.id) >= 0) { if (!hoverEl.value || hoverEl.value !== element) { hoverEl.value = element activeSvgEl.value = gfx } } }) }