@bpmn-nova/studio 0.3.0-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/dist/shell.js ADDED
@@ -0,0 +1,608 @@
1
+ import { createDefaultIconRegistry, hydrateIcons } from '@bpmn-nova/icons';
2
+ import { createDefaultPaletteRegistry, PalettePanel } from '@bpmn-nova/palette';
3
+ import { createDefaultPropertiesRegistry, PropertiesPanel } from '@bpmn-nova/properties';
4
+ import { demoRuntime } from '@bpmn-nova/runtime';
5
+ import { BpmnViewer } from '@bpmn-nova/viewer';
6
+ import { ThemeController } from '@bpmn-nova/theme';
7
+ import { BpmnCanvas } from './canvas.js';
8
+ import { createDefaultContextMenuRegistry } from './context-menu.js';
9
+ import { createInteractionController, createTemplateRegistry } from './interactions.js';
10
+
11
+ function node(tag, className, text) {
12
+ const item = document.createElement(tag);
13
+ if (className) item.className = className;
14
+ if (text !== undefined) item.textContent = text;
15
+ return item;
16
+ }
17
+
18
+ function iconNode(id, className = 'nova-icon nova-icon-sm') {
19
+ const item = node('span', className);
20
+ item.dataset.icon = id;
21
+ return item;
22
+ }
23
+
24
+ function mountSlot(slot, container, context) {
25
+ if (!slot) return null;
26
+ if (slot instanceof HTMLElement) { container.appendChild(slot); return null; }
27
+ if (typeof slot === 'function') return slot({ container, ...context });
28
+ return null;
29
+ }
30
+
31
+ export class BpmnStudioShell {
32
+ constructor({
33
+ container,
34
+ studio,
35
+ iconRegistry = createDefaultIconRegistry(),
36
+ paletteRegistry = createDefaultPaletteRegistry(),
37
+ propertiesRegistry = null,
38
+ templateRegistry = createTemplateRegistry(),
39
+ contextMenuRegistry = createDefaultContextMenuRegistry(),
40
+ rendererOptions = {},
41
+ slots = {},
42
+ layout = null,
43
+ runtime = demoRuntime(),
44
+ mode = 'design',
45
+ projection = undefined,
46
+ responsive = false,
47
+ projectionOptions = null,
48
+ leftWidth = 244,
49
+ rightWidth = 360,
50
+ theme = null,
51
+ runtimeAppearance = null,
52
+ onThemeChange = null,
53
+ } = {}) {
54
+ if (!container || !studio) throw new Error('BpmnStudioShell requires container and studio.');
55
+ const instanceProjection = projection || (responsive ? 'auto' : 'approval');
56
+ const activeProjection = mode === 'instance' ? instanceProjection : 'standard';
57
+ Object.assign(this, {
58
+ container,
59
+ studio,
60
+ iconRegistry,
61
+ paletteRegistry,
62
+ templateRegistry,
63
+ contextMenuRegistry,
64
+ rendererOptions,
65
+ slots,
66
+ runtime,
67
+ mode,
68
+ projection: activeProjection,
69
+ _instanceProjection: instanceProjection,
70
+ responsive,
71
+ projectionOptions,
72
+ runtimeAppearance,
73
+ });
74
+ this.propertiesRegistry = propertiesRegistry || createDefaultPropertiesRegistry({ studio });
75
+ this.interactions = createInteractionController({ studio, templates: templateRegistry });
76
+ this._cleanups = [];
77
+ this._instances = [];
78
+ this.container.classList.add('nova-studio-shell');
79
+ this.themeController = new ThemeController({ root: this.container, theme, onChange: onThemeChange });
80
+ this.container.style.setProperty('--nova-left-width', `${leftWidth}px`);
81
+ this.container.style.setProperty('--nova-right-width', `${rightWidth}px`);
82
+ const context = { studio, iconRegistry, paletteRegistry, propertiesRegistry: this.propertiesRegistry, templateRegistry, contextMenuRegistry, interactions: this.interactions };
83
+ const mount = {
84
+ canvas: (host, options = {}) => this._mountCanvas(host, options),
85
+ palette: (host, options = {}) => this._mountPalette(host, options),
86
+ properties: (host, options = {}) => this._mountProperties(host, options),
87
+ };
88
+ if (typeof layout === 'function') {
89
+ const cleanup = layout({ container, ...context, mount });
90
+ if (typeof cleanup === 'function') this._cleanups.push(cleanup);
91
+ } else this._buildDefault(context, mount);
92
+ }
93
+
94
+ _buildDefault(context, mount) {
95
+ this.container.innerHTML = '';
96
+ const header = node('header', 'nova-studio-header');
97
+ const brand = node('div', 'nova-studio-brand');
98
+ const brandMark = node('span', 'nova-studio-brand-mark', 'N');
99
+ const brandCopy = node('div', 'nova-studio-brand-copy');
100
+ brandCopy.append(node('strong', '', 'BPMN Nova'), node('span', '', this.studio.model.name));
101
+ brand.append(brandMark, brandCopy);
102
+ const modeSwitch = node('nav', 'nova-studio-mode-switch');
103
+ const modeButtons = new Map();
104
+ for (const item of [
105
+ { value: 'design', label: '流程设计', iconId: 'ui.design' },
106
+ { value: 'viewer', label: '流程展示', iconId: 'ui.preview' },
107
+ { value: 'instance', label: '审批轨迹', iconId: 'ui.trace' },
108
+ ]) {
109
+ const button = node('button');
110
+ button.type = 'button';
111
+ button.setAttribute('data-mode', item.value);
112
+ button.append(iconNode(item.iconId, 'nova-icon nova-icon-xs'), node('span', '', item.label));
113
+ button.addEventListener('click', () => this._setMode(item.value));
114
+ modeButtons.set(item.value, button);
115
+ modeSwitch.appendChild(button);
116
+ }
117
+ const projectionSwitch = node('nav', 'nova-studio-projection-switch is-hidden');
118
+ projectionSwitch.setAttribute('aria-label', '审批轨迹视图');
119
+ const projectionButtons = new Map();
120
+ const projectionOptions = this.projectionOptions || [
121
+ { value: this.responsive ? 'auto' : 'approval', label: '实际路径' },
122
+ { value: 'standard', label: '完整 BPMN' },
123
+ ];
124
+ for (const item of projectionOptions) {
125
+ const button = node('button', '', item.label);
126
+ button.type = 'button';
127
+ button.dataset.projection = item.value;
128
+ button.addEventListener('click', () => this.setProjection(item.value));
129
+ projectionButtons.set(item.value, button);
130
+ projectionSwitch.appendChild(button);
131
+ }
132
+ const centerControls = node('div', 'nova-studio-center-controls');
133
+ centerControls.append(modeSwitch, projectionSwitch);
134
+ const tools = node('div', 'nova-studio-tools');
135
+ const tool = (label, title, action, className = '', iconId = null) => {
136
+ const button = node('button', `nova-studio-tool ${className}`.trim()); button.type = 'button'; button.title = title;
137
+ if (iconId) button.appendChild(iconNode(iconId));
138
+ if (label) button.appendChild(node('span', '', label));
139
+ button.addEventListener('click', action); tools.appendChild(button); return button;
140
+ };
141
+
142
+ const divider = () => tools.appendChild(node('span', 'nova-studio-tool-divider'));
143
+ const undo = tool('', '撤销 Ctrl/⌘+Z', () => this.studio.undo(), 'is-icon', 'ui.undo');
144
+ const redo = tool('', '重做 Ctrl/⌘+Y', () => this.studio.redo(), 'is-icon', 'ui.redo');
145
+ divider();
146
+ const beautifySplit = node('div', 'nova-studio-beautify-split');
147
+ const beautifyButton = node('button', 'nova-studio-tool nova-studio-beautify is-magic');
148
+ beautifyButton.type = 'button';
149
+ beautifyButton.title = '按当前设置整理节点与连线';
150
+ beautifyButton.append(iconNode('ui.magic'), node('span', '', '一键美化'));
151
+ beautifyButton.addEventListener('click', () => this._runBeautify());
152
+ const beautifyCaret = node('button', 'nova-studio-tool nova-studio-beautify-caret is-magic');
153
+ beautifyCaret.type = 'button';
154
+ beautifyCaret.title = '选择布局策略';
155
+ beautifyCaret.appendChild(iconNode('ui.chevron', 'nova-icon nova-icon-xs'));
156
+ const beautifyMenu = node('div', 'nova-studio-beautify-menu is-hidden');
157
+ beautifyMenu.append(node('div', 'nova-studio-beautify-title', '布局策略'));
158
+ const density = node('div', 'nova-studio-density');
159
+ for (const item of [
160
+ { value: 'compact', label: '紧凑' },
161
+ { value: 'balanced', label: '标准' },
162
+ { value: 'spacious', label: '舒展' },
163
+ ]) {
164
+ const button = node('button', '', item.label);
165
+ button.type = 'button';
166
+ button.setAttribute('data-density', item.value);
167
+ button.addEventListener('click', () => {
168
+ this.studio.commands.updateProcess({ settings: { layoutDensity: item.value } });
169
+ this._syncDensityButtons();
170
+ });
171
+ density.appendChild(button);
172
+ }
173
+ beautifyMenu.appendChild(density);
174
+ const actionList = node('div', 'nova-studio-beautify-actions');
175
+ for (const item of [
176
+ { value: 'horizontal', iconId: 'ui.layoutHorizontal', label: '横向智能布局', hint: '按审批流方向重新排版' },
177
+ { value: 'vertical', iconId: 'ui.layoutVertical', label: '纵向智能布局', hint: '按审批层级纵向排版' },
178
+ { value: 'routing', iconId: 'ui.routeRounded', label: '重算圆角连线', hint: '保留节点位置,仅重算线路' },
179
+ { value: 'smooth', iconId: 'ui.routeSmooth', label: '柔和曲线连线', hint: '保留节点位置,增强连线弧度' },
180
+ ]) {
181
+ const button = node('button', 'nova-studio-beautify-action');
182
+ button.type = 'button';
183
+ button.setAttribute('data-beautify', item.value);
184
+ button.append(iconNode(item.iconId, 'nova-icon nova-studio-beautify-action-icon'), node('strong', '', item.label), node('small', '', item.hint));
185
+ button.addEventListener('click', () => {
186
+ this._runBeautify(item.value);
187
+ beautifyMenu.classList.add('is-hidden');
188
+ });
189
+ actionList.appendChild(button);
190
+ }
191
+ beautifyMenu.appendChild(actionList);
192
+ beautifyCaret.addEventListener('click', (event) => {
193
+ event.stopPropagation();
194
+ beautifyMenu.classList.toggle('is-hidden');
195
+ this._syncDensityButtons();
196
+ });
197
+ beautifySplit.append(beautifyButton, beautifyCaret, beautifyMenu);
198
+ tools.appendChild(beautifySplit);
199
+ const fit = tool('最佳视图', '适应画布内容', () => this._fitActive(), '', 'ui.fitView');
200
+
201
+ const importInput = node('input', 'nova-studio-import-input');
202
+ importInput.type = 'file';
203
+ importInput.accept = '.bpmn,.xml,.bpmn20.xml,text/xml,application/xml';
204
+ importInput.hidden = true;
205
+ const validateButton = tool('校验', '校验流程结构', () => this._showValidationStatus(), 'nova-studio-validate');
206
+ const importButton = tool('导入', '导入 BPMN XML', () => importInput.click(), 'nova-studio-import');
207
+ const exportButton = tool('导出 BPMN', '导出当前流程 XML', () => this._exportBpmn(), 'nova-studio-export is-primary');
208
+ importInput.addEventListener('change', async () => {
209
+ const file = importInput.files?.[0];
210
+ if (!file) return;
211
+ try {
212
+ this.studio.importXml(await file.text(), this.studio.model.engine);
213
+ this._setStatus('已导入 BPMN 文件', 'ok');
214
+ requestAnimationFrame(() => this._fitActive());
215
+ } catch (error) {
216
+ this._setStatus(`导入失败:${error.message}`, 'error');
217
+ } finally {
218
+ importInput.value = '';
219
+ }
220
+ });
221
+ tools.appendChild(importInput);
222
+ header.append(brand, centerControls, tools);
223
+
224
+ const body = node('div', 'nova-studio-body');
225
+ const left = node('aside', 'nova-studio-left');
226
+ const canvasColumn = node('section', 'nova-studio-canvas-column');
227
+ const canvasStage = node('div', 'nova-studio-canvas-stage');
228
+ const canvasHost = node('main', 'nova-studio-canvas');
229
+ const floatingHead = node('div', 'nova-studio-canvas-floating-head');
230
+ const editingMode = node('strong', 'nova-studio-editing-mode');
231
+ editingMode.append(iconNode('ui.design', 'nova-icon nova-icon-xs'), node('span', 'nova-studio-editing-mode-label', '编辑模式'));
232
+ const scopeBack = node('button', 'nova-studio-scope-back');
233
+ scopeBack.type = 'button';
234
+ scopeBack.hidden = true;
235
+ scopeBack.append(iconNode('ui.leaveScope', 'nova-icon nova-icon-sm'), node('span', '', '返回上一级'));
236
+ scopeBack.addEventListener('click', () => this.studio.leaveScope());
237
+ const scopeNav = node('nav', 'nova-studio-scope-nav');
238
+ scopeNav.setAttribute('aria-label', '流程层级');
239
+ floatingHead.append(editingMode, scopeBack, scopeNav);
240
+ canvasStage.append(canvasHost, floatingHead);
241
+ const statusbar = node('footer', 'nova-studio-statusbar');
242
+ const statusLeft = node('div', 'nova-studio-status-left');
243
+ this._statusDot = node('i', 'is-ok');
244
+ this._statusText = node('span', '', '就绪');
245
+ statusLeft.append(this._statusDot, this._statusText);
246
+ const viewportTools = node('div', 'nova-studio-viewport-tools');
247
+ const viewportTool = (label, title, action, className = '', iconId = null) => {
248
+ const button = node('button', className); button.type = 'button'; button.title = title;
249
+ if (iconId) button.appendChild(iconNode(iconId));
250
+ if (label) button.appendChild(node('span', '', label));
251
+ button.addEventListener('click', action); viewportTools.appendChild(button); return button;
252
+ };
253
+ viewportTool('', '缩小', () => this._zoomActive(0.9), '', 'ui.zoomOut');
254
+ this._zoomText = node('span', 'nova-studio-zoom-text', '100%');
255
+ viewportTools.appendChild(this._zoomText);
256
+ viewportTool('', '放大', () => this._zoomActive(1.1), '', 'ui.zoomIn');
257
+ viewportTools.appendChild(node('span', 'nova-studio-status-divider'));
258
+ viewportTool('1:1', '100% 实际大小', () => this._actualSize(), 'is-text');
259
+ viewportTool('', '最佳视图', () => this._fitActive(), 'is-fit', 'ui.fitView');
260
+ statusbar.append(statusLeft, viewportTools);
261
+ canvasColumn.append(canvasStage, statusbar);
262
+ const right = node('aside', 'nova-studio-right');
263
+ body.append(left, canvasColumn, right);
264
+ this.container.append(header, body);
265
+ hydrateIcons(this.container, this.iconRegistry);
266
+ Object.assign(this, {
267
+ _body: body,
268
+ _left: left,
269
+ _right: right,
270
+ _canvasHost: canvasHost,
271
+ _floatingHead: floatingHead,
272
+ _scopeBack: scopeBack,
273
+ _scopeNav: scopeNav,
274
+ _modeButtons: modeButtons,
275
+ _projectionSwitch: projectionSwitch,
276
+ _projectionButtons: projectionButtons,
277
+ _viewportTools: viewportTools,
278
+ _designControls: [undo, redo, beautifySplit, validateButton, importButton, exportButton],
279
+ _defaultContext: context,
280
+ _defaultMount: mount,
281
+ _usesDefaultProperties: !this.slots.right,
282
+ });
283
+ this.canvas = mount.canvas(canvasHost, {
284
+ rendererOptions: {
285
+ ...this.rendererOptions,
286
+ onViewportChange: (viewport) => {
287
+ this._zoomText.textContent = `${Math.round(viewport.zoom * 100)}%`;
288
+ this.rendererOptions.onViewportChange?.(viewport);
289
+ },
290
+ },
291
+ });
292
+ this._renderScopePath();
293
+ if (this.slots.left) this._recordCleanup(mountSlot(this.slots.left, left, { ...context, canvas: this.canvas }));
294
+ else this.palette = mount.palette(left, { canvas: this.canvas });
295
+ if (this.slots.right) this._recordCleanup(mountSlot(this.slots.right, right, { ...context, canvas: this.canvas }));
296
+ else this.properties = mount.properties(right, { canvas: this.canvas });
297
+ if (this.slots.header) { header.innerHTML = ''; this._recordCleanup(mountSlot(this.slots.header, header, { ...context, canvas: this.canvas })); }
298
+ if (this.slots.footer) { statusbar.innerHTML = ''; this._recordCleanup(mountSlot(this.slots.footer, statusbar, { ...context, canvas: this.canvas })); }
299
+ this._offState = this.studio.subscribe((event) => {
300
+ if (event.type === 'historyChanged' || event.type === 'modelChanged') {
301
+ undo.disabled = !this.studio.history.canUndo;
302
+ redo.disabled = !this.studio.history.canRedo;
303
+ brandCopy.querySelector('span').textContent = this.studio.model.name;
304
+ if (this.viewer) this.viewer.setModel(this.studio.model);
305
+ this._syncDensityButtons();
306
+ const graph = this.studio.getActiveGraph();
307
+ this._setStatus(`${graph.nodes.length} 节点 · ${graph.edges.length} 连线`, 'ok');
308
+ }
309
+ if (event.type === 'scopeChanged' || event.type === 'modelChanged') this._renderScopePath();
310
+ if (event.type === 'scopeChanged') {
311
+ const graph = this.studio.getActiveGraph();
312
+ this._setStatus(`${graph.nodes.length} 节点 · ${graph.edges.length} 连线`, 'ok');
313
+ }
314
+ });
315
+ this._cleanups.push(this._offState);
316
+ undo.disabled = !this.studio.history.canUndo;
317
+ redo.disabled = !this.studio.history.canRedo;
318
+ const initialGraph = this.studio.getActiveGraph();
319
+ this._setStatus(`${initialGraph.nodes.length} 节点 · ${initialGraph.edges.length} 连线`, 'ok');
320
+ const closeBeautify = (event) => {
321
+ if (!beautifySplit.contains(event.target)) beautifyMenu.classList.add('is-hidden');
322
+ };
323
+ document.addEventListener('click', closeBeautify);
324
+ this._cleanups.push(() => document.removeEventListener('click', closeBeautify));
325
+ this._syncDensityButtons();
326
+ this._setMode(this.mode, { force: true });
327
+ requestAnimationFrame(() => fit.click());
328
+ }
329
+
330
+ _renderScopePath() {
331
+ if (!this._scopeNav) return;
332
+ this._scopeNav.replaceChildren();
333
+ const path = this.studio.getState().scopePath || [];
334
+ const parent = path.length > 1 ? path[path.length - 2] : null;
335
+ this._scopeBack.hidden = !parent;
336
+ if (parent) {
337
+ this._scopeBack.title = `返回上一级:${parent.name}`;
338
+ this._scopeBack.setAttribute('aria-label', `返回上一级:${parent.name}`);
339
+ } else {
340
+ this._scopeBack.removeAttribute('title');
341
+ this._scopeBack.removeAttribute('aria-label');
342
+ }
343
+ path.forEach((item, index) => {
344
+ if (index) {
345
+ const separator = iconNode('ui.chevron', 'nova-icon nova-icon-xs nova-studio-scope-separator');
346
+ separator.setAttribute('aria-hidden', 'true');
347
+ this._scopeNav.appendChild(separator);
348
+ }
349
+ if (index === path.length - 1) {
350
+ const current = node('span', 'nova-studio-scope-current', item.name);
351
+ current.title = item.name;
352
+ current.setAttribute('aria-current', 'page');
353
+ this._scopeNav.appendChild(current);
354
+ return;
355
+ }
356
+ const button = node('button', `nova-studio-scope-ancestor${index === 0 ? ' is-root' : ''}`, item.name);
357
+ button.type = 'button';
358
+ button.title = `返回 ${item.name}`;
359
+ button.addEventListener('click', () => this.studio.navigateToScope(item.id));
360
+ this._scopeNav.appendChild(button);
361
+ });
362
+ hydrateIcons(this._scopeNav, this.iconRegistry);
363
+ }
364
+
365
+ _runBeautify(action = null) {
366
+ const settings = this.studio.model.settings || {};
367
+ if (action === 'routing') this.studio.commands.rerouteEdges({ edgeStyle: 'rounded' });
368
+ else if (action === 'smooth') this.studio.commands.rerouteEdges({ edgeStyle: 'smooth' });
369
+ else this.studio.commands.beautify({
370
+ direction: action || settings.direction || 'horizontal',
371
+ density: settings.layoutDensity || 'balanced',
372
+ edgeStyle: settings.edgeStyle || 'rounded',
373
+ });
374
+ requestAnimationFrame(() => this._fitActive());
375
+ }
376
+
377
+ _syncDensityButtons() {
378
+ if (!this.container?.querySelectorAll) return;
379
+ const value = this.studio.model.settings?.layoutDensity || 'balanced';
380
+ this.container.querySelectorAll('[data-density]').forEach((button) => button.classList.toggle('is-active', button.dataset.density === value));
381
+ }
382
+
383
+ _setMode(mode, { force = false } = {}) {
384
+ if (!['design', 'viewer', 'instance'].includes(mode)) return;
385
+ if (!force && this.mode === mode) return;
386
+ this.mode = mode;
387
+ this.projection = mode === 'instance' ? this._instanceProjection : 'standard';
388
+ this._modeButtons?.forEach((button, value) => button.classList.toggle('is-active', value === mode));
389
+ const readonly = mode !== 'design';
390
+ this._body?.classList.toggle('is-readonly', readonly);
391
+ this._floatingHead?.classList.toggle('is-hidden', readonly);
392
+ this._designControls?.forEach((control) => control.classList.toggle('is-hidden-by-mode', readonly));
393
+ this._projectionSwitch?.classList.toggle('is-hidden', mode !== 'instance');
394
+ this._syncProjectionButtons();
395
+ this._body?.classList.remove('is-timeline');
396
+
397
+ if (!this._canvasHost) return;
398
+ if (readonly) {
399
+ this.canvas?.destroy?.();
400
+ this.canvas = null;
401
+ this.viewer?.destroy?.();
402
+ this._canvasHost.innerHTML = '';
403
+ if (this._usesDefaultProperties) this.properties?.destroy?.();
404
+ this.viewer = new BpmnViewer({
405
+ container: this._canvasHost,
406
+ themeController: this.themeController,
407
+ runtimeAppearance: this.runtimeAppearance,
408
+ model: this.studio.model,
409
+ iconRegistry: this.iconRegistry,
410
+ nodeRenderers: this.rendererOptions.nodeRenderers,
411
+ nodeRenderer: this.rendererOptions.nodeRenderer,
412
+ runtime: mode === 'instance' ? this.runtime : null,
413
+ projection: this.projection,
414
+ responsive: this.responsive,
415
+ runtimeTraceOptions: this.rendererOptions.runtimeTraceOptions,
416
+ timeline: this.rendererOptions.timeline,
417
+ runtimeDetails: this.rendererOptions.runtimeDetails,
418
+ runtimePresenter: this.rendererOptions.runtimePresenter,
419
+ runtimeTraceProjector: this.rendererOptions.runtimeTraceProjector,
420
+ runtimeAssetResolver: this.rendererOptions.runtimeAssetResolver,
421
+ runtimeTimelineRenderer: this.slots.runtimeTimeline
422
+ ? (timeline) => mountSlot(this.slots.runtimeTimeline, timeline.container, { ...timeline, studio: this.studio, shell: this })
423
+ : this.rendererOptions.runtimeTimelineRenderer,
424
+ onRuntimeTraceItemClick: this.rendererOptions.onRuntimeTraceItemClick,
425
+ runtimeDetailsRenderer: this.slots.runtimeDetails
426
+ ? (details) => mountSlot(this.slots.runtimeDetails, details.container, { ...details, studio: this.studio, shell: this })
427
+ : this.rendererOptions.runtimeDetailsRenderer,
428
+ onRuntimeDetailsOpen: this.rendererOptions.onRuntimeDetailsOpen,
429
+ runtimeTransitionDetailsRenderer: this.slots.runtimeTransitionDetails
430
+ ? (details) => mountSlot(this.slots.runtimeTransitionDetails, details.container, { ...details, studio: this.studio, shell: this })
431
+ : this.rendererOptions.runtimeTransitionDetailsRenderer,
432
+ onRuntimeTransitionDetailsOpen: this.rendererOptions.onRuntimeTransitionDetailsOpen,
433
+ onTraceClick: (payload) => {
434
+ const kind = payload.targetType === 'visit' ? 'node' : payload.targetType === 'transition' ? 'node' : payload.targetType;
435
+ this._renderReadonlyDetails({ kind, element: payload.element, presentation: payload.presentation, transition: payload.transition });
436
+ this.rendererOptions.onTraceClick?.(payload);
437
+ },
438
+ onProjectionChange: ({ active }) => {
439
+ const timeline = active === 'compact';
440
+ this._body?.classList.toggle('is-timeline', timeline);
441
+ this._viewportTools?.classList.toggle('is-hidden', timeline);
442
+ const status = timeline ? '审批轨迹 · 移动时间线' : active === 'standard' ? '审批轨迹 · 完整 BPMN' : '审批轨迹 · 实际路径';
443
+ this._setStatus(status, 'ok');
444
+ },
445
+ onViewportChange: (viewport) => this._handleViewport(viewport),
446
+ onElementClick: (selection) => {
447
+ if (!selection) this._renderReadonlyDetails(null);
448
+ this.rendererOptions.onElementClick?.(selection);
449
+ },
450
+ });
451
+ this._renderReadonlyDetails(null);
452
+ this._setStatus(mode === 'instance' ? '审批轨迹 · 运行实例' : '流程展示 · 只读预览', 'ok');
453
+ } else if (!this.canvas) {
454
+ this.viewer?.destroy?.();
455
+ this.viewer = null;
456
+ this._canvasHost.innerHTML = '';
457
+ this.canvas = this._defaultMount.canvas(this._canvasHost, {
458
+ rendererOptions: {
459
+ ...this.rendererOptions,
460
+ onViewportChange: (viewport) => this._handleViewport(viewport),
461
+ },
462
+ });
463
+ if (this.palette) this.palette.canvas = this.canvas;
464
+ if (this._usesDefaultProperties) this.properties = this._defaultMount.properties(this._right, { canvas: this.canvas });
465
+ this._setStatus(`${this.studio.model.nodes.length} 节点 · ${this.studio.model.edges.length} 连线`, 'ok');
466
+ }
467
+ requestAnimationFrame(() => this._fitActive());
468
+ }
469
+
470
+ _handleViewport(viewport) {
471
+ if (this._zoomText) this._zoomText.textContent = `${Math.round(viewport.zoom * 100)}%`;
472
+ this.rendererOptions.onViewportChange?.(viewport);
473
+ }
474
+
475
+ _renderReadonlyDetails(selection) {
476
+ if (!this._usesDefaultProperties || !this._right) return;
477
+ const model = this.studio.model;
478
+ const element = selection?.element;
479
+ this._right.innerHTML = '';
480
+ const panel = node('div', 'nova-studio-readonly-panel');
481
+ const header = node('header', 'nova-studio-readonly-header');
482
+ header.append(node('strong', '', element?.name || (selection?.kind === 'edge' ? '流程连线' : model.name)), node('span', '', element?.id || model.id));
483
+ const badge = node('em', '', this.mode === 'instance' ? '运行轨迹' : '只读');
484
+ header.appendChild(badge);
485
+ panel.appendChild(header);
486
+ const runtimeRows = this.mode === 'instance' && selection?.presentation
487
+ ? [
488
+ ['当前状态', selection.presentation.statusLabel],
489
+ ['处理人员', selection.presentation.fullSummary || selection.presentation.summary || '-'],
490
+ ['处理轮次', selection.presentation.round ? `第 ${selection.presentation.round} 次` : '-'],
491
+ ...(selection.presentation.actionSummary ? [['最近操作', selection.presentation.actionSummary]] : []),
492
+ ]
493
+ : [];
494
+ const rows = selection?.kind === 'node'
495
+ ? [...runtimeRows, ['元素类型', element.type], ['BPMN 类型', element.bpmnType || '-'], ['名称', element.name || '-'], ['Element ID', element.id]]
496
+ : selection?.kind === 'edge'
497
+ ? [['来源', element.source], ['目标', element.target], ['条件', element.condition || '-'], ['Element ID', element.id]]
498
+ : [['流程名称', model.name], ['流程引擎', model.engine], ['节点数量', String(model.nodes.length)], ['连线数量', String(model.edges.length)]];
499
+ const body = node('div', 'nova-studio-readonly-body');
500
+ for (const [label, value] of rows) {
501
+ const row = node('div', 'nova-studio-readonly-row');
502
+ row.append(node('span', '', label), node('strong', '', value));
503
+ body.appendChild(row);
504
+ }
505
+ panel.appendChild(body);
506
+ this._right.appendChild(panel);
507
+ }
508
+
509
+ _zoomActive(factor) { (this.canvas || this.viewer)?.zoomBy?.(factor); }
510
+ _actualSize() {
511
+ if (this.canvas) this.canvas.actualSize();
512
+ else this.viewer?.renderer?.actualSize?.();
513
+ }
514
+ _fitActive() {
515
+ if (this.canvas) this.canvas.fitView(72);
516
+ else this.viewer?.fitView?.();
517
+ }
518
+ setMode(mode) { this._setMode(mode); }
519
+ setRuntime(runtime) {
520
+ this.runtime = runtime;
521
+ if (this.mode === 'instance') this.viewer?.setRuntime(runtime);
522
+ }
523
+ setProjection(projection) {
524
+ this._instanceProjection = projection;
525
+ if (this.mode === 'instance') this.projection = projection;
526
+ this._syncProjectionButtons();
527
+ if (this.mode === 'instance') this.viewer?.setProjection?.(projection);
528
+ }
529
+ setTheme(theme) { return this.themeController.setTheme(theme); }
530
+ setThemeMode(mode) { return this.themeController.setMode(mode); }
531
+ getThemeState() { return this.themeController.getState(); }
532
+ subscribeTheme(listener) { return this.themeController.subscribe(listener); }
533
+ setRuntimeAppearance(runtimeAppearance) {
534
+ this.runtimeAppearance = runtimeAppearance || null;
535
+ this.viewer?.setRuntimeAppearance?.(this.runtimeAppearance);
536
+ }
537
+
538
+ _syncProjectionButtons() {
539
+ this._projectionButtons?.forEach((button, value) => {
540
+ const active = value === this._instanceProjection;
541
+ button.classList.toggle('is-active', active);
542
+ button.setAttribute('aria-pressed', String(active));
543
+ });
544
+ }
545
+ fitView() { this._fitActive(); }
546
+ zoomBy(factor) { this._zoomActive(factor); }
547
+
548
+ _setStatus(message, tone = 'ok') {
549
+ if (!this._statusText || !this._statusDot) return;
550
+ this._statusText.textContent = message;
551
+ this._statusDot.className = `is-${tone}`;
552
+ }
553
+
554
+ _showValidationStatus() {
555
+ const issues = this.studio.validate();
556
+ if (!issues.length) this._setStatus('校验通过:流程结构正常', 'ok');
557
+ else {
558
+ const errors = issues.filter((issue) => issue.level === 'error').length;
559
+ this._setStatus(`校验完成:${errors ? `${errors} 个错误 · ` : ''}${issues.length} 个提示`, errors ? 'error' : 'warning');
560
+ }
561
+ return issues;
562
+ }
563
+
564
+ _exportBpmn() {
565
+ const blob = new Blob([this.studio.exportXml()], { type: 'application/xml;charset=utf-8' });
566
+ const url = URL.createObjectURL(blob);
567
+ const anchor = node('a');
568
+ anchor.href = url;
569
+ anchor.download = `${this.studio.model.id || 'process'}.bpmn20.xml`;
570
+ document.body.appendChild(anchor);
571
+ anchor.click();
572
+ anchor.remove();
573
+ setTimeout(() => URL.revokeObjectURL(url), 0);
574
+ this._setStatus('BPMN 文件已导出', 'ok');
575
+ }
576
+
577
+ _recordCleanup(cleanup) { if (typeof cleanup === 'function') this._cleanups.push(cleanup); }
578
+ _mountCanvas(container, options = {}) {
579
+ const instance = new BpmnCanvas({
580
+ container,
581
+ studio: this.studio,
582
+ interactions: this.interactions,
583
+ selectionToolbar: options.selectionToolbar ?? this.slots.selectionToolbar ?? null,
584
+ contextMenu: options.contextMenu ?? this.slots.contextMenu ?? null,
585
+ contextMenuRegistry: options.contextMenuRegistry ?? this.contextMenuRegistry,
586
+ rendererOptions: { iconRegistry: this.iconRegistry, ...this.rendererOptions, ...(options.rendererOptions || {}) },
587
+ themeController: this.themeController,
588
+ });
589
+ this._instances.push(instance); return instance;
590
+ }
591
+ _mountPalette(container, options = {}) {
592
+ const instance = new PalettePanel({ container, studio: this.studio, registry: this.paletteRegistry, interactions: this.interactions, iconRegistry: this.iconRegistry, themeController: this.themeController, ...options });
593
+ this._instances.push(instance); return instance;
594
+ }
595
+ _mountProperties(container, options = {}) {
596
+ const instance = new PropertiesPanel({ container, studio: this.studio, registry: this.propertiesRegistry, iconRegistry: this.iconRegistry, themeController: this.themeController, ...options });
597
+ instance.render(); this._instances.push(instance); return instance;
598
+ }
599
+ destroy() {
600
+ this._cleanups.splice(0).reverse().forEach((cleanup) => cleanup?.());
601
+ this._instances.splice(0).reverse().forEach((instance) => instance.destroy?.());
602
+ this.container.innerHTML = '';
603
+ this.container.classList.remove('nova-studio-shell');
604
+ this.themeController.destroy();
605
+ }
606
+ }
607
+
608
+ export function createStudioShell(options) { return new BpmnStudioShell(options); }