扩展
节点类型、属性、编辑方式、规则、节点渲染、属性编辑器、命令与面板,都经扩展登记。扩展是普通对象,按实例交给元素的 extensions,没有全局注册:同一页面上的两个设计器可以装不同的扩展。完整的字段见扩展参考。
三层
| 层 | 类型 | 所在的包 | 在下一层之上加了什么 | 谁读 |
|---|---|---|---|---|
| core | FlowCoreExtension | @xihan-flow/core | 节点类型、连线与流程定义级属性、编辑方式、规则、问题说明 | 校验、编辑操作、查看器、设计器 |
| 运行时 | FlowRuntimeExtension | @xihan-flow/runtime | 节点内容的渲染 nodeRenderers | 查看器、设计器 |
| 设计器 | FlowDesignerExtension | @xihan-flow/designer | 属性编辑器、命令、面板、setup | 设计器 |
下层的扩展对象可以直接交给上层的元素,core 层的扩展同时交给查看器、设计器与 validateDocument。服务端或 Node.js 里只装 core 层,校验结论与设计器一致。
id全局唯一,用包名加功能名,如@example/leave。它也是节点、连线与流程定义上扩展数据的键,见业务数据与扩展数据。- 内置扩展排在最前,传入的按数组顺序排在后面;物料、属性与命令都按这个顺序。
- 同一个扩展对象换了引用会当作卸下再装上,宿主要保持扩展数组与其中对象的引用稳定。
下面用一个请假流程走一遍:core 层登记四种节点、一种编辑方式与一条规则,设计器层再加编辑器、命令、面板与 setup。
节点类型
const nodeTypes: readonly FlowNodeTypeDefinition[] = [
{
type: 'apply',
label: '发起',
category: 'leave',
size: { width: 120, height: 40 },
ports: 'out',
entry: true,
maxCount: 1,
},
{
type: 'approve',
label: '审批',
category: 'leave',
size: { width: 160, height: 48 },
ports: 'both',
defaults: { label: '审批' },
properties: [
{ path: ['label'], control: { kind: 'text' }, label: '名称', group: 'basic', required: true },
{ path: ['data', 'approver'], control: { kind: 'leave.person' }, label: '审批人', group: 'basic', required: true },
{ path: ['data', 'days'], control: { kind: 'number', integer: true, min: 1 }, label: '超过几天才审批', group: 'rule' },
],
outgoingEdges: [
{
path: ['data', 'result'],
control: { kind: 'select', options: [{ value: 'agree', label: '同意' }, { value: 'reject', label: '驳回' }] },
label: '审批结果',
},
],
},
{
type: 'notify',
label: '抄送',
category: 'leave',
size: { width: 140, height: 40 },
ports: 'both',
properties: [
{ path: ['label'], control: { kind: 'text' }, label: '名称', group: 'basic' },
{ path: ['data', 'to'], control: { kind: 'tags' }, label: '抄送给', group: 'basic' },
],
},
{
type: 'archive',
label: '归档',
category: 'leave',
size: { width: 120, height: 40 },
ports: 'in',
terminal: true,
},
]type是类型名,节点的type写它;label是显示名,物料面板、属性面板与问题说明都用它。icon是 XiHan.UI 的图标记录(如@xihan-ui/icons里的图标),画布上的节点与物料条目都把它画在文字前面。category是物料面板里的类别,设计器的paletteGroupOrder与paletteGroupLabels按类别排序、换标题。size是新建节点的尺寸,defaults是新建时写入的标签与data。ports写预设或具名端口。预设in是左侧一个入口,out是右侧一个出口,both两者都有,none没有端口;具名端口写id、方向(in、out、both)、所在的边与沿边的相对位置,还可以限制连线条数;写了label的端口,设计器把名字写在端口外侧,随端口一起显出。连线的端点不写端口时,取方向相符的第一个端口。entry的节点是可达性分析的起点,不要求有入线;terminal的节点不要求有出线。minCount、maxCount限制这类节点的个数。connection是连接约束:入线、出线的最多条数,能连向、能接受的节点类型,以及是否允许连向自己。设计器拖动连线时按它判断能不能连,校验也按它报问题。
属性
properties 是属性面板里列出的属性,path 相对节点本身:['label'] 是标签,['data', …] 写进节点的业务数据。每个属性写编辑方式 control,内建的有 text、number、boolean、select、tags、color、json 等,全部种类与取值约束见 XiHan.UI 的 designer-kit。
group是属性面板里的分组,设计器的propertyGroupOrder与propertyGroupLabels按分组排序、换标题;label是字段名。required的属性必须填写;default是新建节点或连线时写入的缺省值;visibleWhen按别的属性的值决定显不显示。- 校验按属性描述检查取值:必填没填、类型不对、超出范围、不在选项里等,问题码见问题码。
连线的属性有两处:节点类型的 outgoingEdges 只加在从这类节点连出的连线上,如审批的结果;扩展的 edgeProperties 加在所有连线上。连线的标签与虚线是内置的连线属性。节点类型的 outgoingEdgeStyles 按连线数据给从这类节点连出的连线外观,如 { when: { path: ['data', 'isDefault'], equals: true }, style: { dashed: true } } 把默认分支画成虚线;when 的写法同属性描述的 visibleWhen,路径相对连线本身。连线自己写的外观压过推出的,推出的外观只用于绘制,不写回流程定义。扩展的 documentProperties 是流程定义级的属性,什么都没选中时属性面板编辑它们。
编辑方式
内建的编辑方式不够用时,在 controls 里登记新的种类,键就是属性描述里的 control.kind,不能与内建的重名。check 检查已经填写的取值,返回问题码表示不合格:
/** 审批人这种编辑方式:取值要是名单里的一位。 */
const controls: FlowCoreExtension['controls'] = {
'leave.person': { check: value => (typeof value === 'string' && PEOPLE.includes(value) ? undefined : 'invalid-option') },
}core 层只管取值对不对;属性面板里用什么控件编辑它,由设计器层的扩展按种类提供编辑器,见下文的属性编辑器。没有登记的种类在校验时报 unknown-control。
规则
规则检查节点类型与属性描述表达不了的约束,拿到流程定义与合并后的节点类型,返回问题:
const rules: FlowCoreExtension['rules'] = [
document => document.nodes.flatMap((node, index) => {
if (node.type !== 'approve')
return []
const rejectable = document.edges.some(edge => edge.source.node === node.id && edge.data?.result === 'reject')
return rejectable
? []
: [{ code: 'leave.no-reject', path: ['nodes', index], target: { kind: 'node', id: node.id }, severity: 'warning' as const }]
}),
]- 问题码必须以扩展 id 的末段加点开头,
@example/leave的规则写leave.开头的码,不符时校验当场抛TypeError。 path从流程定义的根算起;带上target时,设计器在问题列表里点这条问题会选中并显露那个对象,节点上也会标出问题。severity不写按错误处理。宿主可以用validateOptions.severity按问题码改写,扩展的问题码也认。
问题的说明由扩展的 issueText 给出,返回 undefined 时交给设计器的文案:
export const leave: FlowCoreExtension = {
id: '@example/leave',
nodeTypes,
controls,
rules,
issueText: issue => (issue.code === 'leave.no-reject' ? '审批节点要连出一条驳回' : undefined),
}设计器层
设计器层的扩展可以与 core 层的分开写,一起放进 extensions。下面的示例里,审批人用下拉框选,工具栏多了「加一个审批」与「吸附网格」,右侧多了一块统计,双击节点时连同它的出线一起选中:
const designer = document.createElement('xh-flow-designer')
designer.extensions = [leave, leaveTools]
designer.paletteGroupLabels = { leave: '请假' }
designer.propertyGroupOrder = ['basic', 'rule']
designer.propertyGroupLabels = { basic: '基本', rule: '规则' }
designer.schema = schema属性编辑器
编辑器是一个函数,拿到上下文,返回放进属性面板的元素,值或只读状态变化时设计器调用 update:
/** 审批人用原生下拉框选。 */
const personEditor: FlowPropertyEditor = (initial) => {
const select = document.createElement('select')
select.dataset.xhPart = 'control'
select.setAttribute('aria-label', initial.descriptor.label ?? '')
select.append(new Option('(未选)', ''), ...PEOPLE.map(name => new Option(name, name)))
let context = initial
select.addEventListener('change', () => {
context.setValue(select.value === '' ? undefined : select.value)
context.commit()
})
const update = (next: typeof initial): void => {
context = next
select.value = typeof next.value === 'string' ? next.value : ''
select.disabled = next.readonly
}
update(initial)
return { element: select, update }
}- 上下文里有当前值、属性描述、全部被编辑的对象与是否只读。
setValue写入新值,undefined表示清除;commit表示一次编辑结束,之后的写入另起一步撤销。 editors有三张表:byControl按编辑方式的种类,byPath按属性路径,byTarget按对象种类再按属性路径。对象种类是节点类型,连线是edge,流程定义是document;路径键是formatJsonPath的写法,如data.approver。- 查找时先看
byTarget,再看byPath,最后看byControl;后面的扩展压过前面的。 - 设计器内建的编辑器(如 XiHan 工作流预设的启动变量)垫在所有扩展之下,扩展在同一张表里登记同一个键就换掉它。
命令
const commands: FlowDesignerExtension['commands'] = [
{
id: '@example/leave-tools.add-approve',
label: '加一个审批',
placement: ['toolbar', 'menu:blank'],
enabled: ({ designer }) => !designer.readonly,
run: ({ designer }) => designer.addNode('approve'),
},
{
id: '@example/leave-tools.grid',
label: '吸附网格',
placement: ['toolbar'],
checked: ({ designer }) => designer.grid !== false,
run: ({ designer }) => {
designer.grid = designer.grid === false ? 16 : false
},
},
]id用扩展 id 加点加名字。placement写缺省出现在哪里:toolbar,菜单栏的menubar:file、menubar:edit、menubar:view、menubar:help,右键菜单的menu:node、menu:edge、menu:blank,选中工具条的selection:node、selection:nodes;扩展命令排在内置命令之后,自成一组(在菜单栏里排在那张菜单收尾的一组之前);不写时只能经快捷键或commandLayout出现。扩展还能用menubarMenus往菜单栏上加自己的菜单{ id, label, order? }(order写在 30–80 之间,排在「视图」与「帮助」之间),命令写menubar:<菜单 id>落进去。enabled不写时总是可用;写了checked就是开关式命令,按钮与菜单项显示选中状态;tone写danger时按危险操作的颜色显示。shortcut写成['Mod', 'Shift', 'P']这样的键序列,Mod在 macOS 上是 ⌘,其它系统是 Ctrl,画布有焦点时生效。run与其它回调拿到的上下文里有设计器本身与此刻的文案。
面板
const panels: FlowDesignerExtension['panels'] = [
{
region: { name: 'leave-summary', position: 'end', title: '统计' },
create: (context) => {
const element = document.createElement('p')
const update = ({ designer }: typeof context): void => {
const approvals = designer.schema.nodes.filter(node => node.type === 'approve').length
element.textContent = `共 ${designer.schema.nodes.length} 个节点,其中审批 ${approvals} 个`
}
update(context)
return { element, update }
},
},
]region是面板占的区:name不能与外框的区(menubar、toolbar、canvas、status)、内置面板(FLOW_PANELS里的物料、流程结构、属性、问题、历史记录与小地图)或别的扩展的重名;position是start、end、bottom之一,即左栏、右栏、下栏,同一栏的面板上下排列。- 写了
title才画成卡片:面板头写标题,带收起、「更多」(换栏、调序、浮出、收成标题条、隐藏)与关闭钮,按住面板头能拖到别处;icon用在工具栏「面板」菜单与浮出小窗的标题栏,help是面板头的说明,defaultHidden缺省不显示。卡片的做法见 XiHan.UI 的面板卡片。 create在给extensions赋值、装上这个扩展时调用一次;之后设计器每次更新都调用update,扩展被换下前调用destroy。- 扩展面板与内置面板一样进布局:能换栏、浮出、收起与隐藏,记在设置的
layout.panels里;每块各有一条开关命令panel:<区名>。
物料来源
paletteSources 往物料面板里加几组条目,接在节点类型的分组后面,适合放常用片段、行业模板这类「一点就放下一段」的物料:
export const snippets: FlowDesignerExtension = {
id: '@acme/flow-snippets',
paletteSources: [{
id: '@acme/flow-snippets.common',
groups: () => [{
id: '@acme/flow-snippets.common',
label: '常用片段',
items: [{ id: '@acme/flow-snippets.two-level-approval', label: '两级审批', description: '部门审批后转财务审批' }],
}],
insert: (_item, { designer }, at) => {
designer.paste(twoLevelApproval, { at: at ?? designer.getViewportCenter() })
},
}],
}groups在设计器每次更新时现取,返回几组物料;条目的写法同 designer-shell 的物料面板(label、icon、description、disabled、children等),内容与上一次一样时面板不重排。组 id 与条目 id(含下级)在整个物料面板里不能重名,节点类型的条目 id 就是类型名;重名时抛 TypeError。- 条目被点按或回车时调用
insert,不带落点;拖进画布松手时带上松手处的世界坐标。放什么、放在哪由来源自己定,常用的是designer.paste(片段, { at }):片段整体的中心放到at,左上角吸附网格;点按时没有落点,取getViewportCenter()放在视口中央。 order排各来源的先后,不写按 100。只读时物料面板的条目一概不能用。
导入与导出
importers 把粘贴、拖进画布的外部内容与打开的文件换成流程内容,exporters 把当前流程定义换成文件:
export const jsonFiles: FlowDesignerExtension = {
id: '@example/json-files',
importers: [{
id: '@example/json-files.open',
fileTypes: ['.json', 'application/json'],
accepts: source => source.file !== undefined && (source.mime === 'application/json' || source.name?.endsWith('.json') === true),
import: async (source) => {
const read = readDocument(JSON.parse(await source.file!.text()))
if (!read.ok)
throw new TypeError('这个文件不是流程定义')
return { document: read.document }
},
}],
exporters: [{
id: '@example/json-files.save',
label: '流程定义(JSON)',
formats: ['json'],
export: async (_format, { designer }) => JSON.stringify(designer.schema, null, 2),
}],
}- 来源有三处:粘贴(
kind为clipboard)、拖进画布(drop)与openFile()选的文件(file)。粘贴与拖放先列文件,再按链接、HTML、纯文本列出文本;来源按先后、导入器按扩展顺序问accepts,第一个接下的导入。设计器自己复制出的内容直接粘贴,不经过导入器。 import可以是异步的,返回{ nodes, edges }片段或{ document }整份流程定义,返回undefined时什么都不放。片段整体挪到落点(粘贴与打开文件时是视口中央,拖放时是指针处),左上角吸附网格,按粘贴的规则换新 id、选中并记一步撤销;整份定义换掉当前定义,同样记一步撤销,视口随后适配。import抛错,或结果的结构不合法、id 重复、用了没登记的节点类型时,派发import-error,detail里是导入器的 id、来源与错误,什么都不放;打开的文件没有导入器接下时也派发,此时没有导入器的 id。- 装了导入器后,工具栏最前面多出「打开文件」;宿主也可以自己调
openFile()。只读时导入一概不接。 fileTypes写这个导入器认的文件类型(.json这样的扩展名或 MIME),「打开文件」的文件框按各导入器写的合起来筛选;都没写时不限类型。它只管文件框里列哪些文件,接不接仍由accepts定。export(format, options)交给登记了这种格式的第一个导出器,options原样放进上下文;得到的文件内容或文本存到哪里由宿主定。没有导出器认这种格式时返回被拒的 Promise(RangeError)。设计器自带一个图片导出器,认svg与png,排在扩展的导出器之前(见设计器的导出图片)。
export function wire(designer: HTMLElementTagNameMap['xh-flow-designer']): void {
designer.extensions = [jsonFiles]
designer.addEventListener('import-error', (event) => {
const { importer, error } = (event as CustomEvent<{ importer?: string, error: unknown }>).detail
console.warn(importer ?? '没有导入器接下这个文件', error)
})
}
export async function save(designer: HTMLElementTagNameMap['xh-flow-designer']): Promise<void> {
const text = await designer.export('json')
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }))
const link = Object.assign(document.createElement('a'), { href: url, download: 'flow.json' })
link.click()
URL.revokeObjectURL(url)
}引导步骤
tourSteps 往新手引导里加几步,与通用步骤、流程自己的三步一起按 order 排:通用步骤是 0–60 与收尾的设置(900),流程的是 100 起,扩展的写 200 起。
export const simulation: FlowDesignerExtension = {
id: '@acme/flow-simulation',
tourSteps: [{
id: '@acme/flow-simulation.panel',
target: { panel: 'simulation' },
title: '模拟运行',
description: '在这里按步模拟流程的走向。',
order: 200,
}],
about: [{ label: '模拟运行', value: '1.0.0' }],
}id用扩展 id 加点加名字;不能与别的步骤重名,也不能用留给 designer-shell 的shell.前缀,否则给extensions赋值就抛DesignerExtensionError。target指要高亮的目标:{ panel }一块面板,{ command }工具栏上的一条命令,{ region }外框的区,{ menubar }菜单栏上的一张菜单(菜单 id,如file、view,窄屏时落在「菜单」钮上),{ selector }设计器里的任意节点;不给就居中显示。目标看不见的那步自动跳过。title、description可以是按上下文现算的函数,跟着语言变。
关于
about 是设置「关于」页里的几行 { label, value },接在设计器自己的版本与许可证后面,适合写附加包的版本与授权;上面的示例里就写了一行。
setup
setup 在设计器连上文档时调用,返回的函数在断开或扩展被换下时调用,适合加监听、开定时器这类要清理的事:
const leaveTools: FlowDesignerExtension = {
id: '@example/leave-tools',
editors: { byControl: { 'leave.person': personEditor } },
commands,
panels,
setup: ({ designer }) => {
const onDoubleClick = (event: Event): void => {
const { id } = (event as FlowDesignerEventMap['node-dblclick']).detail
designer.select({ nodes: [id], edges: designer.schema.edges.filter(edge => edge.source.node === id).map(edge => edge.id) })
}
designer.addEventListener('node-dblclick', onDoubleClick)
return () => designer.removeEventListener('node-dblclick', onDoubleClick)
},
}冲突
节点类型、属性路径、编辑方式、命令 id、快捷键、面板的区名、物料来源 id、导入器或导出器的 id、设置分区 id、引导步骤 id、扩展 id 在两个扩展里重复时,给 extensions 赋值当场抛 DesignerExtensionError(从 @xihan-ui/designer-kit 导入),元素保持原来的扩展。错误上带冲突的名字空间、名字与两个扩展的 id:
const other = { id: '@example/other', nodeTypes: [{ type: 'approve', size: { width: 160, height: 48 }, ports: 'both' as const }] }
try {
designer.extensions = [leave, other]
}
catch (error) {
if (!(error instanceof DesignerExtensionError))
throw error
// error.space 是 'node-type',error.key 是 'approve',error.extensions 是 ['@example/leave', '@example/other']
console.warn(error.message)
}