@bpmn-nova/studio 0.3.2-preview → 0.3.4-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 CHANGED
@@ -29,6 +29,11 @@ function mountSlot(slot, container, context) {
29
29
  }
30
30
 
31
31
  const STUDIO_MODES = ['design', 'viewer', 'instance'];
32
+ const STUDIO_MODE_OPTIONS = Object.freeze([
33
+ Object.freeze({ value: 'design', label: '流程设计', iconId: 'ui.design' }),
34
+ Object.freeze({ value: 'viewer', label: '流程展示', iconId: 'ui.preview' }),
35
+ Object.freeze({ value: 'instance', label: '审批轨迹', iconId: 'ui.trace' }),
36
+ ]);
32
37
  const STUDIO_SHELL_REGIONS = ['header', 'left', 'right', 'footer'];
33
38
  const DEFAULT_STUDIO_SHELL_REGIONS = Object.freeze({
34
39
  header: 'default',
@@ -37,14 +42,13 @@ const DEFAULT_STUDIO_SHELL_REGIONS = Object.freeze({
37
42
  footer: 'default',
38
43
  });
39
44
 
40
- function normalizeAllowedModes(allowedModes) {
41
- if (allowedModes === undefined || allowedModes === null) return [...STUDIO_MODES];
45
+ function normalizeAllowedModes(allowedModes, { allowDefault = true } = {}) {
46
+ if (allowDefault && (allowedModes === undefined || allowedModes === null)) return [...STUDIO_MODES];
42
47
  if (!Array.isArray(allowedModes)) throw new Error('allowedModes must be an array of Studio modes.');
43
- const normalized = [...new Set(allowedModes.filter((mode) => STUDIO_MODES.includes(mode)))];
44
- if (!normalized.length || normalized.length !== allowedModes.length) {
48
+ if (!allowedModes.length || allowedModes.some((mode) => !STUDIO_MODES.includes(mode)) || new Set(allowedModes).size !== allowedModes.length) {
45
49
  throw new Error('allowedModes must contain one or more unique design, viewer, or instance modes.');
46
50
  }
47
- return normalized;
51
+ return [...allowedModes];
48
52
  }
49
53
 
50
54
  function normalizeRegions(regions, current = DEFAULT_STUDIO_SHELL_REGIONS) {
@@ -71,6 +75,7 @@ export class BpmnStudioShell {
71
75
  templateRegistry = createTemplateRegistry(),
72
76
  contextMenuRegistry = createDefaultContextMenuRegistry(),
73
77
  rendererOptions = {},
78
+ nodeSubtitleResolver = rendererOptions.nodeSubtitleResolver,
74
79
  slots = {},
75
80
  layout = null,
76
81
  regions = null,
@@ -102,7 +107,7 @@ export class BpmnStudioShell {
102
107
  paletteRegistry,
103
108
  templateRegistry,
104
109
  contextMenuRegistry,
105
- rendererOptions,
110
+ rendererOptions: { ...rendererOptions, nodeSubtitleResolver },
106
111
  slots,
107
112
  runtime,
108
113
  mode: resolvedMode,
@@ -120,6 +125,10 @@ export class BpmnStudioShell {
120
125
  this.interactions = createInteractionController({ studio, templates: templateRegistry });
121
126
  this._cleanups = [];
122
127
  this._instances = [];
128
+ this._modeListeners = new Set();
129
+ this._validationListeners = new Set();
130
+ this._modeViewports = new Map();
131
+ this._destroyed = false;
123
132
  this._svgExportPreview = null;
124
133
  this.actions = Object.freeze({
125
134
  undo: () => this.studio.undo(),
@@ -133,7 +142,7 @@ export class BpmnStudioShell {
133
142
  requestAnimationFrame(() => this._fitActive());
134
143
  },
135
144
  fitView: () => this.fitView(),
136
- validate: () => this.studio.validate(),
145
+ validate: () => this._runValidation('toolbar'),
137
146
  importXml: (xml, engine) => this.studio.importXml(xml, engine),
138
147
  exportXml: (engine) => this.studio.exportXml(engine),
139
148
  exportSvg: (options) => this.exportSvg(options),
@@ -149,6 +158,10 @@ export class BpmnStudioShell {
149
158
  actions: this.actions,
150
159
  getState: () => this.studio.getState(),
151
160
  subscribe: (listener) => this.studio.subscribe(listener),
161
+ getMode: () => this.getMode(),
162
+ getAllowedModes: () => this.getAllowedModes(),
163
+ subscribeMode: (listener) => this.subscribeMode(listener),
164
+ subscribeValidation: (listener) => this.subscribeValidation(listener),
152
165
  iconRegistry,
153
166
  paletteRegistry,
154
167
  propertiesRegistry: this.propertiesRegistry,
@@ -176,20 +189,8 @@ export class BpmnStudioShell {
176
189
  brandCopy.append(node('strong', '', 'BPMN Nova'), node('span', '', this.studio.model.name));
177
190
  brand.append(brandMark, brandCopy);
178
191
  const modeSwitch = node('nav', 'nova-studio-mode-switch');
192
+ modeSwitch.setAttribute('aria-label', 'Studio 模式');
179
193
  const modeButtons = new Map();
180
- for (const item of [
181
- { value: 'design', label: '流程设计', iconId: 'ui.design' },
182
- { value: 'viewer', label: '流程展示', iconId: 'ui.preview' },
183
- { value: 'instance', label: '审批轨迹', iconId: 'ui.trace' },
184
- ].filter((item) => this.allowedModes.includes(item.value))) {
185
- const button = node('button');
186
- button.type = 'button';
187
- button.setAttribute('data-mode', item.value);
188
- button.append(iconNode(item.iconId, 'nova-icon nova-icon-xs'), node('span', '', item.label));
189
- button.addEventListener('click', () => this._setMode(item.value));
190
- modeButtons.set(item.value, button);
191
- modeSwitch.appendChild(button);
192
- }
193
194
  const projectionSwitch = node('nav', 'nova-studio-projection-switch is-hidden');
194
195
  projectionSwitch.setAttribute('aria-label', '审批轨迹视图');
195
196
  const projectionButtons = new Map();
@@ -208,11 +209,11 @@ export class BpmnStudioShell {
208
209
  const centerControls = node('div', 'nova-studio-center-controls');
209
210
  centerControls.append(modeSwitch, projectionSwitch);
210
211
  const tools = node('div', 'nova-studio-tools');
211
- const tool = (label, title, action, className = '', iconId = null) => {
212
+ const tool = (label, title, action, className = '', iconId = null, target = tools) => {
212
213
  const button = node('button', `nova-studio-tool ${className}`.trim()); button.type = 'button'; button.title = title;
213
214
  if (iconId) button.appendChild(iconNode(iconId));
214
215
  if (label) button.appendChild(node('span', '', label));
215
- button.addEventListener('click', action); tools.appendChild(button); return button;
216
+ button.addEventListener('click', action); target.appendChild(button); return button;
216
217
  };
217
218
 
218
219
  const divider = () => tools.appendChild(node('span', 'nova-studio-tool-divider'));
@@ -272,56 +273,62 @@ export class BpmnStudioShell {
272
273
  });
273
274
  beautifySplit.append(beautifyButton, beautifyCaret, beautifyMenu);
274
275
  tools.appendChild(beautifySplit);
275
- const fit = tool('最佳视图', '适应画布内容', () => this.actions.fitView(), '', 'ui.fitView');
276
-
277
- const importInput = node('input', 'nova-studio-import-input');
278
- importInput.type = 'file';
279
- importInput.accept = '.bpmn,.xml,.bpmn20.xml,text/xml,application/xml';
280
- importInput.hidden = true;
281
- const validateButton = tool('校验', '校验流程结构', () => this._showValidationStatus(), 'nova-studio-validate');
282
- const importButton = tool('导入', '导入 BPMN XML', () => importInput.click(), 'nova-studio-import');
283
- const exportMenu = node('div', 'nova-studio-export-menu');
284
- const exportButton = node('button', 'nova-studio-tool nova-studio-export is-primary');
285
- exportButton.type = 'button';
286
- exportButton.title = '导出当前内容';
287
- exportButton.append(node('span', '', '导出'), iconNode('ui.chevron', 'nova-icon nova-icon-xs'));
288
- const exportPopover = node('div', 'nova-studio-export-popover is-hidden');
289
- const exportSvgButton = node('button', 'nova-studio-export-option');
290
- exportSvgButton.type = 'button';
291
- exportSvgButton.append(iconNode('ui.preview'), node('strong', '', '导出 SVG'), node('small', '', '预览确认后下载'));
292
- exportSvgButton.addEventListener('click', () => {
293
- exportPopover.classList.add('is-hidden');
294
- exportButton.focus({ preventScroll: true });
295
- this.actions.openSvgExportPreview();
296
- });
297
- const exportBpmnButton = node('button', 'nova-studio-export-option');
298
- exportBpmnButton.type = 'button';
299
- exportBpmnButton.append(iconNode('ui.design'), node('strong', '', '导出 BPMN'), node('small', '', '下载流程 XML'));
300
- exportBpmnButton.addEventListener('click', () => {
301
- exportPopover.classList.add('is-hidden');
302
- this._exportBpmn();
303
- });
304
- exportPopover.append(exportSvgButton, exportBpmnButton);
305
- exportButton.addEventListener('click', (event) => {
306
- event.stopPropagation();
307
- exportPopover.classList.toggle('is-hidden');
308
- });
309
- exportMenu.append(exportButton, exportPopover);
310
- tools.appendChild(exportMenu);
311
- importInput.addEventListener('change', async () => {
312
- const file = importInput.files?.[0];
313
- if (!file) return;
314
- try {
315
- this.actions.importXml(await file.text(), this.studio.model.engine);
316
- this._setStatus('已导入 BPMN 文件', 'ok');
317
- requestAnimationFrame(() => this._fitActive());
318
- } catch (error) {
319
- this._setStatus(`导入失败:${error.message}`, 'error');
320
- } finally {
321
- importInput.value = '';
322
- }
323
- });
324
- tools.appendChild(importInput);
276
+ const headerActions = node('div', 'nova-studio-header-actions');
277
+ let validateButton = null;
278
+ let importButton = null;
279
+ let exportMenu = null;
280
+ let exportPopover = null;
281
+ let exportBpmnButton = null;
282
+ if (!this.slots.headerActions) {
283
+ const importInput = node('input', 'nova-studio-import-input');
284
+ importInput.type = 'file';
285
+ importInput.accept = '.bpmn,.xml,.bpmn20.xml,text/xml,application/xml';
286
+ importInput.hidden = true;
287
+ validateButton = tool('校验', '校验流程结构', () => this.actions.validate(), 'nova-studio-validate', null, headerActions);
288
+ importButton = tool('导入', '导入 BPMN XML', () => importInput.click(), 'nova-studio-import', null, headerActions);
289
+ exportMenu = node('div', 'nova-studio-export-menu');
290
+ const exportButton = node('button', 'nova-studio-tool nova-studio-export is-primary');
291
+ exportButton.type = 'button';
292
+ exportButton.title = '导出当前内容';
293
+ exportButton.append(node('span', '', '导出'), iconNode('ui.chevron', 'nova-icon nova-icon-xs'));
294
+ exportPopover = node('div', 'nova-studio-export-popover is-hidden');
295
+ const exportSvgButton = node('button', 'nova-studio-export-option');
296
+ exportSvgButton.type = 'button';
297
+ exportSvgButton.append(iconNode('ui.preview'), node('strong', '', '导出 SVG'), node('small', '', '预览确认后下载'));
298
+ exportSvgButton.addEventListener('click', () => {
299
+ exportPopover.classList.add('is-hidden');
300
+ exportButton.focus({ preventScroll: true });
301
+ this.actions.openSvgExportPreview();
302
+ });
303
+ exportBpmnButton = node('button', 'nova-studio-export-option');
304
+ exportBpmnButton.type = 'button';
305
+ exportBpmnButton.append(iconNode('ui.design'), node('strong', '', '导出 BPMN'), node('small', '', '下载流程 XML'));
306
+ exportBpmnButton.addEventListener('click', () => {
307
+ exportPopover.classList.add('is-hidden');
308
+ this._exportBpmn();
309
+ });
310
+ exportPopover.append(exportSvgButton, exportBpmnButton);
311
+ exportButton.addEventListener('click', (event) => {
312
+ event.stopPropagation();
313
+ exportPopover.classList.toggle('is-hidden');
314
+ });
315
+ exportMenu.append(exportButton, exportPopover);
316
+ headerActions.append(exportMenu, importInput);
317
+ importInput.addEventListener('change', async () => {
318
+ const file = importInput.files?.[0];
319
+ if (!file) return;
320
+ try {
321
+ this.actions.importXml(await file.text(), this.studio.model.engine);
322
+ this._setStatus('已导入 BPMN 文件', 'ok');
323
+ requestAnimationFrame(() => this._fitActive());
324
+ } catch (error) {
325
+ this._setStatus(`导入失败:${error.message}`, 'error');
326
+ } finally {
327
+ importInput.value = '';
328
+ }
329
+ });
330
+ }
331
+ tools.appendChild(headerActions);
325
332
  header.append(brand, centerControls, tools);
326
333
 
327
334
  const body = node('div', 'nova-studio-body');
@@ -369,6 +376,7 @@ export class BpmnStudioShell {
369
376
  Object.assign(this, {
370
377
  _header: header,
371
378
  _headerStart: brand,
379
+ _headerActions: headerActions,
372
380
  _body: body,
373
381
  _left: left,
374
382
  _right: right,
@@ -379,10 +387,11 @@ export class BpmnStudioShell {
379
387
  _scopeBack: scopeBack,
380
388
  _scopeNav: scopeNav,
381
389
  _modeButtons: modeButtons,
390
+ _modeSwitch: modeSwitch,
382
391
  _projectionSwitch: projectionSwitch,
383
392
  _projectionButtons: projectionButtons,
384
393
  _viewportTools: viewportTools,
385
- _designControls: [undo, redo, beautifySplit, validateButton, importButton],
394
+ _designControls: [undo, redo, beautifySplit, validateButton, importButton].filter(Boolean),
386
395
  _exportMenu: exportMenu,
387
396
  _exportPopover: exportPopover,
388
397
  _exportBpmnButton: exportBpmnButton,
@@ -390,6 +399,7 @@ export class BpmnStudioShell {
390
399
  _defaultMount: mount,
391
400
  _usesDefaultProperties: !this.slots.right,
392
401
  });
402
+ this._rebuildModeButtons();
393
403
  this.canvas = mount.canvas(canvasHost, {
394
404
  rendererOptions: {
395
405
  ...this.rendererOptions,
@@ -404,13 +414,19 @@ export class BpmnStudioShell {
404
414
  else this.palette = mount.palette(left, { canvas: this.canvas });
405
415
  if (this.slots.right) this._recordCleanup(mountSlot(this.slots.right, right, { ...context, canvas: this.canvas }));
406
416
  else this.properties = mount.properties(right, { canvas: this.canvas });
417
+ const mountHeaderActions = () => {
418
+ if (this.slots.headerActions) {
419
+ this._recordCleanup(mountSlot(this.slots.headerActions, headerActions, { ...context, canvas: this.canvas }));
420
+ }
421
+ };
407
422
  if (this.slots.header) {
408
423
  header.innerHTML = '';
409
424
  this._recordCleanup(mountSlot(this.slots.header, header, { ...context, canvas: this.canvas }));
410
425
  } else if (this.slots.headerStart) {
411
426
  brand.innerHTML = '';
412
427
  this._recordCleanup(mountSlot(this.slots.headerStart, brand, { ...context, canvas: this.canvas }));
413
- }
428
+ mountHeaderActions();
429
+ } else mountHeaderActions();
414
430
  if (this.slots.footer) { statusbar.innerHTML = ''; this._recordCleanup(mountSlot(this.slots.footer, statusbar, { ...context, canvas: this.canvas })); }
415
431
  hydrateIcons(this.container, this.iconRegistry);
416
432
  this._offState = this.studio.subscribe((event) => {
@@ -436,13 +452,12 @@ export class BpmnStudioShell {
436
452
  this._setStatus(`${initialGraph.nodes.length} 节点 · ${initialGraph.edges.length} 连线`, 'ok');
437
453
  const closeBeautify = (event) => {
438
454
  if (!beautifySplit.contains(event.target)) beautifyMenu.classList.add('is-hidden');
439
- if (!exportMenu.contains(event.target)) exportPopover.classList.add('is-hidden');
455
+ if (exportMenu && !exportMenu.contains(event.target)) exportPopover.classList.add('is-hidden');
440
456
  };
441
457
  document.addEventListener('click', closeBeautify);
442
458
  this._cleanups.push(() => document.removeEventListener('click', closeBeautify));
443
459
  this._syncDensityButtons();
444
- this._setMode(this.mode, { force: true });
445
- requestAnimationFrame(() => fit.click());
460
+ this._setMode(this.mode, { force: true, emit: false });
446
461
  }
447
462
 
448
463
  _renderScopePath() {
@@ -497,94 +512,225 @@ export class BpmnStudioShell {
497
512
  this.container.querySelectorAll('[data-density]').forEach((button) => button.classList.toggle('is-active', button.dataset.density === value));
498
513
  }
499
514
 
500
- _setMode(mode, { force = false } = {}) {
501
- if (!this.allowedModes.includes(mode)) return;
502
- if (!force && this.mode === mode) return;
503
- this.mode = mode;
504
- this.projection = mode === 'instance' ? this._instanceProjection : 'standard';
505
- this._modeButtons?.forEach((button, value) => button.classList.toggle('is-active', value === mode));
506
- const readonly = mode !== 'design';
515
+ _rebuildModeButtons() {
516
+ if (!this._modeSwitch) return;
517
+ this._modeSwitch.replaceChildren();
518
+ this._modeButtons = new Map();
519
+ for (const item of STUDIO_MODE_OPTIONS.filter(({ value }) => this.allowedModes.includes(value))) {
520
+ const button = node('button');
521
+ const active = item.value === this.mode;
522
+ button.type = 'button';
523
+ button.setAttribute('data-mode', item.value);
524
+ button.setAttribute('aria-label', `切换到${item.label}`);
525
+ button.setAttribute('aria-pressed', String(active));
526
+ button.classList.toggle('is-active', active);
527
+ button.append(iconNode(item.iconId, 'nova-icon nova-icon-xs'), node('span', '', item.label));
528
+ button.addEventListener('click', () => this.setMode(item.value, 'toolbar'));
529
+ this._modeButtons.set(item.value, button);
530
+ this._modeSwitch.appendChild(button);
531
+ }
532
+ hydrateIcons(this._modeSwitch, this.iconRegistry);
533
+ }
534
+
535
+ _syncModeChrome() {
536
+ this._modeButtons?.forEach((button, value) => {
537
+ const active = value === this.mode;
538
+ button.classList.toggle('is-active', active);
539
+ button.setAttribute('aria-pressed', String(active));
540
+ });
541
+ const readonly = this.mode !== 'design';
507
542
  this._body?.classList.toggle('is-readonly', readonly);
508
543
  this._applyRegions({ fit: false });
509
544
  this._floatingHead?.classList.toggle('is-hidden', readonly);
510
545
  this._designControls?.forEach((control) => control.classList.toggle('is-hidden-by-mode', readonly));
511
- this._projectionSwitch?.classList.toggle('is-hidden', mode !== 'instance');
512
- this._exportBpmnButton?.classList.toggle('is-hidden', mode !== 'design');
546
+ this._projectionSwitch?.classList.toggle('is-hidden', this.mode !== 'instance');
547
+ this._exportBpmnButton?.classList.toggle('is-hidden', this.mode !== 'design');
513
548
  this._syncProjectionButtons();
514
- this._body?.classList.remove('is-timeline');
515
-
516
- if (!this._canvasHost) return;
517
- if (readonly) {
518
- this.canvas?.destroy?.();
519
- this.canvas = null;
520
- this.viewer?.destroy?.();
521
- this._canvasHost.innerHTML = '';
522
- if (this._usesDefaultProperties) this.properties?.destroy?.();
523
- this.viewer = new BpmnViewer({
524
- container: this._canvasHost,
525
- themeController: this.themeController,
526
- runtimeAppearance: this.runtimeAppearance,
527
- model: this.studio.model,
528
- iconRegistry: this.iconRegistry,
529
- nodeRenderers: this.rendererOptions.nodeRenderers,
530
- nodeRenderer: this.rendererOptions.nodeRenderer,
531
- runtime: mode === 'instance' ? this.runtime : null,
532
- projection: this.projection,
533
- responsive: this.responsive,
534
- runtimeTraceOptions: this.rendererOptions.runtimeTraceOptions,
535
- timeline: this.rendererOptions.timeline,
536
- runtimeDetails: this.rendererOptions.runtimeDetails,
537
- runtimePresenter: this.rendererOptions.runtimePresenter,
538
- runtimeTraceProjector: this.rendererOptions.runtimeTraceProjector,
539
- runtimeAssetResolver: this.rendererOptions.runtimeAssetResolver,
540
- svgExport: this.svgExport,
541
- runtimeTimelineRenderer: this.slots.runtimeTimeline
542
- ? (timeline) => mountSlot(this.slots.runtimeTimeline, timeline.container, { ...timeline, studio: this.studio, shell: this })
543
- : this.rendererOptions.runtimeTimelineRenderer,
544
- onRuntimeTraceItemClick: this.rendererOptions.onRuntimeTraceItemClick,
545
- runtimeDetailsRenderer: this.slots.runtimeDetails
546
- ? (details) => mountSlot(this.slots.runtimeDetails, details.container, { ...details, studio: this.studio, shell: this })
547
- : this.rendererOptions.runtimeDetailsRenderer,
548
- onRuntimeDetailsOpen: this.rendererOptions.onRuntimeDetailsOpen,
549
- runtimeTransitionDetailsRenderer: this.slots.runtimeTransitionDetails
550
- ? (details) => mountSlot(this.slots.runtimeTransitionDetails, details.container, { ...details, studio: this.studio, shell: this })
551
- : this.rendererOptions.runtimeTransitionDetailsRenderer,
552
- onRuntimeTransitionDetailsOpen: this.rendererOptions.onRuntimeTransitionDetailsOpen,
553
- onTraceClick: (payload) => {
554
- const kind = payload.targetType === 'visit' ? 'node' : payload.targetType === 'transition' ? 'node' : payload.targetType;
555
- this._renderReadonlyDetails({ kind, element: payload.element, presentation: payload.presentation, transition: payload.transition });
556
- this.rendererOptions.onTraceClick?.(payload);
557
- },
558
- onProjectionChange: ({ active }) => {
559
- const timeline = active === 'compact';
560
- this._body?.classList.toggle('is-timeline', timeline);
561
- this._viewportTools?.classList.toggle('is-hidden', timeline);
562
- const status = timeline ? '审批轨迹 · 移动时间线' : active === 'standard' ? '审批轨迹 · 完整 BPMN' : '审批轨迹 · 实际路径';
563
- this._setStatus(status, 'ok');
564
- },
565
- onViewportChange: (viewport) => this._handleViewport(viewport),
566
- onElementClick: (selection) => {
567
- if (!selection) this._renderReadonlyDetails(null);
568
- this.rendererOptions.onElementClick?.(selection);
569
- },
570
- });
571
- this._renderReadonlyDetails(null);
572
- this._setStatus(mode === 'instance' ? '审批轨迹 · 运行实例' : '流程展示 · 只读预览', 'ok');
573
- } else if (!this.canvas) {
574
- this.viewer?.destroy?.();
575
- this.viewer = null;
576
- this._canvasHost.innerHTML = '';
577
- this.canvas = this._defaultMount.canvas(this._canvasHost, {
578
- rendererOptions: {
579
- ...this.rendererOptions,
580
- onViewportChange: (viewport) => this._handleViewport(viewport),
581
- },
582
- });
583
- if (this.palette) this.palette.canvas = this.canvas;
584
- if (this._usesDefaultProperties) this.properties = this._defaultMount.properties(this._right, { canvas: this.canvas });
585
- this._setStatus(`${this.studio.model.nodes.length} 节点 · ${this.studio.model.edges.length} 连线`, 'ok');
549
+ this._syncProjectionChrome();
550
+ }
551
+
552
+ _syncProjectionChrome() {
553
+ const timeline = this.mode === 'instance' && this.viewer?.activeProjection === 'compact';
554
+ this._body?.classList.toggle('is-timeline', timeline);
555
+ this._viewportTools?.classList.toggle('is-hidden', timeline);
556
+ if (this.mode === 'design') {
557
+ const graph = this.studio.getActiveGraph();
558
+ this._setStatus(`${graph.nodes.length} 节点 · ${graph.edges.length} 连线`, 'ok');
559
+ } else if (this.mode === 'viewer') this._setStatus('流程展示 · 只读预览', 'ok');
560
+ else {
561
+ const active = this.viewer?.activeProjection || this.projection;
562
+ const status = active === 'compact' ? '审批轨迹 · 移动时间线' : active === 'standard' ? '审批轨迹 · 完整 BPMN' : '审批轨迹 · 实际路径';
563
+ this._setStatus(status, 'ok');
586
564
  }
587
- requestAnimationFrame(() => this._fitActive());
565
+ }
566
+
567
+ _captureModeViewport(mode = this.mode) {
568
+ if (this.viewer?.activeProjection === 'compact') return;
569
+ const renderer = this.canvas?.renderer || this.viewer?.renderer;
570
+ const viewport = renderer?.getViewportState?.();
571
+ if (viewport) this._modeViewports.set(mode, viewport);
572
+ }
573
+
574
+ _restoreModeViewport(mode) {
575
+ const viewport = this._modeViewports.get(mode);
576
+ const renderer = this.canvas?.renderer || this.viewer?.renderer;
577
+ if (viewport && renderer?.setViewportState) {
578
+ renderer.setViewportState(viewport);
579
+ return;
580
+ }
581
+ requestAnimationFrame(() => {
582
+ if (this._destroyed || this.mode !== mode) return;
583
+ this._fitActive();
584
+ });
585
+ }
586
+
587
+ _createViewer(container, mode) {
588
+ let viewer = null;
589
+ viewer = new BpmnViewer({
590
+ container,
591
+ themeController: this.themeController,
592
+ runtimeAppearance: this.runtimeAppearance,
593
+ model: this.studio.model,
594
+ mode,
595
+ iconRegistry: this.iconRegistry,
596
+ nodeRenderers: this.rendererOptions.nodeRenderers,
597
+ nodeRenderer: this.rendererOptions.nodeRenderer,
598
+ nodeSubtitleResolver: this.rendererOptions.nodeSubtitleResolver,
599
+ runtime: mode === 'instance' ? this.runtime : null,
600
+ projection: mode === 'instance' ? this._instanceProjection : 'standard',
601
+ responsive: this.responsive,
602
+ runtimeTraceOptions: this.rendererOptions.runtimeTraceOptions,
603
+ timeline: this.rendererOptions.timeline,
604
+ runtimeDetails: this.rendererOptions.runtimeDetails,
605
+ runtimePresenter: this.rendererOptions.runtimePresenter,
606
+ runtimeTraceProjector: this.rendererOptions.runtimeTraceProjector,
607
+ runtimeAssetResolver: this.rendererOptions.runtimeAssetResolver,
608
+ svgExport: this.svgExport,
609
+ runtimeTimelineRenderer: this.slots.runtimeTimeline
610
+ ? (timeline) => mountSlot(this.slots.runtimeTimeline, timeline.container, { ...timeline, studio: this.studio, shell: this })
611
+ : this.rendererOptions.runtimeTimelineRenderer,
612
+ onRuntimeTraceItemClick: this.rendererOptions.onRuntimeTraceItemClick,
613
+ runtimeDetailsRenderer: this.slots.runtimeDetails
614
+ ? (details) => mountSlot(this.slots.runtimeDetails, details.container, { ...details, studio: this.studio, shell: this })
615
+ : this.rendererOptions.runtimeDetailsRenderer,
616
+ onRuntimeDetailsOpen: this.rendererOptions.onRuntimeDetailsOpen,
617
+ runtimeTransitionDetailsRenderer: this.slots.runtimeTransitionDetails
618
+ ? (details) => mountSlot(this.slots.runtimeTransitionDetails, details.container, { ...details, studio: this.studio, shell: this })
619
+ : this.rendererOptions.runtimeTransitionDetailsRenderer,
620
+ onRuntimeTransitionDetailsOpen: this.rendererOptions.onRuntimeTransitionDetailsOpen,
621
+ onTraceClick: (payload) => {
622
+ const kind = payload.targetType === 'visit' ? 'node' : payload.targetType === 'transition' ? 'node' : payload.targetType;
623
+ this._renderReadonlyDetails({ kind, element: payload.element, presentation: payload.presentation, transition: payload.transition });
624
+ this.rendererOptions.onTraceClick?.(payload);
625
+ },
626
+ onProjectionChange: () => {
627
+ if (this.viewer === viewer) this._syncProjectionChrome();
628
+ },
629
+ onViewportChange: (viewport) => {
630
+ if (this.viewer === viewer) this._handleViewport(viewport);
631
+ },
632
+ onElementClick: (selection) => {
633
+ if (!selection) this._renderReadonlyDetails(null);
634
+ this.rendererOptions.onElementClick?.(selection);
635
+ },
636
+ });
637
+ return viewer;
638
+ }
639
+
640
+ _emitModeChange(previousMode, source) {
641
+ const event = Object.freeze({
642
+ mode: this.mode,
643
+ previousMode,
644
+ source,
645
+ allowedModes: this.getAllowedModes(),
646
+ });
647
+ for (const listener of [...this._modeListeners]) {
648
+ try {
649
+ listener(event);
650
+ } catch (error) {
651
+ console.error('BpmnStudioShell mode listener failed.', error);
652
+ }
653
+ }
654
+ }
655
+
656
+ _setMode(mode, { force = false, emit = true, source = 'api', allowedModes = this.allowedModes } = {}) {
657
+ if (this._destroyed || !allowedModes.includes(mode)) return false;
658
+ const previousMode = this.mode;
659
+ if (!force && previousMode === mode) return false;
660
+
661
+ const reusable = (mode === 'design' && this.canvas) || (mode !== 'design' && this.viewer);
662
+ if (force && previousMode === mode && reusable) {
663
+ this.allowedModes = allowedModes;
664
+ this.projection = mode === 'instance' ? this._instanceProjection : 'standard';
665
+ this._rebuildModeButtons();
666
+ this._syncModeChrome();
667
+ this._restoreModeViewport(mode);
668
+ return true;
669
+ }
670
+
671
+ if (!this._canvasHost) {
672
+ this.allowedModes = allowedModes;
673
+ this.mode = mode;
674
+ this.projection = mode === 'instance' ? this._instanceProjection : 'standard';
675
+ this._rebuildModeButtons();
676
+ this._syncModeChrome();
677
+ if (emit && previousMode !== mode) this._emitModeChange(previousMode, source);
678
+ return true;
679
+ }
680
+
681
+ if (!force || previousMode !== mode) this._captureModeViewport(previousMode);
682
+ const previousHost = this._canvasHost;
683
+ const nextHost = node('main', 'nova-studio-canvas');
684
+ nextHost.hidden = true;
685
+ previousHost.parentNode?.insertBefore(nextHost, previousHost.nextSibling);
686
+ let nextCanvas = null;
687
+ let nextViewer = null;
688
+ try {
689
+ if (mode === 'design') {
690
+ nextCanvas = this._createCanvas(nextHost, {
691
+ rendererOptions: {
692
+ ...this.rendererOptions,
693
+ onViewportChange: (viewport) => {
694
+ if (this.canvas === nextCanvas) this._handleViewport(viewport);
695
+ },
696
+ },
697
+ });
698
+ } else nextViewer = this._createViewer(nextHost, mode);
699
+ } catch (error) {
700
+ nextCanvas?.destroy?.();
701
+ nextViewer?.destroy?.();
702
+ nextHost.remove();
703
+ this._syncModeChrome();
704
+ console.error(`BpmnStudioShell failed to mount mode "${mode}".`, error);
705
+ return false;
706
+ }
707
+
708
+ const previousCanvas = this.canvas;
709
+ const previousViewer = this.viewer;
710
+ nextHost.hidden = false;
711
+ previousHost.replaceWith(nextHost);
712
+ this._canvasHost = nextHost;
713
+ this.canvas = nextCanvas;
714
+ this.viewer = nextViewer;
715
+ this.allowedModes = allowedModes;
716
+ this.mode = mode;
717
+ this.projection = mode === 'instance' ? this._instanceProjection : 'standard';
718
+ if (nextCanvas) this._instances.push(nextCanvas);
719
+ this._releaseInstance(previousCanvas);
720
+ previousViewer?.destroy?.();
721
+
722
+ if (this.palette) this.palette.canvas = this.canvas;
723
+ if (this._usesDefaultProperties) {
724
+ this._releaseInstance(this.properties);
725
+ this.properties = null;
726
+ if (this.canvas) this.properties = this._mountProperties(this._right, { canvas: this.canvas });
727
+ else this._renderReadonlyDetails(null);
728
+ }
729
+ this._rebuildModeButtons();
730
+ this._syncModeChrome();
731
+ this._restoreModeViewport(mode);
732
+ if (emit && previousMode !== mode) this._emitModeChange(previousMode, source);
733
+ return true;
588
734
  }
589
735
 
590
736
  _handleViewport(viewport) {
@@ -635,7 +781,34 @@ export class BpmnStudioShell {
635
781
  if (this.canvas) this.canvas.fitView(72);
636
782
  else this.viewer?.fitView?.();
637
783
  }
638
- setMode(mode) { this._setMode(mode); }
784
+ getMode() { return this.mode; }
785
+ getAllowedModes() { return Object.freeze([...this.allowedModes]); }
786
+ subscribeMode(listener) {
787
+ if (typeof listener !== 'function') throw new TypeError('subscribeMode() requires a listener function.');
788
+ if (this._destroyed) return () => {};
789
+ this._modeListeners.add(listener);
790
+ return () => this._modeListeners.delete(listener);
791
+ }
792
+ subscribeValidation(listener) {
793
+ if (typeof listener !== 'function') throw new TypeError('subscribeValidation() requires a listener function.');
794
+ if (this._destroyed) return () => {};
795
+ this._validationListeners.add(listener);
796
+ return () => this._validationListeners.delete(listener);
797
+ }
798
+ setMode(mode, source = 'api') { return this._setMode(mode, { source }); }
799
+ setAllowedModes(modes) {
800
+ const next = normalizeAllowedModes(modes, { allowDefault: false });
801
+ if (this._destroyed) return this.getAllowedModes();
802
+ if (next.length === this.allowedModes.length && next.every((mode, index) => mode === this.allowedModes[index])) {
803
+ return this.getAllowedModes();
804
+ }
805
+ if (next.includes(this.mode)) {
806
+ this.allowedModes = next;
807
+ this._rebuildModeButtons();
808
+ this._syncModeChrome();
809
+ } else this._setMode(next[0], { source: 'allowed-modes', allowedModes: next });
810
+ return this.getAllowedModes();
811
+ }
639
812
  setRuntime(runtime) {
640
813
  this.runtime = runtime;
641
814
  if (this.mode === 'instance') this.viewer?.setRuntime(runtime);
@@ -682,6 +855,8 @@ export class BpmnStudioShell {
682
855
  this.runtimeAppearance = runtimeAppearance || null;
683
856
  this.viewer?.setRuntimeAppearance?.(this.runtimeAppearance);
684
857
  }
858
+ refreshPresentation() { (this.canvas || this.viewer)?.refreshPresentation?.(); }
859
+ validate() { return this._runValidation('api'); }
685
860
 
686
861
  _syncProjectionButtons() {
687
862
  this._projectionButtons?.forEach((button, value) => {
@@ -728,12 +903,32 @@ export class BpmnStudioShell {
728
903
  this._statusDot.className = `is-${tone}`;
729
904
  }
730
905
 
731
- _showValidationStatus() {
732
- const issues = this.actions.validate();
906
+ _runValidation(source) {
907
+ const issues = this.studio.validate();
908
+ const errorCount = issues.filter((issue) => issue.level === 'error').length;
909
+ const warningCount = issues.filter((issue) => issue.level === 'warning').length;
733
910
  if (!issues.length) this._setStatus('校验通过:流程结构正常', 'ok');
734
911
  else {
735
- const errors = issues.filter((issue) => issue.level === 'error').length;
736
- this._setStatus(`校验完成:${errors ? `${errors} 个错误 · ` : ''}${issues.length} 个提示`, errors ? 'error' : 'warning');
912
+ const summary = [
913
+ errorCount ? `${errorCount} 个错误` : null,
914
+ warningCount ? `${warningCount} 个警告` : null,
915
+ ].filter(Boolean).join(' · ');
916
+ this._setStatus(`校验完成:${summary}`, errorCount ? 'error' : 'warning');
917
+ }
918
+ const eventIssues = Object.freeze(issues.map((issue) => Object.freeze({ ...issue })));
919
+ const event = Object.freeze({
920
+ source,
921
+ valid: errorCount === 0,
922
+ errorCount,
923
+ warningCount,
924
+ issues: eventIssues,
925
+ });
926
+ for (const listener of [...this._validationListeners]) {
927
+ try {
928
+ listener(event);
929
+ } catch (error) {
930
+ console.error('BpmnStudioShell validation listener failed.', error);
931
+ }
737
932
  }
738
933
  return issues;
739
934
  }
@@ -752,8 +947,8 @@ export class BpmnStudioShell {
752
947
  }
753
948
 
754
949
  _recordCleanup(cleanup) { if (typeof cleanup === 'function') this._cleanups.push(cleanup); }
755
- _mountCanvas(container, options = {}) {
756
- const instance = new BpmnCanvas({
950
+ _createCanvas(container, options = {}) {
951
+ return new BpmnCanvas({
757
952
  container,
758
953
  studio: this.studio,
759
954
  interactions: this.interactions,
@@ -768,6 +963,9 @@ export class BpmnStudioShell {
768
963
  },
769
964
  themeController: this.themeController,
770
965
  });
966
+ }
967
+ _mountCanvas(container, options = {}) {
968
+ const instance = this._createCanvas(container, options);
771
969
  this._instances.push(instance); return instance;
772
970
  }
773
971
  _mountPalette(container, options = {}) {
@@ -778,11 +976,25 @@ export class BpmnStudioShell {
778
976
  const instance = new PropertiesPanel({ container, studio: this.studio, registry: this.propertiesRegistry, iconRegistry: this.iconRegistry, themeController: this.themeController, ...options });
779
977
  instance.render(); this._instances.push(instance); return instance;
780
978
  }
979
+ _releaseInstance(instance) {
980
+ if (!instance) return;
981
+ const index = this._instances.indexOf(instance);
982
+ if (index >= 0) this._instances.splice(index, 1);
983
+ instance.destroy?.();
984
+ }
781
985
  destroy() {
986
+ if (this._destroyed) return;
987
+ this._destroyed = true;
988
+ this._modeListeners.clear();
989
+ this._validationListeners.clear();
782
990
  this._svgExportPreview?.close?.({ immediate: true });
783
991
  this._svgExportPreview = null;
784
992
  this._cleanups.splice(0).reverse().forEach((cleanup) => cleanup?.());
993
+ this.viewer?.destroy?.();
994
+ this.viewer = null;
785
995
  this._instances.splice(0).reverse().forEach((instance) => instance.destroy?.());
996
+ this.canvas = null;
997
+ this.properties = null;
786
998
  this.container.innerHTML = '';
787
999
  this.container.classList.remove('nova-studio-shell');
788
1000
  this.themeController.destroy();