@bpmn-nova/studio 0.3.4-preview → 0.3.6-preview
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -298
- package/dist/config.js +285 -0
- package/dist/controller.js +19 -4
- package/dist/index.d.ts +101 -2
- package/dist/modules/export-svg/render.js +84 -64
- package/dist/modules/node-geometry/index.d.ts +19 -0
- package/dist/modules/node-geometry/index.js +147 -0
- package/dist/modules/renderer-svg/index.js +64 -25
- package/dist/modules/runtime/diagnostics.js +22 -0
- package/dist/modules/runtime/index.d.ts +38 -4
- package/dist/modules/runtime/index.js +272 -80
- package/dist/modules/runtime/time.js +41 -0
- package/dist/modules/runtime/visits.js +167 -0
- package/dist/modules/viewer/index.d.ts +1 -1
- package/dist/modules/viewer/index.js +220 -50
- package/dist/modules/viewer/runtime-trace.js +33 -30
- package/dist/modules/viewer/timeline.js +2 -2
- package/dist/panel-selection.js +60 -0
- package/dist/shell.js +315 -87
- package/dist/sidebars.js +195 -0
- package/dist/styles.css +72 -12
- package/llms-full.txt +3924 -826
- package/llms.txt +64 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,32 +4,21 @@ BPMN Nova 的 Vanilla JavaScript / TypeScript 完整入口,提供流程设计
|
|
|
4
4
|
|
|
5
5
|
> **English summary:** The complete framework-neutral BPMN Nova package for process design, viewing, approval traces, themes, properties, and pure SVG export.
|
|
6
6
|
|
|
7
|
-
> 当前版本为 `0.3.
|
|
7
|
+
> 当前版本为 `0.3.6-preview`。请使用 `@preview` 安装,并在生产接入前验证目标 BPMN XML 与引擎扩展。
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
11
|
-
## 什么时候使用
|
|
12
|
-
|
|
13
|
-
- Vanilla JavaScript 或 TypeScript 项目安装本包。
|
|
14
|
-
- React 18+ 项目安装 `@bpmn-nova/react`。
|
|
15
|
-
- Vue 3.3+ 项目安装 `@bpmn-nova/vue`。
|
|
16
|
-
- 一个项目只需要选择一个公开包,不需要安装 Core、Viewer、Runtime、Theme 等内部 Module。
|
|
17
|
-
|
|
18
11
|
## 安装
|
|
19
12
|
|
|
20
13
|
```bash
|
|
21
14
|
npm install @bpmn-nova/studio@preview
|
|
22
15
|
```
|
|
23
16
|
|
|
24
|
-
|
|
25
|
-
import '@bpmn-nova/studio/styles.css'
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
BPMN Nova 不会在 JavaScript 中隐式注入 CSS。视觉容器必须有明确高度。
|
|
17
|
+
Vanilla JavaScript / TypeScript 项目选择本包;React 18+ 和 Vue 3.3+ 项目分别选择 `@bpmn-nova/react` 或 `@bpmn-nova/vue`。
|
|
29
18
|
|
|
30
|
-
|
|
19
|
+
CSS 必须显式导入 `@bpmn-nova/studio/styles.css`;视觉容器及其父级需要具有可计算高度。
|
|
31
20
|
|
|
32
|
-
##
|
|
21
|
+
## 最小用法
|
|
33
22
|
|
|
34
23
|
```html
|
|
35
24
|
<div id="studio" style="height: 720px"></div>
|
|
@@ -43,14 +32,8 @@ import {
|
|
|
43
32
|
} from '@bpmn-nova/studio'
|
|
44
33
|
import '@bpmn-nova/studio/styles.css'
|
|
45
34
|
|
|
46
|
-
const model = createEmptyProcess('flowable')
|
|
47
|
-
model.id = 'Process_PurchaseApproval'
|
|
48
|
-
model.name = '采购申请审批流程'
|
|
49
|
-
|
|
50
35
|
const studio = createStudioController({
|
|
51
|
-
model,
|
|
52
|
-
allowedNodeTypes: ['startEvent', 'userTask', 'exclusiveGateway', 'endEvent'],
|
|
53
|
-
allowedEdgeTypes: ['sequenceFlow'],
|
|
36
|
+
model: createEmptyProcess('flowable'),
|
|
54
37
|
})
|
|
55
38
|
const shell = createStudioShell({
|
|
56
39
|
container: document.querySelector('#studio'),
|
|
@@ -60,288 +43,23 @@ const shell = createStudioShell({
|
|
|
60
43
|
theme: 'auto',
|
|
61
44
|
})
|
|
62
45
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
shell.destroy()
|
|
67
|
-
studio.destroy()
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
`allowedNodeTypes` 与 `allowedEdgeTypes` 同时约束 XML 导入、Palette、连接、快捷新增、模板和节点类型转换;不传时开放 Nova 当前支持的全部图元。`allowedModes` 控制默认工作台显示和允许切换的模式。不传 Runtime 时实例模式保持空运行事实,不会注入演示审批数据。
|
|
71
|
-
|
|
72
|
-
## 嵌入宿主工作台
|
|
73
|
-
|
|
74
|
-
默认 `BpmnStudioShell` 仍是包含 Header、Palette、Canvas、Properties 和 Statusbar 的完整工作台。宿主要复用自己的业务属性面板时,应显式隐藏 Nova 右侧区域,并把业务面板作为 Nova 根节点的外部兄弟区域:
|
|
75
|
-
|
|
76
|
-
```js
|
|
77
|
-
const shell = createStudioShell({
|
|
78
|
-
container: novaHost,
|
|
79
|
-
studio,
|
|
80
|
-
mode: 'design',
|
|
81
|
-
allowedModes: ['design'],
|
|
82
|
-
regions: { right: 'hidden' },
|
|
83
|
-
slots: {
|
|
84
|
-
headerStart({ container, actions, getState, subscribe }) {
|
|
85
|
-
// 在 Nova Header 左侧挂载宿主的返回入口、业务图标、流程名称和类型。
|
|
86
|
-
// 完整替换 Header 时改用 slots.header,并继续调用 actions。
|
|
87
|
-
},
|
|
88
|
-
headerActions({ container, actions, getMode, subscribeMode }) {
|
|
89
|
-
// 替换默认“校验 / 导入 / 导出”,在这里挂载宿主的“校验 / 保存 / 发布”。
|
|
90
|
-
// 校验按钮调用 actions.validate();保存和发布继续调用宿主服务。
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
theme: 'auto',
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
shell.setRegions({ right: 'default' })
|
|
97
|
-
shell.actions.undo()
|
|
98
|
-
shell.actions.redo()
|
|
99
|
-
shell.actions.fitView()
|
|
100
|
-
const issues = shell.actions.validate()
|
|
101
|
-
const xml = shell.actions.exportXml('flowable')
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
`regions` 支持 `header`、`left`、`right`、`footer` 的 `default | hidden` 状态;隐藏区域不占布局轨道。`setRegions()` 原地更新默认 Shell,不重建 Controller 或 Canvas。`layout()` 是完整布局替换,不能与 `regions` 同时使用。
|
|
105
|
-
|
|
106
|
-
`headerActions` 只替换 Header 最右侧动作组,`headerStart` 可与其同时使用,完整 `header` 的优先级更高。未提供 `headerActions` 时仍显示默认“校验 / 导入 / 导出”。顶部不再重复显示最佳视图;底部缩放区和 `fitView()` Interface 保持不变。
|
|
107
|
-
|
|
108
|
-
`shell.actions.validate()` 作为 Header 动作,以 `toolbar` 来源先更新 Nova 默认状态栏、再发送 Validation Event,最后返回 issues;程序化调用 `shell.validate()` 使用 `api` 来源。`valid` 表示没有 error,warning 不默认阻止发布:
|
|
109
|
-
|
|
110
|
-
```js
|
|
111
|
-
const offValidation = shell.subscribeValidation((event) => {
|
|
112
|
-
console.log(event.source, event.valid, event.errorCount, event.warningCount, event.issues)
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
shell.validate()
|
|
116
|
-
offValidation()
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
宿主发布按钮可以复用 Toolbar 来源的完整流程;仅 error 默认阻止发布:
|
|
120
|
-
|
|
121
|
-
```js
|
|
122
|
-
async function publishProcess(actions) {
|
|
123
|
-
const issues = actions.validate()
|
|
124
|
-
if (issues.some((issue) => issue.level === 'error')) return
|
|
125
|
-
await publishXml(actions.exportXml())
|
|
126
|
-
}
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
`shell.actions` 只封装撤销、重做、布局、视图、校验和 BPMN/SVG 导入导出。宿主的保存草稿、发布、权限、文件选择和服务端事务不属于 Nova Actions。
|
|
130
|
-
|
|
131
|
-
## Mode 与节点副标题投影
|
|
132
|
-
|
|
133
|
-
`design`、`viewer`、`instance` 分别表示流程设计、流程展示和审批轨迹。Shell 是唯一 Mode 状态源:
|
|
134
|
-
|
|
135
|
-
```js
|
|
136
|
-
const unsubscribe = shell.subscribeMode((event) => {
|
|
137
|
-
console.log(event.mode, event.previousMode, event.source, event.allowedModes)
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
shell.getMode()
|
|
141
|
-
shell.setMode('viewer')
|
|
142
|
-
shell.setAllowedModes(['design', 'viewer'])
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
`setMode()` 仅在实际成功切换时返回 `true`。`setAllowedModes()` 拒绝空数组、重复值和未知值;若移除当前 Mode,会回退到新数组首项并发送一次 `allowed-modes` 事件。切换只改变 Shell 展示,不进入 Model、History 或 Studio State。
|
|
146
|
-
|
|
147
|
-
标准任务与容器卡片支持定义态副标题 Resolver:
|
|
148
|
-
|
|
149
|
-
```js
|
|
150
|
-
const summaries = new Map()
|
|
151
|
-
const shell = createStudioShell({
|
|
152
|
-
container,
|
|
153
|
-
studio,
|
|
154
|
-
nodeSubtitleResolver({ node, mode, surface, defaultSubtitle }) {
|
|
155
|
-
return summaries.has(node.id) ? summaries.get(node.id) : undefined
|
|
156
|
-
},
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
summaries.set('ServiceTask_Archive', '归档到采购系统')
|
|
160
|
-
shell.refreshPresentation()
|
|
161
|
-
summaries.set('ServiceTask_Archive', null)
|
|
162
|
-
shell.refreshPresentation()
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
`undefined` 保留默认副标题,`null` 移除副标题行,字符串(包括空字符串)作为覆盖值。Resolver 同步应用于 Design、Viewer 与标准 SVG 降级视觉;完整自定义 Renderer 优先,Instance 始终使用 Runtime Presentation。`refreshPresentation()` 不写 XML、不创建历史、不改变选择、Scope 或视口。
|
|
166
|
-
|
|
167
|
-
## 只读 Viewer
|
|
168
|
-
|
|
169
|
-
```js
|
|
170
|
-
import { importBpmn } from '@bpmn-nova/studio'
|
|
171
|
-
import { BpmnViewer } from '@bpmn-nova/studio/viewer'
|
|
172
|
-
import '@bpmn-nova/studio/styles.css'
|
|
173
|
-
|
|
174
|
-
const viewer = new BpmnViewer({
|
|
175
|
-
container: document.querySelector('#viewer'),
|
|
176
|
-
model: importBpmn(xml, 'flowable'),
|
|
177
|
-
projection: 'standard',
|
|
178
|
-
theme: 'auto',
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
viewer.fitView()
|
|
182
|
-
viewer.destroy()
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
也可以从 `@bpmn-nova/studio` 根入口导入所有公开 Interface;子路径用于明确表达接入场景。
|
|
186
|
-
|
|
187
|
-

|
|
188
|
-
|
|
189
|
-
## 审批轨迹
|
|
190
|
-
|
|
191
|
-
Viewer 接收引擎中立的 Runtime Snapshot。三种投影使用相同数据:
|
|
192
|
-
|
|
193
|
-
| 投影 | 配置 | 适用场景 |
|
|
194
|
-
| --- | --- | --- |
|
|
195
|
-
| 实际路径 | `projection: 'approval'` | 只展示实际发生的有效路径 |
|
|
196
|
-
| 移动时间线 | `projection: 'compact'` | 按访问轮次展示动作、图片和附件 |
|
|
197
|
-
| 完整 BPMN | `projection: 'standard'` + `runtime` | 完整结构叠加运行状态 |
|
|
198
|
-
|
|
199
|
-
```js
|
|
200
|
-
const runtime = {
|
|
201
|
-
processInstanceId: 'purchase-20260824',
|
|
202
|
-
status: 'running',
|
|
203
|
-
activities: [{
|
|
204
|
-
id: 'activity-manager-1',
|
|
205
|
-
elementId: 'UserTask_Manager',
|
|
206
|
-
visitId: 'visit-manager-1',
|
|
207
|
-
status: 'completed',
|
|
208
|
-
assignee: '李经理',
|
|
209
|
-
endTime: '2026-08-24T10:12:00+08:00',
|
|
210
|
-
}],
|
|
211
|
-
actions: [{
|
|
212
|
-
id: 'action-manager-approve',
|
|
213
|
-
type: 'approve',
|
|
214
|
-
elementId: 'UserTask_Manager',
|
|
215
|
-
visitId: 'visit-manager-1',
|
|
216
|
-
activityId: 'activity-manager-1',
|
|
217
|
-
actor: { id: 'manager-li', name: '李经理' },
|
|
218
|
-
occurredAt: '2026-08-24T10:12:00+08:00',
|
|
219
|
-
content: {
|
|
220
|
-
plainText: '资料完整,同意提交总经理审批。',
|
|
221
|
-
blocks: [
|
|
222
|
-
{ type: 'paragraph', text: '资料完整,同意提交总经理审批。' },
|
|
223
|
-
{ type: 'image', assetId: 'quotation-preview' },
|
|
224
|
-
{ type: 'file', assetId: 'purchase-checklist' },
|
|
225
|
-
],
|
|
226
|
-
assets: [
|
|
227
|
-
{
|
|
228
|
-
id: 'quotation-preview',
|
|
229
|
-
name: '现场报价单.png',
|
|
230
|
-
mediaType: 'image/png',
|
|
231
|
-
width: 1280,
|
|
232
|
-
height: 720,
|
|
233
|
-
},
|
|
234
|
-
{
|
|
235
|
-
id: 'purchase-checklist',
|
|
236
|
-
name: '采购核验清单.txt',
|
|
237
|
-
mediaType: 'text/plain',
|
|
238
|
-
size: 248,
|
|
239
|
-
},
|
|
240
|
-
],
|
|
241
|
-
},
|
|
242
|
-
}],
|
|
243
|
-
visitedEdges: [],
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const runtimeAssetResolver = async (asset, { purpose, signal }) => {
|
|
247
|
-
const response = await fetch(
|
|
248
|
-
`/api/runtime-assets/${encodeURIComponent(asset.id)}?purpose=${purpose}`,
|
|
249
|
-
{ signal },
|
|
250
|
-
)
|
|
251
|
-
if (!response.ok) return null
|
|
252
|
-
return response.url
|
|
46
|
+
function dispose() {
|
|
47
|
+
shell.destroy()
|
|
48
|
+
studio.destroy()
|
|
253
49
|
}
|
|
254
|
-
|
|
255
|
-
viewer.setRuntime(runtime)
|
|
256
|
-
viewer.setProjection('compact')
|
|
257
|
-
viewer.setDisplayOptions({ runtimeAssetResolver })
|
|
258
50
|
```
|
|
259
51
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
| 实际路径 | 移动时间线 |
|
|
263
|
-
| --- | --- |
|
|
264
|
-
|  |  |
|
|
265
|
-
|
|
266
|
-

|
|
267
|
-
|
|
268
|
-
## 主题与运行态配色
|
|
269
|
-
|
|
270
|
-
```js
|
|
271
|
-
shell.setTheme({
|
|
272
|
-
mode: 'auto',
|
|
273
|
-
dark: {
|
|
274
|
-
colors: {
|
|
275
|
-
canvas: '#0b1018',
|
|
276
|
-
surface: '#18202b',
|
|
277
|
-
},
|
|
278
|
-
},
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
shell.setRuntimeAppearance({
|
|
282
|
-
statuses: { completed: 'success', active: 'primary' },
|
|
283
|
-
actions: {
|
|
284
|
-
approve: { label: '通过', tone: 'success' },
|
|
285
|
-
reject: { label: '驳回', tone: 'danger' },
|
|
286
|
-
},
|
|
287
|
-
})
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
主题限定在实例根节点;`auto` 会跟随并监听浏览器系统主题变化,不修改宿主页面的 `<html>`。
|
|
291
|
-
|
|
292
|
-

|
|
293
|
-
|
|
294
|
-
## SVG 导出
|
|
295
|
-
|
|
296
|
-
```js
|
|
297
|
-
// 打开确认预览,用户确认后下载。
|
|
298
|
-
shell.openSvgExportPreview({
|
|
299
|
-
theme: 'current',
|
|
300
|
-
transparentBackground: false,
|
|
301
|
-
filename: '采购申请审批流程.svg',
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
// 或只创建 Artifact,不触发下载。
|
|
305
|
-
const artifact = await shell.exportSvg({ theme: 'dark' })
|
|
306
|
-
console.log(artifact.svg, artifact.warnings)
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
导出完整业务内容,不受当前缩放、平移或滚动影响。图片会通过 Resolver 嵌入 SVG,普通附件只展示摘要。
|
|
310
|
-
|
|
311
|
-

|
|
312
|
-
|
|
313
|
-
## 支持的子路径
|
|
314
|
-
|
|
315
|
-
```js
|
|
316
|
-
import { BpmnDesigner } from '@bpmn-nova/studio/designer'
|
|
317
|
-
import { BpmnViewer } from '@bpmn-nova/studio/viewer'
|
|
318
|
-
import { createRuntimePresentation } from '@bpmn-nova/studio/runtime'
|
|
319
|
-
import { createThemeController } from '@bpmn-nova/studio/theme'
|
|
320
|
-
import { exportDiagramSvg } from '@bpmn-nova/studio/export-svg'
|
|
321
|
-
import { flowableProfile } from '@bpmn-nova/studio/flowable'
|
|
322
|
-
import { activitiProfile } from '@bpmn-nova/studio/activiti'
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
## 常见问题
|
|
326
|
-
|
|
327
|
-
- **界面为空或高度为 0:** 为 Studio/Viewer 的父容器设置明确高度。
|
|
328
|
-
- **界面没有样式:** 导入一次 `@bpmn-nova/studio/styles.css`。
|
|
329
|
-
- **附件不可用:** 提供 `runtimeAssetResolver`,并为不同 purpose 返回当前有效地址。
|
|
330
|
-
- **SSR 报错:** 在浏览器挂载阶段创建实例,服务端只准备 XML 和 Runtime 数据。
|
|
331
|
-
- **重复安装:** 不直接安装任何未列出的 `@bpmn-nova/*` 内部 Module。
|
|
332
|
-
|
|
333
|
-
## 文档与 AI
|
|
52
|
+
在页面卸载时调用 `dispose()`;SSR 应用只在客户端创建视觉实例。
|
|
334
53
|
|
|
335
|
-
|
|
336
|
-
- npm 包内 `llms-full.txt`:完整 AI 上下文
|
|
337
|
-
- [项目首页](https://github.com/daxiangme/bpmn-nova)
|
|
338
|
-
- [快速开始](https://github.com/daxiangme/bpmn-nova/blob/dev/docs/GETTING-STARTED.md)
|
|
339
|
-
- [公开 Interface](https://github.com/daxiangme/bpmn-nova/blob/dev/docs/API.md)
|
|
340
|
-
- [自定义指南](https://github.com/daxiangme/bpmn-nova/blob/dev/docs/CUSTOMIZATION.md)
|
|
54
|
+
## 完整文档
|
|
341
55
|
|
|
342
|
-
|
|
56
|
+
- [官网与文档](https://github.com/daxiangme/bpmn-nova/blob/dev/docs/src/index.md)
|
|
57
|
+
- [Studio 接入、宿主组合和完整示例](https://github.com/daxiangme/bpmn-nova/blob/dev/docs/src/guide/studio.md)
|
|
58
|
+
- [组件参数与事件](https://github.com/daxiangme/bpmn-nova/blob/dev/docs/src/components/index.md)、[公开 API](https://github.com/daxiangme/bpmn-nova/blob/dev/docs/src/api/index.md)
|
|
59
|
+
- [审批轨迹接入](https://github.com/daxiangme/bpmn-nova/blob/dev/docs/src/guide/runtime.md)、[自定义扩展](https://github.com/daxiangme/bpmn-nova/blob/dev/docs/src/customization/index.md)
|
|
60
|
+
- [更新日志](https://github.com/daxiangme/bpmn-nova/blob/dev/docs/src/changelog.md)
|
|
343
61
|
|
|
344
|
-
|
|
62
|
+
官网尚未部署,以上链接暂指向仓库文档。AI 接入从随包的 `llms.txt` 开始;`llms-full.txt` 提供完整离线上下文,两份文件都包含在 npm tarball 中。
|
|
345
63
|
|
|
346
64
|
## License
|
|
347
65
|
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
const CONTROL_SIZE_PRESETS = Object.freeze({
|
|
2
|
+
small: 28,
|
|
3
|
+
medium: 32,
|
|
4
|
+
large: 40,
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
const STUDIO_SHELL_REGIONS = Object.freeze(['header', 'left', 'right', 'footer']);
|
|
8
|
+
const STUDIO_PROJECTIONS = Object.freeze(['auto', 'standard', 'approval', 'compact']);
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_STUDIO_SHELL_REGIONS = Object.freeze({
|
|
11
|
+
header: 'default',
|
|
12
|
+
left: 'default',
|
|
13
|
+
right: 'default',
|
|
14
|
+
footer: 'default',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function isObject(value) {
|
|
18
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertObject(value, path) {
|
|
22
|
+
if (!isObject(value)) throw new TypeError(`${path} must be an object.`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasOwn(object, key) {
|
|
27
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function copyValue(value) {
|
|
31
|
+
if (Array.isArray(value)) return value.map(copyValue);
|
|
32
|
+
if (!isObject(value)) return value;
|
|
33
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyValue(item)]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function freezeValue(value) {
|
|
37
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
38
|
+
Object.values(value).forEach(freezeValue);
|
|
39
|
+
return Object.freeze(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function immutableCopy(value) {
|
|
43
|
+
return freezeValue(copyValue(value));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function optionalObject(value, path) {
|
|
47
|
+
if (value === undefined) return undefined;
|
|
48
|
+
return immutableCopy(assertObject(value, path));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function optionalTypeList(value, path) {
|
|
52
|
+
if (value === undefined || value === null) return undefined;
|
|
53
|
+
if (typeof value === 'string' || typeof value?.[Symbol.iterator] !== 'function') {
|
|
54
|
+
throw new TypeError(`${path} must be a readonly array or iterable.`);
|
|
55
|
+
}
|
|
56
|
+
const entries = [...value];
|
|
57
|
+
if (entries.some((item) => typeof item !== 'string')) throw new TypeError(`${path} must contain only strings.`);
|
|
58
|
+
return Object.freeze(entries);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizePropertiesProfile(value) {
|
|
62
|
+
if (value === undefined) return 'business';
|
|
63
|
+
if (value !== 'business' && value !== 'developer') {
|
|
64
|
+
throw new TypeError('config.modeling.propertiesProfile must be "business" or "developer".');
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeSidebarWidth(value, fallback, path) {
|
|
70
|
+
if (value === undefined) return fallback;
|
|
71
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
72
|
+
throw new TypeError(`${path} must be a finite non-negative number.`);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeSidebarConfig(value, side) {
|
|
78
|
+
const path = `config.ui.${side}Panel`;
|
|
79
|
+
const input = value === undefined ? {} : assertObject(value, path);
|
|
80
|
+
for (const key of ['collapsible', 'defaultCollapsed']) {
|
|
81
|
+
if (input[key] !== undefined && typeof input[key] !== 'boolean') {
|
|
82
|
+
throw new TypeError(`${path}.${key} must be a boolean.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const result = { collapsible: input.collapsible ?? true, defaultCollapsed: input.defaultCollapsed ?? false };
|
|
86
|
+
if (side === 'right') {
|
|
87
|
+
const layout = input.layout === undefined ? 'flex' : input.layout;
|
|
88
|
+
if (!['flex', 'scroll'].includes(layout)) throw new TypeError(`${path}.layout must be flex or scroll.`);
|
|
89
|
+
result.layout = layout;
|
|
90
|
+
}
|
|
91
|
+
return Object.freeze(result);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function normalizeStudioControlSize(value) {
|
|
95
|
+
if (value === undefined) return 'medium';
|
|
96
|
+
if (typeof value !== 'string') {
|
|
97
|
+
throw new TypeError('config.ui.controlSize must be small, medium, large, or a 24px–48px CSS pixel value.');
|
|
98
|
+
}
|
|
99
|
+
if (hasOwn(CONTROL_SIZE_PRESETS, value)) return value;
|
|
100
|
+
if (!/^(?:\d+(?:\.\d+)?|\.\d+)px$/.test(value)) {
|
|
101
|
+
throw new TypeError('config.ui.controlSize must be small, medium, large, or a 24px–48px CSS pixel value.');
|
|
102
|
+
}
|
|
103
|
+
const pixels = Number(value.slice(0, -2));
|
|
104
|
+
if (!Number.isFinite(pixels) || pixels < 24 || pixels > 48) {
|
|
105
|
+
throw new RangeError('config.ui.controlSize must be between 24px and 48px.');
|
|
106
|
+
}
|
|
107
|
+
return `${pixels}px`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function resolveStudioControlMetrics(controlSize) {
|
|
111
|
+
const normalized = normalizeStudioControlSize(controlSize);
|
|
112
|
+
const height = CONTROL_SIZE_PRESETS[normalized] || Number(normalized.slice(0, -2));
|
|
113
|
+
const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value));
|
|
114
|
+
const pixels = (value) => `${Number(value.toFixed(2))}px`;
|
|
115
|
+
return Object.freeze({
|
|
116
|
+
controlSize: normalized,
|
|
117
|
+
controlHeight: pixels(height),
|
|
118
|
+
headerHeight: pixels(height + 26),
|
|
119
|
+
modeHeight: pixels(height + 4),
|
|
120
|
+
modeButtonHeight: pixels(height - 4),
|
|
121
|
+
projectionButtonHeight: pixels(height - 6),
|
|
122
|
+
iconButtonSize: pixels(height - 1),
|
|
123
|
+
splitCaretWidth: pixels(height - 5),
|
|
124
|
+
fontSize: pixels(clamp(height * 0.25 + 4, 11, 14)),
|
|
125
|
+
paddingInline: pixels(clamp((height - 12) / 2, 8, 14)),
|
|
126
|
+
modePaddingInline: pixels(clamp(height / 2 - 4, 10, 14)),
|
|
127
|
+
iconSize: pixels(clamp(height / 2 - 2, 12, 18)),
|
|
128
|
+
radius: pixels(clamp(height / 4 - 1, 6, 9)),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function normalizeStudioRegions(regions, current = DEFAULT_STUDIO_SHELL_REGIONS) {
|
|
133
|
+
if (regions === undefined || regions === null) return { ...current };
|
|
134
|
+
assertObject(regions, 'config.ui.regions');
|
|
135
|
+
for (const name of Object.keys(regions)) {
|
|
136
|
+
if (!STUDIO_SHELL_REGIONS.includes(name)) throw new Error(`Unknown Studio Shell region: ${name}.`);
|
|
137
|
+
if (!['default', 'hidden'].includes(regions[name])) {
|
|
138
|
+
throw new Error(`Studio Shell region "${name}" must be "default" or "hidden".`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { ...current, ...regions };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeProjectionOptions(value) {
|
|
145
|
+
if (value === undefined || value === null) return undefined;
|
|
146
|
+
if (!Array.isArray(value)) throw new TypeError('config.viewer.projectionOptions must be a readonly array.');
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const options = value.map((item, index) => {
|
|
149
|
+
assertObject(item, `config.viewer.projectionOptions[${index}]`);
|
|
150
|
+
if (!STUDIO_PROJECTIONS.includes(item.value)) {
|
|
151
|
+
throw new TypeError(`config.viewer.projectionOptions[${index}].value must be auto, standard, approval, or compact.`);
|
|
152
|
+
}
|
|
153
|
+
if (seen.has(item.value)) throw new TypeError(`config.viewer.projectionOptions contains duplicate value "${item.value}".`);
|
|
154
|
+
if (typeof item.label !== 'string' || !item.label.trim()) {
|
|
155
|
+
throw new TypeError(`config.viewer.projectionOptions[${index}].label must be a non-empty string.`);
|
|
156
|
+
}
|
|
157
|
+
seen.add(item.value);
|
|
158
|
+
return Object.freeze({ value: item.value, label: item.label });
|
|
159
|
+
});
|
|
160
|
+
return Object.freeze(options);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeStudioConfigInput(config) {
|
|
164
|
+
if (config === undefined) return {};
|
|
165
|
+
return assertObject(config, 'config');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function assignPath(target, section, key, value) {
|
|
169
|
+
if (!target[section]) target[section] = {};
|
|
170
|
+
target[section][key] = value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function applyKnownConfig(target, source) {
|
|
174
|
+
if (!source) return;
|
|
175
|
+
const config = normalizeStudioConfigInput(source);
|
|
176
|
+
for (const section of ['modeling', 'ui', 'viewer']) {
|
|
177
|
+
if (config[section] !== undefined) target[section] = { ...(target[section] || {}), ...assertObject(config[section], `config.${section}`) };
|
|
178
|
+
}
|
|
179
|
+
if (hasOwn(config, 'export')) target.export = config.export;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function applyLegacyConfig(target, legacy) {
|
|
183
|
+
if (!legacy) return;
|
|
184
|
+
for (const [key, path] of Object.entries({
|
|
185
|
+
propertiesProfile: ['modeling', 'propertiesProfile'],
|
|
186
|
+
allowedNodeTypes: ['modeling', 'allowedNodeTypes'],
|
|
187
|
+
allowedEdgeTypes: ['modeling', 'allowedEdgeTypes'],
|
|
188
|
+
regions: ['ui', 'regions'],
|
|
189
|
+
leftWidth: ['ui', 'leftWidth'],
|
|
190
|
+
rightWidth: ['ui', 'rightWidth'],
|
|
191
|
+
responsive: ['viewer', 'responsive'],
|
|
192
|
+
projectionOptions: ['viewer', 'projectionOptions'],
|
|
193
|
+
timeline: ['viewer', 'timeline'],
|
|
194
|
+
runtimeDetails: ['viewer', 'runtimeDetails'],
|
|
195
|
+
runtimeTraceOptions: ['viewer', 'runtimeTraceOptions'],
|
|
196
|
+
})) {
|
|
197
|
+
if (hasOwn(legacy, key) && legacy[key] !== undefined) assignPath(target, path[0], path[1], legacy[key]);
|
|
198
|
+
}
|
|
199
|
+
if (hasOwn(legacy, 'svgExport') && legacy.svgExport !== undefined) target.export = legacy.svgExport;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function normalizeStudioConfig(config, { fallback = null, legacy = null } = {}) {
|
|
203
|
+
const merged = {};
|
|
204
|
+
applyKnownConfig(merged, fallback);
|
|
205
|
+
applyKnownConfig(merged, config);
|
|
206
|
+
applyLegacyConfig(merged, legacy);
|
|
207
|
+
|
|
208
|
+
const modelingInput = merged.modeling === undefined ? {} : assertObject(merged.modeling, 'config.modeling');
|
|
209
|
+
const uiInput = merged.ui === undefined ? {} : assertObject(merged.ui, 'config.ui');
|
|
210
|
+
const viewerInput = merged.viewer === undefined ? {} : assertObject(merged.viewer, 'config.viewer');
|
|
211
|
+
const sidebarInput = uiInput.sidebarWidth === undefined
|
|
212
|
+
? {}
|
|
213
|
+
: assertObject(uiInput.sidebarWidth, 'config.ui.sidebarWidth');
|
|
214
|
+
const leftWidth = hasOwn(uiInput, 'leftWidth') ? uiInput.leftWidth : sidebarInput.left;
|
|
215
|
+
const rightWidth = hasOwn(uiInput, 'rightWidth') ? uiInput.rightWidth : sidebarInput.right;
|
|
216
|
+
const controlSize = normalizeStudioControlSize(uiInput.controlSize);
|
|
217
|
+
const allowedNodeTypes = optionalTypeList(modelingInput.allowedNodeTypes, 'config.modeling.allowedNodeTypes');
|
|
218
|
+
const allowedEdgeTypes = optionalTypeList(modelingInput.allowedEdgeTypes, 'config.modeling.allowedEdgeTypes');
|
|
219
|
+
const responsive = viewerInput.responsive === undefined ? false : viewerInput.responsive;
|
|
220
|
+
if (typeof responsive !== 'boolean') throw new TypeError('config.viewer.responsive must be a boolean.');
|
|
221
|
+
|
|
222
|
+
const modeling = {
|
|
223
|
+
propertiesProfile: normalizePropertiesProfile(modelingInput.propertiesProfile),
|
|
224
|
+
...(allowedNodeTypes ? { allowedNodeTypes } : {}),
|
|
225
|
+
...(allowedEdgeTypes ? { allowedEdgeTypes } : {}),
|
|
226
|
+
};
|
|
227
|
+
const ui = {
|
|
228
|
+
controlSize,
|
|
229
|
+
// Keep omitted regions omitted: an explicit right='default' opts into the
|
|
230
|
+
// Instance sidebar, whereas the mode default must survive get/setConfig.
|
|
231
|
+
regions: Object.freeze(normalizeStudioRegions(uiInput.regions, {})),
|
|
232
|
+
leftPanel: normalizeSidebarConfig(uiInput.leftPanel, 'left'),
|
|
233
|
+
rightPanel: normalizeSidebarConfig(uiInput.rightPanel, 'right'),
|
|
234
|
+
sidebarWidth: Object.freeze({
|
|
235
|
+
left: normalizeSidebarWidth(leftWidth, 244, 'config.ui.sidebarWidth.left'),
|
|
236
|
+
right: normalizeSidebarWidth(rightWidth, 360, 'config.ui.sidebarWidth.right'),
|
|
237
|
+
}),
|
|
238
|
+
};
|
|
239
|
+
const projectionOptions = normalizeProjectionOptions(viewerInput.projectionOptions);
|
|
240
|
+
const timeline = optionalObject(viewerInput.timeline, 'config.viewer.timeline');
|
|
241
|
+
const runtimeDetails = optionalObject(viewerInput.runtimeDetails, 'config.viewer.runtimeDetails');
|
|
242
|
+
const runtimeTraceOptions = optionalObject(viewerInput.runtimeTraceOptions, 'config.viewer.runtimeTraceOptions');
|
|
243
|
+
const viewer = {
|
|
244
|
+
responsive,
|
|
245
|
+
...(projectionOptions ? { projectionOptions } : {}),
|
|
246
|
+
...(timeline ? { timeline } : {}),
|
|
247
|
+
...(runtimeDetails ? { runtimeDetails } : {}),
|
|
248
|
+
...(runtimeTraceOptions ? { runtimeTraceOptions } : {}),
|
|
249
|
+
};
|
|
250
|
+
const exportOptions = merged.export === undefined || merged.export === null
|
|
251
|
+
? undefined
|
|
252
|
+
: optionalObject(merged.export, 'config.export');
|
|
253
|
+
|
|
254
|
+
return Object.freeze({
|
|
255
|
+
modeling: Object.freeze(modeling),
|
|
256
|
+
ui: Object.freeze(ui),
|
|
257
|
+
viewer: Object.freeze(viewer),
|
|
258
|
+
...(exportOptions ? { export: exportOptions } : {}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function createStudioUiContext(getMetrics) {
|
|
263
|
+
return Object.freeze({
|
|
264
|
+
get controlSize() { return getMetrics().controlSize; },
|
|
265
|
+
get controlHeight() { return getMetrics().controlHeight; },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function applyStudioControlMetrics(root, metrics) {
|
|
270
|
+
const properties = {
|
|
271
|
+
'--nova-control-height': metrics.controlHeight,
|
|
272
|
+
'--nova-header-height': metrics.headerHeight,
|
|
273
|
+
'--nova-control-font-size': metrics.fontSize,
|
|
274
|
+
'--nova-control-padding-inline': metrics.paddingInline,
|
|
275
|
+
'--nova-mode-control-padding-inline': metrics.modePaddingInline,
|
|
276
|
+
'--nova-control-icon-size': metrics.iconSize,
|
|
277
|
+
'--nova-control-radius': metrics.radius,
|
|
278
|
+
'--nova-mode-control-height': metrics.modeHeight,
|
|
279
|
+
'--nova-mode-button-height': metrics.modeButtonHeight,
|
|
280
|
+
'--nova-projection-button-height': metrics.projectionButtonHeight,
|
|
281
|
+
'--nova-icon-button-size': metrics.iconButtonSize,
|
|
282
|
+
'--nova-split-caret-width': metrics.splitCaretWidth,
|
|
283
|
+
};
|
|
284
|
+
for (const [name, value] of Object.entries(properties)) root.style.setProperty(name, value);
|
|
285
|
+
}
|
package/dist/controller.js
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
selectedLayoutNodes,
|
|
37
37
|
selectionBounds,
|
|
38
38
|
} from './selection-layout.js';
|
|
39
|
+
import { normalizeStudioConfig } from './config.js';
|
|
39
40
|
|
|
40
41
|
const SEQUENCE_FLOW_KINDS = new Set(['event', 'boundary', 'task', 'container', 'gateway']);
|
|
41
42
|
|
|
@@ -86,17 +87,31 @@ function normalizeAllowedEdgeTypes(allowedEdgeTypes) {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
export class BpmnStudioController {
|
|
89
|
-
constructor(
|
|
90
|
+
constructor(options = {}) {
|
|
91
|
+
const {
|
|
92
|
+
model,
|
|
93
|
+
historyLimit = 80,
|
|
94
|
+
extensions = [],
|
|
95
|
+
propertiesProfile,
|
|
96
|
+
allowedNodeTypes,
|
|
97
|
+
allowedEdgeTypes,
|
|
98
|
+
config,
|
|
99
|
+
} = options;
|
|
90
100
|
if (!model) throw new Error('BpmnStudioController requires a model.');
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
const legacy = {};
|
|
102
|
+
if (Object.prototype.hasOwnProperty.call(options, 'propertiesProfile')) legacy.propertiesProfile = propertiesProfile;
|
|
103
|
+
if (Object.prototype.hasOwnProperty.call(options, 'allowedNodeTypes')) legacy.allowedNodeTypes = allowedNodeTypes;
|
|
104
|
+
if (Object.prototype.hasOwnProperty.call(options, 'allowedEdgeTypes')) legacy.allowedEdgeTypes = allowedEdgeTypes;
|
|
105
|
+
const normalizedConfig = normalizeStudioConfig(config, { legacy });
|
|
106
|
+
this._allowedNodeTypes = normalizeAllowedNodeTypes(normalizedConfig.modeling.allowedNodeTypes);
|
|
107
|
+
this._allowedEdgeTypes = normalizeAllowedEdgeTypes(normalizedConfig.modeling.allowedEdgeTypes);
|
|
93
108
|
this._assertAllowedModel(model);
|
|
94
109
|
this._model = model;
|
|
95
110
|
this.history = new HistoryStack(historyLimit);
|
|
96
111
|
this.selection = { kind: 'process', id: model.id };
|
|
97
112
|
this.activeScopeId = model.id;
|
|
98
113
|
this.connectingSource = null;
|
|
99
|
-
this.propertiesProfile = propertiesProfile;
|
|
114
|
+
this.propertiesProfile = normalizedConfig.modeling.propertiesProfile;
|
|
100
115
|
this._listeners = new Set();
|
|
101
116
|
this._extensions = new Map();
|
|
102
117
|
applyContainmentOperation(this._model, { type: 'reconcile' });
|