@bpmn-nova/studio 0.3.1-preview → 0.3.3-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
@@ -1,7 +1,6 @@
1
1
  import { createDefaultIconRegistry, hydrateIcons } from './modules/icons/index.js';
2
2
  import { createDefaultPaletteRegistry, PalettePanel } from './modules/palette/index.js';
3
3
  import { createDefaultPropertiesRegistry, PropertiesPanel } from './modules/properties/index.js';
4
- import { demoRuntime } from './modules/runtime/index.js';
5
4
  import { BpmnViewer } from './modules/viewer/index.js';
6
5
  import { ThemeController } from './modules/theme/index.js';
7
6
  import { openSvgExportPreview } from './modules/export-svg/index.js';
@@ -29,6 +28,43 @@ function mountSlot(slot, container, context) {
29
28
  return null;
30
29
  }
31
30
 
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
+ ]);
37
+ const STUDIO_SHELL_REGIONS = ['header', 'left', 'right', 'footer'];
38
+ const DEFAULT_STUDIO_SHELL_REGIONS = Object.freeze({
39
+ header: 'default',
40
+ left: 'default',
41
+ right: 'default',
42
+ footer: 'default',
43
+ });
44
+
45
+ function normalizeAllowedModes(allowedModes, { allowDefault = true } = {}) {
46
+ if (allowDefault && (allowedModes === undefined || allowedModes === null)) return [...STUDIO_MODES];
47
+ if (!Array.isArray(allowedModes)) throw new Error('allowedModes must be an array of Studio modes.');
48
+ if (!allowedModes.length || allowedModes.some((mode) => !STUDIO_MODES.includes(mode)) || new Set(allowedModes).size !== allowedModes.length) {
49
+ throw new Error('allowedModes must contain one or more unique design, viewer, or instance modes.');
50
+ }
51
+ return [...allowedModes];
52
+ }
53
+
54
+ function normalizeRegions(regions, current = DEFAULT_STUDIO_SHELL_REGIONS) {
55
+ if (regions === undefined || regions === null) return { ...current };
56
+ if (!regions || typeof regions !== 'object' || Array.isArray(regions)) {
57
+ throw new Error('regions must be an object containing header, left, right, or footer modes.');
58
+ }
59
+ for (const name of Object.keys(regions)) {
60
+ if (!STUDIO_SHELL_REGIONS.includes(name)) throw new Error(`Unknown Studio Shell region: ${name}.`);
61
+ if (!['default', 'hidden'].includes(regions[name])) {
62
+ throw new Error(`Studio Shell region "${name}" must be "default" or "hidden".`);
63
+ }
64
+ }
65
+ return { ...current, ...regions };
66
+ }
67
+
32
68
  export class BpmnStudioShell {
33
69
  constructor({
34
70
  container,
@@ -39,10 +75,13 @@ export class BpmnStudioShell {
39
75
  templateRegistry = createTemplateRegistry(),
40
76
  contextMenuRegistry = createDefaultContextMenuRegistry(),
41
77
  rendererOptions = {},
78
+ nodeSubtitleResolver = rendererOptions.nodeSubtitleResolver,
42
79
  slots = {},
43
80
  layout = null,
44
- runtime = demoRuntime(),
81
+ regions = null,
82
+ runtime = null,
45
83
  mode = 'design',
84
+ allowedModes = null,
46
85
  projection = undefined,
47
86
  responsive = false,
48
87
  projectionOptions = null,
@@ -54,8 +93,13 @@ export class BpmnStudioShell {
54
93
  onThemeChange = null,
55
94
  } = {}) {
56
95
  if (!container || !studio) throw new Error('BpmnStudioShell requires container and studio.');
96
+ if (typeof layout === 'function' && regions !== null && regions !== undefined) {
97
+ throw new Error('BpmnStudioShell options "layout" and "regions" cannot be used together.');
98
+ }
99
+ const resolvedAllowedModes = normalizeAllowedModes(allowedModes);
100
+ const resolvedMode = resolvedAllowedModes.includes(mode) ? mode : resolvedAllowedModes[0];
57
101
  const instanceProjection = projection || (responsive ? 'auto' : 'approval');
58
- const activeProjection = mode === 'instance' ? instanceProjection : 'standard';
102
+ const activeProjection = resolvedMode === 'instance' ? instanceProjection : 'standard';
59
103
  Object.assign(this, {
60
104
  container,
61
105
  studio,
@@ -63,27 +107,68 @@ export class BpmnStudioShell {
63
107
  paletteRegistry,
64
108
  templateRegistry,
65
109
  contextMenuRegistry,
66
- rendererOptions,
110
+ rendererOptions: { ...rendererOptions, nodeSubtitleResolver },
67
111
  slots,
68
112
  runtime,
69
- mode,
113
+ mode: resolvedMode,
114
+ allowedModes: resolvedAllowedModes,
70
115
  projection: activeProjection,
71
116
  _instanceProjection: instanceProjection,
72
117
  responsive,
73
118
  projectionOptions,
74
119
  runtimeAppearance,
75
120
  svgExport: svgExport || rendererOptions.svgExport || null,
121
+ _usesCustomLayout: typeof layout === 'function',
122
+ _regions: normalizeRegions(regions),
76
123
  });
77
124
  this.propertiesRegistry = propertiesRegistry || createDefaultPropertiesRegistry({ studio });
78
125
  this.interactions = createInteractionController({ studio, templates: templateRegistry });
79
126
  this._cleanups = [];
80
127
  this._instances = [];
128
+ this._modeListeners = new Set();
129
+ this._validationListeners = new Set();
130
+ this._modeViewports = new Map();
131
+ this._destroyed = false;
81
132
  this._svgExportPreview = null;
133
+ this.actions = Object.freeze({
134
+ undo: () => this.studio.undo(),
135
+ redo: () => this.studio.redo(),
136
+ beautify: (options) => {
137
+ this.studio.commands.beautify(options);
138
+ requestAnimationFrame(() => this._fitActive());
139
+ },
140
+ rerouteEdges: (options) => {
141
+ this.studio.commands.rerouteEdges(options);
142
+ requestAnimationFrame(() => this._fitActive());
143
+ },
144
+ fitView: () => this.fitView(),
145
+ validate: () => this._runValidation('toolbar'),
146
+ importXml: (xml, engine) => this.studio.importXml(xml, engine),
147
+ exportXml: (engine) => this.studio.exportXml(engine),
148
+ exportSvg: (options) => this.exportSvg(options),
149
+ openSvgExportPreview: (options) => this.openSvgExportPreview(options),
150
+ });
82
151
  this.container.classList.add('nova-studio-shell');
83
152
  this.themeController = new ThemeController({ root: this.container, theme, onChange: onThemeChange });
84
153
  this.container.style.setProperty('--nova-left-width', `${leftWidth}px`);
85
154
  this.container.style.setProperty('--nova-right-width', `${rightWidth}px`);
86
- const context = { studio, iconRegistry, paletteRegistry, propertiesRegistry: this.propertiesRegistry, templateRegistry, contextMenuRegistry, interactions: this.interactions };
155
+ const context = {
156
+ studio,
157
+ shell: this,
158
+ actions: this.actions,
159
+ getState: () => this.studio.getState(),
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),
165
+ iconRegistry,
166
+ paletteRegistry,
167
+ propertiesRegistry: this.propertiesRegistry,
168
+ templateRegistry,
169
+ contextMenuRegistry,
170
+ interactions: this.interactions,
171
+ };
87
172
  const mount = {
88
173
  canvas: (host, options = {}) => this._mountCanvas(host, options),
89
174
  palette: (host, options = {}) => this._mountPalette(host, options),
@@ -104,20 +189,8 @@ export class BpmnStudioShell {
104
189
  brandCopy.append(node('strong', '', 'BPMN Nova'), node('span', '', this.studio.model.name));
105
190
  brand.append(brandMark, brandCopy);
106
191
  const modeSwitch = node('nav', 'nova-studio-mode-switch');
192
+ modeSwitch.setAttribute('aria-label', 'Studio 模式');
107
193
  const modeButtons = new Map();
108
- for (const item of [
109
- { value: 'design', label: '流程设计', iconId: 'ui.design' },
110
- { value: 'viewer', label: '流程展示', iconId: 'ui.preview' },
111
- { value: 'instance', label: '审批轨迹', iconId: 'ui.trace' },
112
- ]) {
113
- const button = node('button');
114
- button.type = 'button';
115
- button.setAttribute('data-mode', item.value);
116
- button.append(iconNode(item.iconId, 'nova-icon nova-icon-xs'), node('span', '', item.label));
117
- button.addEventListener('click', () => this._setMode(item.value));
118
- modeButtons.set(item.value, button);
119
- modeSwitch.appendChild(button);
120
- }
121
194
  const projectionSwitch = node('nav', 'nova-studio-projection-switch is-hidden');
122
195
  projectionSwitch.setAttribute('aria-label', '审批轨迹视图');
123
196
  const projectionButtons = new Map();
@@ -136,16 +209,16 @@ export class BpmnStudioShell {
136
209
  const centerControls = node('div', 'nova-studio-center-controls');
137
210
  centerControls.append(modeSwitch, projectionSwitch);
138
211
  const tools = node('div', 'nova-studio-tools');
139
- const tool = (label, title, action, className = '', iconId = null) => {
212
+ const tool = (label, title, action, className = '', iconId = null, target = tools) => {
140
213
  const button = node('button', `nova-studio-tool ${className}`.trim()); button.type = 'button'; button.title = title;
141
214
  if (iconId) button.appendChild(iconNode(iconId));
142
215
  if (label) button.appendChild(node('span', '', label));
143
- button.addEventListener('click', action); tools.appendChild(button); return button;
216
+ button.addEventListener('click', action); target.appendChild(button); return button;
144
217
  };
145
218
 
146
219
  const divider = () => tools.appendChild(node('span', 'nova-studio-tool-divider'));
147
- const undo = tool('', '撤销 Ctrl/⌘+Z', () => this.studio.undo(), 'is-icon', 'ui.undo');
148
- const redo = tool('', '重做 Ctrl/⌘+Y', () => this.studio.redo(), 'is-icon', 'ui.redo');
220
+ const undo = tool('', '撤销 Ctrl/⌘+Z', () => this.actions.undo(), 'is-icon', 'ui.undo');
221
+ const redo = tool('', '重做 Ctrl/⌘+Y', () => this.actions.redo(), 'is-icon', 'ui.redo');
149
222
  divider();
150
223
  const beautifySplit = node('div', 'nova-studio-beautify-split');
151
224
  const beautifyButton = node('button', 'nova-studio-tool nova-studio-beautify is-magic');
@@ -200,56 +273,62 @@ export class BpmnStudioShell {
200
273
  });
201
274
  beautifySplit.append(beautifyButton, beautifyCaret, beautifyMenu);
202
275
  tools.appendChild(beautifySplit);
203
- const fit = tool('最佳视图', '适应画布内容', () => this._fitActive(), '', 'ui.fitView');
204
-
205
- const importInput = node('input', 'nova-studio-import-input');
206
- importInput.type = 'file';
207
- importInput.accept = '.bpmn,.xml,.bpmn20.xml,text/xml,application/xml';
208
- importInput.hidden = true;
209
- const validateButton = tool('校验', '校验流程结构', () => this._showValidationStatus(), 'nova-studio-validate');
210
- const importButton = tool('导入', '导入 BPMN XML', () => importInput.click(), 'nova-studio-import');
211
- const exportMenu = node('div', 'nova-studio-export-menu');
212
- const exportButton = node('button', 'nova-studio-tool nova-studio-export is-primary');
213
- exportButton.type = 'button';
214
- exportButton.title = '导出当前内容';
215
- exportButton.append(node('span', '', '导出'), iconNode('ui.chevron', 'nova-icon nova-icon-xs'));
216
- const exportPopover = node('div', 'nova-studio-export-popover is-hidden');
217
- const exportSvgButton = node('button', 'nova-studio-export-option');
218
- exportSvgButton.type = 'button';
219
- exportSvgButton.append(iconNode('ui.preview'), node('strong', '', '导出 SVG'), node('small', '', '预览确认后下载'));
220
- exportSvgButton.addEventListener('click', () => {
221
- exportPopover.classList.add('is-hidden');
222
- exportButton.focus({ preventScroll: true });
223
- this.openSvgExportPreview();
224
- });
225
- const exportBpmnButton = node('button', 'nova-studio-export-option');
226
- exportBpmnButton.type = 'button';
227
- exportBpmnButton.append(iconNode('ui.design'), node('strong', '', '导出 BPMN'), node('small', '', '下载流程 XML'));
228
- exportBpmnButton.addEventListener('click', () => {
229
- exportPopover.classList.add('is-hidden');
230
- this._exportBpmn();
231
- });
232
- exportPopover.append(exportSvgButton, exportBpmnButton);
233
- exportButton.addEventListener('click', (event) => {
234
- event.stopPropagation();
235
- exportPopover.classList.toggle('is-hidden');
236
- });
237
- exportMenu.append(exportButton, exportPopover);
238
- tools.appendChild(exportMenu);
239
- importInput.addEventListener('change', async () => {
240
- const file = importInput.files?.[0];
241
- if (!file) return;
242
- try {
243
- this.studio.importXml(await file.text(), this.studio.model.engine);
244
- this._setStatus('已导入 BPMN 文件', 'ok');
245
- requestAnimationFrame(() => this._fitActive());
246
- } catch (error) {
247
- this._setStatus(`导入失败:${error.message}`, 'error');
248
- } finally {
249
- importInput.value = '';
250
- }
251
- });
252
- 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);
253
332
  header.append(brand, centerControls, tools);
254
333
 
255
334
  const body = node('div', 'nova-studio-body');
@@ -295,18 +374,24 @@ export class BpmnStudioShell {
295
374
  this.container.append(header, body);
296
375
  hydrateIcons(this.container, this.iconRegistry);
297
376
  Object.assign(this, {
377
+ _header: header,
378
+ _headerStart: brand,
379
+ _headerActions: headerActions,
298
380
  _body: body,
299
381
  _left: left,
300
382
  _right: right,
383
+ _canvasColumn: canvasColumn,
384
+ _footer: statusbar,
301
385
  _canvasHost: canvasHost,
302
386
  _floatingHead: floatingHead,
303
387
  _scopeBack: scopeBack,
304
388
  _scopeNav: scopeNav,
305
389
  _modeButtons: modeButtons,
390
+ _modeSwitch: modeSwitch,
306
391
  _projectionSwitch: projectionSwitch,
307
392
  _projectionButtons: projectionButtons,
308
393
  _viewportTools: viewportTools,
309
- _designControls: [undo, redo, beautifySplit, validateButton, importButton],
394
+ _designControls: [undo, redo, beautifySplit, validateButton, importButton].filter(Boolean),
310
395
  _exportMenu: exportMenu,
311
396
  _exportPopover: exportPopover,
312
397
  _exportBpmnButton: exportBpmnButton,
@@ -314,6 +399,7 @@ export class BpmnStudioShell {
314
399
  _defaultMount: mount,
315
400
  _usesDefaultProperties: !this.slots.right,
316
401
  });
402
+ this._rebuildModeButtons();
317
403
  this.canvas = mount.canvas(canvasHost, {
318
404
  rendererOptions: {
319
405
  ...this.rendererOptions,
@@ -328,8 +414,21 @@ export class BpmnStudioShell {
328
414
  else this.palette = mount.palette(left, { canvas: this.canvas });
329
415
  if (this.slots.right) this._recordCleanup(mountSlot(this.slots.right, right, { ...context, canvas: this.canvas }));
330
416
  else this.properties = mount.properties(right, { canvas: this.canvas });
331
- if (this.slots.header) { header.innerHTML = ''; this._recordCleanup(mountSlot(this.slots.header, header, { ...context, 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
+ };
422
+ if (this.slots.header) {
423
+ header.innerHTML = '';
424
+ this._recordCleanup(mountSlot(this.slots.header, header, { ...context, canvas: this.canvas }));
425
+ } else if (this.slots.headerStart) {
426
+ brand.innerHTML = '';
427
+ this._recordCleanup(mountSlot(this.slots.headerStart, brand, { ...context, canvas: this.canvas }));
428
+ mountHeaderActions();
429
+ } else mountHeaderActions();
332
430
  if (this.slots.footer) { statusbar.innerHTML = ''; this._recordCleanup(mountSlot(this.slots.footer, statusbar, { ...context, canvas: this.canvas })); }
431
+ hydrateIcons(this.container, this.iconRegistry);
333
432
  this._offState = this.studio.subscribe((event) => {
334
433
  if (event.type === 'historyChanged' || event.type === 'modelChanged') {
335
434
  undo.disabled = !this.studio.history.canUndo;
@@ -353,13 +452,12 @@ export class BpmnStudioShell {
353
452
  this._setStatus(`${initialGraph.nodes.length} 节点 · ${initialGraph.edges.length} 连线`, 'ok');
354
453
  const closeBeautify = (event) => {
355
454
  if (!beautifySplit.contains(event.target)) beautifyMenu.classList.add('is-hidden');
356
- if (!exportMenu.contains(event.target)) exportPopover.classList.add('is-hidden');
455
+ if (exportMenu && !exportMenu.contains(event.target)) exportPopover.classList.add('is-hidden');
357
456
  };
358
457
  document.addEventListener('click', closeBeautify);
359
458
  this._cleanups.push(() => document.removeEventListener('click', closeBeautify));
360
459
  this._syncDensityButtons();
361
- this._setMode(this.mode, { force: true });
362
- requestAnimationFrame(() => fit.click());
460
+ this._setMode(this.mode, { force: true, emit: false });
363
461
  }
364
462
 
365
463
  _renderScopePath() {
@@ -399,14 +497,13 @@ export class BpmnStudioShell {
399
497
 
400
498
  _runBeautify(action = null) {
401
499
  const settings = this.studio.model.settings || {};
402
- if (action === 'routing') this.studio.commands.rerouteEdges({ edgeStyle: 'rounded' });
403
- else if (action === 'smooth') this.studio.commands.rerouteEdges({ edgeStyle: 'smooth' });
404
- else this.studio.commands.beautify({
500
+ if (action === 'routing') this.actions.rerouteEdges({ edgeStyle: 'rounded' });
501
+ else if (action === 'smooth') this.actions.rerouteEdges({ edgeStyle: 'smooth' });
502
+ else this.actions.beautify({
405
503
  direction: action || settings.direction || 'horizontal',
406
504
  density: settings.layoutDensity || 'balanced',
407
505
  edgeStyle: settings.edgeStyle || 'rounded',
408
506
  });
409
- requestAnimationFrame(() => this._fitActive());
410
507
  }
411
508
 
412
509
  _syncDensityButtons() {
@@ -415,93 +512,225 @@ export class BpmnStudioShell {
415
512
  this.container.querySelectorAll('[data-density]').forEach((button) => button.classList.toggle('is-active', button.dataset.density === value));
416
513
  }
417
514
 
418
- _setMode(mode, { force = false } = {}) {
419
- if (!['design', 'viewer', 'instance'].includes(mode)) return;
420
- if (!force && this.mode === mode) return;
421
- this.mode = mode;
422
- this.projection = mode === 'instance' ? this._instanceProjection : 'standard';
423
- this._modeButtons?.forEach((button, value) => button.classList.toggle('is-active', value === mode));
424
- 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';
425
542
  this._body?.classList.toggle('is-readonly', readonly);
543
+ this._applyRegions({ fit: false });
426
544
  this._floatingHead?.classList.toggle('is-hidden', readonly);
427
545
  this._designControls?.forEach((control) => control.classList.toggle('is-hidden-by-mode', readonly));
428
- this._projectionSwitch?.classList.toggle('is-hidden', mode !== 'instance');
429
- 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');
430
548
  this._syncProjectionButtons();
431
- this._body?.classList.remove('is-timeline');
432
-
433
- if (!this._canvasHost) return;
434
- if (readonly) {
435
- this.canvas?.destroy?.();
436
- this.canvas = null;
437
- this.viewer?.destroy?.();
438
- this._canvasHost.innerHTML = '';
439
- if (this._usesDefaultProperties) this.properties?.destroy?.();
440
- this.viewer = new BpmnViewer({
441
- container: this._canvasHost,
442
- themeController: this.themeController,
443
- runtimeAppearance: this.runtimeAppearance,
444
- model: this.studio.model,
445
- iconRegistry: this.iconRegistry,
446
- nodeRenderers: this.rendererOptions.nodeRenderers,
447
- nodeRenderer: this.rendererOptions.nodeRenderer,
448
- runtime: mode === 'instance' ? this.runtime : null,
449
- projection: this.projection,
450
- responsive: this.responsive,
451
- runtimeTraceOptions: this.rendererOptions.runtimeTraceOptions,
452
- timeline: this.rendererOptions.timeline,
453
- runtimeDetails: this.rendererOptions.runtimeDetails,
454
- runtimePresenter: this.rendererOptions.runtimePresenter,
455
- runtimeTraceProjector: this.rendererOptions.runtimeTraceProjector,
456
- runtimeAssetResolver: this.rendererOptions.runtimeAssetResolver,
457
- svgExport: this.svgExport,
458
- runtimeTimelineRenderer: this.slots.runtimeTimeline
459
- ? (timeline) => mountSlot(this.slots.runtimeTimeline, timeline.container, { ...timeline, studio: this.studio, shell: this })
460
- : this.rendererOptions.runtimeTimelineRenderer,
461
- onRuntimeTraceItemClick: this.rendererOptions.onRuntimeTraceItemClick,
462
- runtimeDetailsRenderer: this.slots.runtimeDetails
463
- ? (details) => mountSlot(this.slots.runtimeDetails, details.container, { ...details, studio: this.studio, shell: this })
464
- : this.rendererOptions.runtimeDetailsRenderer,
465
- onRuntimeDetailsOpen: this.rendererOptions.onRuntimeDetailsOpen,
466
- runtimeTransitionDetailsRenderer: this.slots.runtimeTransitionDetails
467
- ? (details) => mountSlot(this.slots.runtimeTransitionDetails, details.container, { ...details, studio: this.studio, shell: this })
468
- : this.rendererOptions.runtimeTransitionDetailsRenderer,
469
- onRuntimeTransitionDetailsOpen: this.rendererOptions.onRuntimeTransitionDetailsOpen,
470
- onTraceClick: (payload) => {
471
- const kind = payload.targetType === 'visit' ? 'node' : payload.targetType === 'transition' ? 'node' : payload.targetType;
472
- this._renderReadonlyDetails({ kind, element: payload.element, presentation: payload.presentation, transition: payload.transition });
473
- this.rendererOptions.onTraceClick?.(payload);
474
- },
475
- onProjectionChange: ({ active }) => {
476
- const timeline = active === 'compact';
477
- this._body?.classList.toggle('is-timeline', timeline);
478
- this._viewportTools?.classList.toggle('is-hidden', timeline);
479
- const status = timeline ? '审批轨迹 · 移动时间线' : active === 'standard' ? '审批轨迹 · 完整 BPMN' : '审批轨迹 · 实际路径';
480
- this._setStatus(status, 'ok');
481
- },
482
- onViewportChange: (viewport) => this._handleViewport(viewport),
483
- onElementClick: (selection) => {
484
- if (!selection) this._renderReadonlyDetails(null);
485
- this.rendererOptions.onElementClick?.(selection);
486
- },
487
- });
488
- this._renderReadonlyDetails(null);
489
- this._setStatus(mode === 'instance' ? '审批轨迹 · 运行实例' : '流程展示 · 只读预览', 'ok');
490
- } else if (!this.canvas) {
491
- this.viewer?.destroy?.();
492
- this.viewer = null;
493
- this._canvasHost.innerHTML = '';
494
- this.canvas = this._defaultMount.canvas(this._canvasHost, {
495
- rendererOptions: {
496
- ...this.rendererOptions,
497
- onViewportChange: (viewport) => this._handleViewport(viewport),
498
- },
499
- });
500
- if (this.palette) this.palette.canvas = this.canvas;
501
- if (this._usesDefaultProperties) this.properties = this._defaultMount.properties(this._right, { canvas: this.canvas });
502
- 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');
564
+ }
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;
503
679
  }
504
- requestAnimationFrame(() => this._fitActive());
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;
505
734
  }
506
735
 
507
736
  _handleViewport(viewport) {
@@ -552,7 +781,34 @@ export class BpmnStudioShell {
552
781
  if (this.canvas) this.canvas.fitView(72);
553
782
  else this.viewer?.fitView?.();
554
783
  }
555
- 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
+ }
556
812
  setRuntime(runtime) {
557
813
  this.runtime = runtime;
558
814
  if (this.mode === 'instance') this.viewer?.setRuntime(runtime);
@@ -563,6 +819,34 @@ export class BpmnStudioShell {
563
819
  this._syncProjectionButtons();
564
820
  if (this.mode === 'instance') this.viewer?.setProjection?.(projection);
565
821
  }
822
+ getRegions() { return Object.freeze({ ...this._regions }); }
823
+ setRegions(regions) {
824
+ if (this._usesCustomLayout) {
825
+ throw new Error('BpmnStudioShell setRegions() is unavailable when a custom layout is active.');
826
+ }
827
+ const next = normalizeRegions(regions, this._regions);
828
+ const changed = STUDIO_SHELL_REGIONS.some((name) => next[name] !== this._regions[name]);
829
+ this._regions = next;
830
+ if (changed) this._applyRegions();
831
+ return this.getRegions();
832
+ }
833
+ _applyRegions({ fit = true } = {}) {
834
+ if (this._usesCustomLayout || !this._body) return;
835
+ const readonly = this.mode !== 'design';
836
+ const headerHidden = this._regions.header === 'hidden';
837
+ const leftHidden = readonly || this._regions.left === 'hidden';
838
+ const rightHidden = this._regions.right === 'hidden';
839
+ const footerHidden = this._regions.footer === 'hidden';
840
+ this.container.classList.toggle('is-header-hidden', headerHidden);
841
+ this._body.classList.toggle('is-left-hidden', leftHidden);
842
+ this._body.classList.toggle('is-right-hidden', rightHidden);
843
+ this._canvasColumn?.classList.toggle('is-footer-hidden', footerHidden);
844
+ if (this._header) this._header.hidden = headerHidden;
845
+ if (this._left) this._left.hidden = leftHidden;
846
+ if (this._right) this._right.hidden = rightHidden;
847
+ if (this._footer) this._footer.hidden = footerHidden;
848
+ if (fit) requestAnimationFrame(() => this._fitActive());
849
+ }
566
850
  setTheme(theme) { return this.themeController.setTheme(theme); }
567
851
  setThemeMode(mode) { return this.themeController.setMode(mode); }
568
852
  getThemeState() { return this.themeController.getState(); }
@@ -571,6 +855,8 @@ export class BpmnStudioShell {
571
855
  this.runtimeAppearance = runtimeAppearance || null;
572
856
  this.viewer?.setRuntimeAppearance?.(this.runtimeAppearance);
573
857
  }
858
+ refreshPresentation() { (this.canvas || this.viewer)?.refreshPresentation?.(); }
859
+ validate() { return this._runValidation('api'); }
574
860
 
575
861
  _syncProjectionButtons() {
576
862
  this._projectionButtons?.forEach((button, value) => {
@@ -617,18 +903,38 @@ export class BpmnStudioShell {
617
903
  this._statusDot.className = `is-${tone}`;
618
904
  }
619
905
 
620
- _showValidationStatus() {
906
+ _runValidation(source) {
621
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;
622
910
  if (!issues.length) this._setStatus('校验通过:流程结构正常', 'ok');
623
911
  else {
624
- const errors = issues.filter((issue) => issue.level === 'error').length;
625
- 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
+ }
626
932
  }
627
933
  return issues;
628
934
  }
629
935
 
630
936
  _exportBpmn() {
631
- const blob = new Blob([this.studio.exportXml()], { type: 'application/xml;charset=utf-8' });
937
+ const blob = new Blob([this.actions.exportXml()], { type: 'application/xml;charset=utf-8' });
632
938
  const url = URL.createObjectURL(blob);
633
939
  const anchor = node('a');
634
940
  anchor.href = url;
@@ -641,8 +947,8 @@ export class BpmnStudioShell {
641
947
  }
642
948
 
643
949
  _recordCleanup(cleanup) { if (typeof cleanup === 'function') this._cleanups.push(cleanup); }
644
- _mountCanvas(container, options = {}) {
645
- const instance = new BpmnCanvas({
950
+ _createCanvas(container, options = {}) {
951
+ return new BpmnCanvas({
646
952
  container,
647
953
  studio: this.studio,
648
954
  interactions: this.interactions,
@@ -657,6 +963,9 @@ export class BpmnStudioShell {
657
963
  },
658
964
  themeController: this.themeController,
659
965
  });
966
+ }
967
+ _mountCanvas(container, options = {}) {
968
+ const instance = this._createCanvas(container, options);
660
969
  this._instances.push(instance); return instance;
661
970
  }
662
971
  _mountPalette(container, options = {}) {
@@ -667,11 +976,25 @@ export class BpmnStudioShell {
667
976
  const instance = new PropertiesPanel({ container, studio: this.studio, registry: this.propertiesRegistry, iconRegistry: this.iconRegistry, themeController: this.themeController, ...options });
668
977
  instance.render(); this._instances.push(instance); return instance;
669
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
+ }
670
985
  destroy() {
986
+ if (this._destroyed) return;
987
+ this._destroyed = true;
988
+ this._modeListeners.clear();
989
+ this._validationListeners.clear();
671
990
  this._svgExportPreview?.close?.({ immediate: true });
672
991
  this._svgExportPreview = null;
673
992
  this._cleanups.splice(0).reverse().forEach((cleanup) => cleanup?.());
993
+ this.viewer?.destroy?.();
994
+ this.viewer = null;
674
995
  this._instances.splice(0).reverse().forEach((instance) => instance.destroy?.());
996
+ this.canvas = null;
997
+ this.properties = null;
675
998
  this.container.innerHTML = '';
676
999
  this.container.classList.remove('nova-studio-shell');
677
1000
  this.themeController.destroy();