@bpmn-nova/studio 0.3.0-preview → 0.3.1-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.
Files changed (61) hide show
  1. package/README.md +202 -19
  2. package/dist/canvas.js +5 -3
  3. package/dist/context-menu.js +1 -1
  4. package/dist/controller.js +2 -2
  5. package/dist/index.d.ts +76 -35
  6. package/dist/index.js +12 -11
  7. package/dist/interactions.js +1 -1
  8. package/dist/modules/bpmn-model/index.d.ts +11 -0
  9. package/dist/modules/bpmn-model/index.js +703 -0
  10. package/dist/modules/core/containment.js +590 -0
  11. package/dist/modules/core/gateway.js +72 -0
  12. package/dist/modules/core/history.js +32 -0
  13. package/dist/modules/core/index.d.ts +268 -0
  14. package/dist/modules/core/index.js +7 -0
  15. package/dist/modules/core/layout.js +644 -0
  16. package/dist/modules/core/model.js +287 -0
  17. package/dist/modules/core/runtime-transition-route.js +360 -0
  18. package/dist/modules/core/scope.js +99 -0
  19. package/dist/modules/designer/index.d.ts +79 -0
  20. package/dist/modules/designer/index.js +607 -0
  21. package/dist/modules/engine-activiti/index.d.ts +19 -0
  22. package/dist/modules/engine-activiti/index.js +160 -0
  23. package/dist/modules/engine-flowable/index.d.ts +19 -0
  24. package/dist/modules/engine-flowable/index.js +160 -0
  25. package/dist/modules/export-svg/index.d.ts +112 -0
  26. package/dist/modules/export-svg/index.js +2 -0
  27. package/dist/modules/export-svg/preview.js +327 -0
  28. package/dist/modules/export-svg/render.js +718 -0
  29. package/dist/modules/icons/index.d.ts +24 -0
  30. package/dist/modules/icons/index.js +264 -0
  31. package/dist/modules/palette/index.d.ts +62 -0
  32. package/dist/modules/palette/index.js +99 -0
  33. package/dist/modules/palette/panel.js +99 -0
  34. package/dist/modules/properties/index.d.ts +20 -0
  35. package/dist/modules/properties/index.js +19 -0
  36. package/dist/modules/properties-activiti/index.d.ts +3 -0
  37. package/dist/modules/properties-activiti/index.js +97 -0
  38. package/dist/modules/properties-bpmn/index.d.ts +3 -0
  39. package/dist/modules/properties-bpmn/index.js +516 -0
  40. package/dist/modules/properties-core/index.d.ts +124 -0
  41. package/dist/modules/properties-core/index.js +312 -0
  42. package/dist/modules/properties-flowable/index.d.ts +3 -0
  43. package/dist/modules/properties-flowable/index.js +114 -0
  44. package/dist/modules/properties-renderer/index.d.ts +25 -0
  45. package/dist/modules/properties-renderer/index.js +491 -0
  46. package/dist/modules/renderer-svg/index.d.ts +117 -0
  47. package/dist/modules/renderer-svg/index.js +1460 -0
  48. package/dist/modules/runtime/index.d.ts +169 -0
  49. package/dist/modules/runtime/index.js +535 -0
  50. package/dist/modules/theme/index.d.ts +95 -0
  51. package/dist/modules/theme/index.js +368 -0
  52. package/dist/modules/viewer/index.d.ts +265 -0
  53. package/dist/modules/viewer/index.js +1011 -0
  54. package/dist/modules/viewer/runtime-content.js +123 -0
  55. package/dist/modules/viewer/runtime-details-motion.js +228 -0
  56. package/dist/modules/viewer/runtime-trace.js +574 -0
  57. package/dist/modules/viewer/timeline.js +276 -0
  58. package/dist/selection-layout.js +1 -1
  59. package/dist/shell.js +82 -9
  60. package/dist/styles.css +108 -7
  61. package/package.json +36 -15
@@ -0,0 +1,276 @@
1
+ import { createDefaultIconRegistry, createIconElement } from '../icons/index.js';
2
+ import { renderRuntimeApprovalContent } from './runtime-content.js';
3
+ import { applyRuntimeTone } from '../theme/index.js';
4
+
5
+ function element(tag, className, text) {
6
+ const node = document.createElement(tag);
7
+ if (className) node.className = className;
8
+ if (text !== undefined) node.textContent = text;
9
+ return node;
10
+ }
11
+
12
+ function statusIconId(status) {
13
+ if (status === 'completed') return 'ui.statusCompleted';
14
+ if (status === 'active') return 'ui.statusActive';
15
+ if (status === 'failed') return 'ui.statusFailed';
16
+ if (status === 'rejected') return 'ui.statusRejected';
17
+ if (['skipped', 'cancelled'].includes(status)) return 'ui.statusSkipped';
18
+ return 'ui.statusIdle';
19
+ }
20
+
21
+ function icon(registry, id, className = 'nova-icon nova-icon-md') {
22
+ const host = element('span', className);
23
+ const svg = createIconElement(registry.resolve(id, null), { className: 'nova-icon-svg' });
24
+ if (svg) host.appendChild(svg);
25
+ return host;
26
+ }
27
+
28
+ function resultText(item) {
29
+ const outcome = item.outcome;
30
+ return { approved: '已同意', rejected: '已驳回', returned: '已退回', submitted: '已提交', pending: '待处理' }[outcome] || item.statusLabel || '';
31
+ }
32
+
33
+ function formatTime(value) {
34
+ if (!value) return '';
35
+ return String(value).replace('T', ' ');
36
+ }
37
+
38
+ function renderParticipants(item) {
39
+ if ((item.participants || []).length < 2) return null;
40
+ const details = element('details', 'mb-runtime-timeline-participants');
41
+ const summary = element('summary', '', `查看处理人 · ${item.participants.length}人`);
42
+ const list = element('div', 'mb-runtime-timeline-participant-list');
43
+ item.participants.forEach((participant) => list.appendChild(element('span', '', participant.name)));
44
+ details.append(summary, list);
45
+ return details;
46
+ }
47
+
48
+ function renderTimelineAction({ container, action, resolveAsset, onAssetPreview, appearance }) {
49
+ const actionAppearance = appearance?.resolveAction(action) || { label: action.label, tone: 'neutral' };
50
+ const article = element('article', `mb-runtime-timeline-action type-${action.type}`);
51
+ applyRuntimeTone(article, actionAppearance.tone);
52
+ const header = element('header', 'mb-runtime-timeline-action-head');
53
+ header.append(
54
+ element('strong', '', actionAppearance.label),
55
+ element('span', '', [action.actor?.name, formatTime(action.occurredAt)].filter(Boolean).join(' · ')),
56
+ );
57
+ article.appendChild(header);
58
+ if (action.targets?.length) article.appendChild(element('div', 'mb-runtime-action-targets', `目标:${action.targets.map((target) => target.name).join('、')}`));
59
+ renderRuntimeApprovalContent({
60
+ container: article,
61
+ action,
62
+ resolveAsset,
63
+ compact: true,
64
+ onPreview: onAssetPreview,
65
+ });
66
+ container.appendChild(article);
67
+ }
68
+
69
+ function renderTimelineActions({ item, resolveAsset, onAssetPreview, appearance }) {
70
+ const actions = item.actions || [];
71
+ if (!actions.length) return null;
72
+ const host = element('div', 'mb-runtime-timeline-actions');
73
+ const newest = actions.at(-1);
74
+ renderTimelineAction({ container: host, action: newest, resolveAsset, onAssetPreview, appearance });
75
+ if (actions.length > 1) {
76
+ const details = element('details', 'mb-runtime-timeline-action-history');
77
+ const summary = element('summary', '', `查看更早操作 · ${actions.length - 1} 条`);
78
+ const body = element('div', 'mb-runtime-timeline-action-history-body');
79
+ [...actions].slice(0, -1).reverse().forEach((action) => renderTimelineAction({ container: body, action, resolveAsset, onAssetPreview, appearance }));
80
+ details.append(summary, body);
81
+ host.appendChild(details);
82
+ }
83
+ return host;
84
+ }
85
+
86
+ function renderActivityItem({ item, registry, conditionLabel, onItemClick, onDetailsRequest, resolveAsset, onAssetPreview, appearance, compact = false }) {
87
+ const article = element('article', `mb-runtime-timeline-item status-${item.status}${item.predicted ? ' is-predicted' : ''}${compact ? ' is-group-child' : ''}`);
88
+ applyRuntimeTone(article, appearance?.resolveStatus(item.status));
89
+ article.dataset.traceItemId = item.id;
90
+ article.dataset.elementId = item.elementId;
91
+ const rail = element('div', 'mb-runtime-timeline-rail');
92
+ rail.appendChild(icon(registry, statusIconId(item.status), 'nova-icon nova-icon-lg'));
93
+ const card = element('div', 'mb-runtime-timeline-card');
94
+ const trigger = element('button', 'mb-runtime-timeline-card-main');
95
+ trigger.type = 'button';
96
+ trigger.setAttribute('aria-label', `${item.name},${item.statusLabel}${item.summary ? `,${item.summary}` : ''}${item.comment ? `,${item.comment}` : ''}`);
97
+ const heading = element('div', 'mb-runtime-timeline-heading');
98
+ const title = element('strong', '', item.name);
99
+ if (item.round > 1) title.appendChild(element('small', '', `第 ${item.round} 次`));
100
+ heading.append(title, element('em', '', resultText(item)));
101
+ const meta = element('div', 'mb-runtime-timeline-meta');
102
+ meta.append(element('span', '', item.summary || (item.automated ? item.statusLabel : '')), element('time', '', formatTime(item.time)));
103
+ trigger.append(heading, meta);
104
+ trigger.addEventListener('click', (event) => {
105
+ onItemClick?.(item, event);
106
+ onDetailsRequest?.(item, event.currentTarget, event);
107
+ });
108
+ card.appendChild(trigger);
109
+ const actions = renderTimelineActions({ item, resolveAsset, onAssetPreview, appearance });
110
+ if (actions) card.appendChild(actions);
111
+ const content = element('div', 'mb-runtime-timeline-content');
112
+ if (conditionLabel) content.appendChild(element('span', 'mb-runtime-timeline-condition', conditionLabel));
113
+ content.appendChild(card);
114
+ const participants = renderParticipants(item);
115
+ if (participants) content.appendChild(participants);
116
+ article.append(rail, content);
117
+ return article;
118
+ }
119
+
120
+ function renderTransitionItem({ item, registry, onItemClick, onTransitionRequest, resolveAsset, onAssetPreview, appearance }) {
121
+ const article = element('article', `mb-runtime-timeline-item mb-runtime-timeline-transition type-${item.type}`);
122
+ applyRuntimeTone(article, appearance?.resolveTransition(item).tone);
123
+ article.dataset.traceItemId = item.id;
124
+ const rail = element('div', 'mb-runtime-timeline-rail');
125
+ rail.appendChild(icon(registry, 'ui.statusRejected', 'nova-icon nova-icon-lg'));
126
+ const card = element('div', 'mb-runtime-timeline-transition-card');
127
+ const button = element('button', 'mb-runtime-timeline-transition-main');
128
+ button.type = 'button';
129
+ button.append(
130
+ element('strong', '', item.name),
131
+ element('span', '', [item.summary, formatTime(item.time)].filter(Boolean).join(' · ')),
132
+ );
133
+ button.addEventListener('click', (event) => {
134
+ onItemClick?.(item, event);
135
+ onTransitionRequest?.(item, event.currentTarget, event);
136
+ });
137
+ card.appendChild(button);
138
+ const action = item.actions?.[0];
139
+ if (action) {
140
+ renderRuntimeApprovalContent({ container: card, action, resolveAsset, compact: true, onPreview: onAssetPreview });
141
+ } else if (item.comment) card.appendChild(element('p', 'mb-runtime-action-paragraph', item.comment));
142
+ article.append(rail, card);
143
+ return article;
144
+ }
145
+
146
+ function renderParallelGroup({ group, children, registry, linkByTarget, onItemClick, onDetailsRequest, resolveAsset, onAssetPreview, appearance }) {
147
+ const details = element('details', `mb-runtime-timeline-group status-${group.status}`);
148
+ applyRuntimeTone(details, appearance?.resolveStatus(group.status));
149
+ details.dataset.traceGroupId = group.id;
150
+ const summary = element('summary', 'mb-runtime-timeline-group-summary');
151
+ summary.append(
152
+ icon(registry, statusIconId(group.status), 'nova-icon nova-icon-lg'),
153
+ element('strong', '', group.label),
154
+ element('span', '', `${group.completed}/${group.total} 已完成`),
155
+ );
156
+ const body = element('div', 'mb-runtime-timeline-group-body');
157
+ children.sort((a, b) => a.order - b.order).forEach((item) => body.appendChild(renderActivityItem({
158
+ item,
159
+ registry,
160
+ conditionLabel: linkByTarget.get(item.id)?.label || '',
161
+ onItemClick,
162
+ onDetailsRequest,
163
+ resolveAsset,
164
+ onAssetPreview,
165
+ appearance,
166
+ compact: true,
167
+ })));
168
+ details.append(summary, body);
169
+ return details;
170
+ }
171
+
172
+ export function renderDefaultRuntimeTimeline({
173
+ container,
174
+ model,
175
+ projection,
176
+ title,
177
+ description,
178
+ iconRegistry,
179
+ onItemClick,
180
+ onDetailsRequest,
181
+ onTransitionRequest,
182
+ resolveAsset,
183
+ onAssetPreview,
184
+ appearance,
185
+ }) {
186
+ const registry = iconRegistry || createDefaultIconRegistry();
187
+ const root = element('section', 'mb-runtime-timeline');
188
+ root.setAttribute('aria-label', `${title || model.name || '流程'}审批轨迹`);
189
+ const list = element('div', 'mb-runtime-timeline-list');
190
+ const linkByTarget = new Map(projection.links.map((link) => [link.targetItemId, link]));
191
+ const groupedIds = new Set(projection.groups.flatMap((group) => group.itemIds));
192
+ const sequence = [
193
+ ...projection.items.filter((item) => !groupedIds.has(item.id)),
194
+ ...projection.groups.map((group) => ({ ...group, kind: 'parallel-group' })),
195
+ ].sort((a, b) => (a.order - b.order) || a.id.localeCompare(b.id));
196
+
197
+ sequence.forEach((entry) => {
198
+ if (entry.kind === 'parallel-group') {
199
+ list.appendChild(renderParallelGroup({
200
+ group: entry,
201
+ children: entry.itemIds.map((id) => projection.items.find((item) => item.id === id)).filter(Boolean),
202
+ registry,
203
+ linkByTarget,
204
+ onItemClick,
205
+ onDetailsRequest,
206
+ resolveAsset,
207
+ onAssetPreview,
208
+ appearance,
209
+ }));
210
+ return;
211
+ }
212
+ if (entry.kind === 'transition') {
213
+ list.appendChild(renderTransitionItem({ item: entry, registry, onItemClick, onTransitionRequest, resolveAsset, onAssetPreview, appearance }));
214
+ return;
215
+ }
216
+ list.appendChild(renderActivityItem({
217
+ item: entry,
218
+ registry,
219
+ conditionLabel: linkByTarget.get(entry.id)?.label || '',
220
+ onItemClick,
221
+ onDetailsRequest,
222
+ resolveAsset,
223
+ onAssetPreview,
224
+ appearance,
225
+ }));
226
+ });
227
+ if (!sequence.length) list.appendChild(element('div', 'mb-runtime-timeline-empty', '暂无运行轨迹'));
228
+ if (title !== null || description !== null) {
229
+ const header = element('header', 'mb-runtime-timeline-header');
230
+ if (title !== null) header.appendChild(element('strong', '', title));
231
+ if (description !== null) header.appendChild(element('span', '', description));
232
+ root.appendChild(header);
233
+ }
234
+ root.appendChild(list);
235
+ container.appendChild(root);
236
+ return () => root.remove();
237
+ }
238
+
239
+ export class RuntimeTimelineHost {
240
+ constructor(container, options = {}) {
241
+ this.container = container;
242
+ this.options = options;
243
+ this.iconRegistry = options.iconRegistry || createDefaultIconRegistry();
244
+ this.selection = null;
245
+ this._cleanup = null;
246
+ this.container.innerHTML = '';
247
+ this.container.classList.remove('mb-diagram-host');
248
+ this.container.classList.add('mb-runtime-timeline-host');
249
+ this.render();
250
+ }
251
+
252
+ render() {
253
+ const scrollTop = this.container.scrollTop;
254
+ this._cleanup?.();
255
+ this.container.replaceChildren();
256
+ const renderer = this.options.renderer || renderDefaultRuntimeTimeline;
257
+ this._cleanup = renderer({ container: this.container, iconRegistry: this.iconRegistry, ...this.options }) || null;
258
+ this.container.scrollTop = scrollTop;
259
+ }
260
+
261
+ setSelection(selection) {
262
+ this.selection = selection;
263
+ this.container.querySelectorAll('[data-element-id]').forEach((node) => node.classList.toggle('is-selected', selection?.kind === 'node' && node.dataset.elementId === selection.id));
264
+ }
265
+
266
+ fitView() { this.container.scrollTop = 0; }
267
+ zoomBy() {}
268
+ actualSize() {}
269
+
270
+ destroy() {
271
+ this._cleanup?.();
272
+ this._cleanup = null;
273
+ this.container.innerHTML = '';
274
+ this.container.classList.remove('mb-runtime-timeline-host');
275
+ }
276
+ }
@@ -3,7 +3,7 @@ import {
3
3
  autoLayout,
4
4
  elementScopeId,
5
5
  getNode,
6
- } from '@bpmn-nova/core';
6
+ } from './modules/core/index.js';
7
7
 
8
8
  const LAYOUT_KINDS = new Set(['event', 'task', 'container', 'gateway']);
9
9
  const GROUPABLE_KINDS = new Set(['event', 'task', 'container', 'gateway', 'data', 'dataStore', 'annotation']);
package/dist/shell.js CHANGED
@@ -1,9 +1,10 @@
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';
1
+ import { createDefaultIconRegistry, hydrateIcons } from './modules/icons/index.js';
2
+ import { createDefaultPaletteRegistry, PalettePanel } from './modules/palette/index.js';
3
+ import { createDefaultPropertiesRegistry, PropertiesPanel } from './modules/properties/index.js';
4
+ import { demoRuntime } from './modules/runtime/index.js';
5
+ import { BpmnViewer } from './modules/viewer/index.js';
6
+ import { ThemeController } from './modules/theme/index.js';
7
+ import { openSvgExportPreview } from './modules/export-svg/index.js';
7
8
  import { BpmnCanvas } from './canvas.js';
8
9
  import { createDefaultContextMenuRegistry } from './context-menu.js';
9
10
  import { createInteractionController, createTemplateRegistry } from './interactions.js';
@@ -49,6 +50,7 @@ export class BpmnStudioShell {
49
50
  rightWidth = 360,
50
51
  theme = null,
51
52
  runtimeAppearance = null,
53
+ svgExport = null,
52
54
  onThemeChange = null,
53
55
  } = {}) {
54
56
  if (!container || !studio) throw new Error('BpmnStudioShell requires container and studio.');
@@ -70,11 +72,13 @@ export class BpmnStudioShell {
70
72
  responsive,
71
73
  projectionOptions,
72
74
  runtimeAppearance,
75
+ svgExport: svgExport || rendererOptions.svgExport || null,
73
76
  });
74
77
  this.propertiesRegistry = propertiesRegistry || createDefaultPropertiesRegistry({ studio });
75
78
  this.interactions = createInteractionController({ studio, templates: templateRegistry });
76
79
  this._cleanups = [];
77
80
  this._instances = [];
81
+ this._svgExportPreview = null;
78
82
  this.container.classList.add('nova-studio-shell');
79
83
  this.themeController = new ThemeController({ root: this.container, theme, onChange: onThemeChange });
80
84
  this.container.style.setProperty('--nova-left-width', `${leftWidth}px`);
@@ -204,7 +208,34 @@ export class BpmnStudioShell {
204
208
  importInput.hidden = true;
205
209
  const validateButton = tool('校验', '校验流程结构', () => this._showValidationStatus(), 'nova-studio-validate');
206
210
  const importButton = tool('导入', '导入 BPMN XML', () => importInput.click(), 'nova-studio-import');
207
- const exportButton = tool('导出 BPMN', '导出当前流程 XML', () => this._exportBpmn(), 'nova-studio-export is-primary');
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);
208
239
  importInput.addEventListener('change', async () => {
209
240
  const file = importInput.files?.[0];
210
241
  if (!file) return;
@@ -275,7 +306,10 @@ export class BpmnStudioShell {
275
306
  _projectionSwitch: projectionSwitch,
276
307
  _projectionButtons: projectionButtons,
277
308
  _viewportTools: viewportTools,
278
- _designControls: [undo, redo, beautifySplit, validateButton, importButton, exportButton],
309
+ _designControls: [undo, redo, beautifySplit, validateButton, importButton],
310
+ _exportMenu: exportMenu,
311
+ _exportPopover: exportPopover,
312
+ _exportBpmnButton: exportBpmnButton,
279
313
  _defaultContext: context,
280
314
  _defaultMount: mount,
281
315
  _usesDefaultProperties: !this.slots.right,
@@ -319,6 +353,7 @@ export class BpmnStudioShell {
319
353
  this._setStatus(`${initialGraph.nodes.length} 节点 · ${initialGraph.edges.length} 连线`, 'ok');
320
354
  const closeBeautify = (event) => {
321
355
  if (!beautifySplit.contains(event.target)) beautifyMenu.classList.add('is-hidden');
356
+ if (!exportMenu.contains(event.target)) exportPopover.classList.add('is-hidden');
322
357
  };
323
358
  document.addEventListener('click', closeBeautify);
324
359
  this._cleanups.push(() => document.removeEventListener('click', closeBeautify));
@@ -391,6 +426,7 @@ export class BpmnStudioShell {
391
426
  this._floatingHead?.classList.toggle('is-hidden', readonly);
392
427
  this._designControls?.forEach((control) => control.classList.toggle('is-hidden-by-mode', readonly));
393
428
  this._projectionSwitch?.classList.toggle('is-hidden', mode !== 'instance');
429
+ this._exportBpmnButton?.classList.toggle('is-hidden', mode !== 'design');
394
430
  this._syncProjectionButtons();
395
431
  this._body?.classList.remove('is-timeline');
396
432
 
@@ -418,6 +454,7 @@ export class BpmnStudioShell {
418
454
  runtimePresenter: this.rendererOptions.runtimePresenter,
419
455
  runtimeTraceProjector: this.rendererOptions.runtimeTraceProjector,
420
456
  runtimeAssetResolver: this.rendererOptions.runtimeAssetResolver,
457
+ svgExport: this.svgExport,
421
458
  runtimeTimelineRenderer: this.slots.runtimeTimeline
422
459
  ? (timeline) => mountSlot(this.slots.runtimeTimeline, timeline.container, { ...timeline, studio: this.studio, shell: this })
423
460
  : this.rendererOptions.runtimeTimelineRenderer,
@@ -544,6 +581,35 @@ export class BpmnStudioShell {
544
581
  }
545
582
  fitView() { this._fitActive(); }
546
583
  zoomBy(factor) { this._zoomActive(factor); }
584
+ exportSvg(options) {
585
+ const active = this.canvas || this.viewer;
586
+ if (!active?.exportSvg) return Promise.reject(new Error('当前视图不支持 SVG 导出。'));
587
+ const label = this.mode === 'design' ? '流程设计' : this.mode === 'viewer' ? '流程展示' : this.viewer?.activeProjection === 'compact' ? '移动时间线' : this.viewer?.activeProjection === 'approval' ? '实际路径' : '完整 BPMN';
588
+ const filename = options?.filename || this.svgExport?.filename || `${this.studio.model.name || this.studio.model.id}-${label}`;
589
+ return active.exportSvg({ label, ...(options || {}), filename });
590
+ }
591
+ openSvgExportPreview(options = {}) {
592
+ if (this._svgExportPreview && !this._svgExportPreview.closed) {
593
+ this._svgExportPreview.focus();
594
+ return this._svgExportPreview;
595
+ }
596
+ const active = this.canvas || this.viewer;
597
+ if (!active?.exportSvg) return null;
598
+ const label = this.mode === 'design' ? '流程设计' : this.mode === 'viewer' ? '流程展示' : this.viewer?.activeProjection === 'compact' ? '移动时间线' : this.viewer?.activeProjection === 'approval' ? '实际路径' : '完整 BPMN';
599
+ const filename = options.filename || this.svgExport?.filename || `${this.studio.model.name || this.studio.model.id}-${label}`;
600
+ this._svgExportPreview = openSvgExportPreview({
601
+ container: this.container,
602
+ title: options.previewTitle || `导出 ${label} SVG`,
603
+ initialTheme: options.theme || 'current',
604
+ createArtifact: (previewOptions) => this.exportSvg({ label, ...options, ...previewOptions, filename }),
605
+ onClose: () => { this._svgExportPreview = null; },
606
+ onDownload: (artifact) => {
607
+ this._setStatus(`SVG 已导出:${artifact.filename}`, 'ok');
608
+ options.onDownload?.(artifact);
609
+ },
610
+ });
611
+ return this._svgExportPreview;
612
+ }
547
613
 
548
614
  _setStatus(message, tone = 'ok') {
549
615
  if (!this._statusText || !this._statusDot) return;
@@ -583,7 +649,12 @@ export class BpmnStudioShell {
583
649
  selectionToolbar: options.selectionToolbar ?? this.slots.selectionToolbar ?? null,
584
650
  contextMenu: options.contextMenu ?? this.slots.contextMenu ?? null,
585
651
  contextMenuRegistry: options.contextMenuRegistry ?? this.contextMenuRegistry,
586
- rendererOptions: { iconRegistry: this.iconRegistry, ...this.rendererOptions, ...(options.rendererOptions || {}) },
652
+ rendererOptions: {
653
+ iconRegistry: this.iconRegistry,
654
+ ...this.rendererOptions,
655
+ svgExport: this.svgExport,
656
+ ...(options.rendererOptions || {}),
657
+ },
587
658
  themeController: this.themeController,
588
659
  });
589
660
  this._instances.push(instance); return instance;
@@ -597,6 +668,8 @@ export class BpmnStudioShell {
597
668
  instance.render(); this._instances.push(instance); return instance;
598
669
  }
599
670
  destroy() {
671
+ this._svgExportPreview?.close?.({ immediate: true });
672
+ this._svgExportPreview = null;
600
673
  this._cleanups.splice(0).reverse().forEach((cleanup) => cleanup?.());
601
674
  this._instances.splice(0).reverse().forEach((instance) => instance.destroy?.());
602
675
  this.container.innerHTML = '';
package/dist/styles.css CHANGED
@@ -1,4 +1,4 @@
1
- /* @bpmn-nova/theme */
1
+ /* @bpmn-nova/internal/theme */
2
2
  [data-nova-theme] {
3
3
  accent-color: var(--nova-color-primary);
4
4
  }
@@ -149,7 +149,7 @@
149
149
  --nova-tone-manual-task-strong: #dc876f;
150
150
  }
151
151
 
152
- /* @bpmn-nova/icons */
152
+ /* @bpmn-nova/internal/icons */
153
153
  .nova-icon { width: 16px; height: 16px; display: inline-grid; place-items: center; flex: none; color: currentColor; line-height: 0; transform-origin: 50% 50%; }
154
154
  .nova-icon.nova-icon-xs { width: 12px; height: 12px; }
155
155
  .nova-icon.nova-icon-sm { width: 14px; height: 14px; }
@@ -157,7 +157,97 @@
157
157
  .nova-icon > .nova-icon-svg { width: 100%; height: 100%; display: block; overflow: visible; fill: none; stroke: currentColor; vector-effect: non-scaling-stroke; }
158
158
  .nova-icon[data-icon-missing="true"] { visibility: hidden; }
159
159
 
160
- /* @bpmn-nova/renderer-svg */
160
+ /* @bpmn-nova/internal/export-svg */
161
+ .nova-svg-export-preview {
162
+ position: absolute;
163
+ inset: 0;
164
+ z-index: 140;
165
+ display: grid;
166
+ place-items: center;
167
+ padding: 24px;
168
+ box-sizing: border-box;
169
+ background: var(--nova-color-backdrop, rgba(20, 26, 39, .38));
170
+ color: var(--nova-color-text, #1f2430);
171
+ font-family: var(--nova-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif);
172
+ opacity: 1;
173
+ transition: opacity .18s ease;
174
+ }
175
+ .nova-svg-export-preview.is-entering,
176
+ .nova-svg-export-preview.is-closing { opacity: 0; }
177
+ .nova-svg-export-dialog {
178
+ width: min(1120px, 100%);
179
+ height: min(820px, 100%);
180
+ min-height: 420px;
181
+ display: grid;
182
+ grid-template-rows: auto auto minmax(0, 1fr) auto auto;
183
+ overflow: hidden;
184
+ border: 1px solid var(--nova-color-border, #e1e5ec);
185
+ border-radius: 16px;
186
+ background: var(--nova-color-surface, #fff);
187
+ box-shadow: var(--nova-shadow-lg, 0 18px 52px rgba(22, 29, 48, .2));
188
+ transform: translateY(0) scale(1);
189
+ transition: transform .2s cubic-bezier(.2, .8, .2, 1);
190
+ }
191
+ .nova-svg-export-preview.is-entering .nova-svg-export-dialog,
192
+ .nova-svg-export-preview.is-closing .nova-svg-export-dialog { transform: translateY(10px) scale(.985); }
193
+ .nova-svg-export-header,
194
+ .nova-svg-export-toolbar,
195
+ .nova-svg-export-footer { display: flex; align-items: center; gap: 12px; padding: 14px 18px; }
196
+ .nova-svg-export-header { justify-content: space-between; border-bottom: 1px solid var(--nova-color-divider, #e9ecf2); }
197
+ .nova-svg-export-heading { display: grid; gap: 3px; }
198
+ .nova-svg-export-heading strong { font-size: 17px; }
199
+ .nova-svg-export-heading span { color: var(--nova-color-text-muted); font-size: 12px; }
200
+ .nova-svg-export-close { width: 34px; height: 34px; border: 0; border-radius: 9px; background: transparent; color: var(--nova-color-text-muted); font-size: 24px; line-height: 1; cursor: pointer; }
201
+ .nova-svg-export-close:hover { background: var(--nova-color-surface-muted); color: var(--nova-color-text); }
202
+ .nova-svg-export-toolbar { justify-content: space-between; flex-wrap: wrap; background: var(--nova-color-surface-subtle); border-bottom: 1px solid var(--nova-color-divider); }
203
+ .nova-svg-export-theme,
204
+ .nova-svg-export-viewport-tools { display: inline-flex; align-items: center; gap: 3px; padding: 3px; border-radius: 9px; background: var(--nova-color-surface); border: 1px solid var(--nova-color-border); }
205
+ .nova-svg-export-theme button,
206
+ .nova-svg-export-viewport-tools button { min-height: 28px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--nova-color-text-secondary); cursor: pointer; }
207
+ .nova-svg-export-theme button.is-active { background: var(--nova-color-primary-soft); color: var(--nova-color-primary); font-weight: 650; }
208
+ .nova-svg-export-theme button:focus-visible,
209
+ .nova-svg-export-viewport-tools button:focus-visible,
210
+ .nova-svg-export-actions button:focus-visible,
211
+ .nova-svg-export-close:focus-visible,
212
+ .nova-svg-export-viewport:focus-visible { outline: 2px solid var(--nova-color-focus-ring); outline-offset: 2px; }
213
+ .nova-svg-export-transparent { display: inline-flex; align-items: center; gap: 7px; color: var(--nova-color-text-secondary); font-size: 12px; cursor: pointer; }
214
+ .nova-svg-export-viewport-tools span { min-width: 46px; text-align: center; color: var(--nova-color-text-muted); font-size: 11px; }
215
+ .nova-svg-export-body { position: relative; min-height: 0; overflow: hidden; background: var(--nova-color-canvas); }
216
+ .nova-svg-export-viewport { position: absolute; inset: 0; overflow: hidden; cursor: grab; touch-action: none; background-color: var(--nova-color-canvas); background-image: linear-gradient(45deg, color-mix(in srgb, var(--nova-color-border) 35%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in srgb, var(--nova-color-border) 35%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--nova-color-border) 35%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--nova-color-border) 35%, transparent) 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0; }
217
+ .nova-svg-export-viewport.is-dragging { cursor: grabbing; }
218
+ .nova-svg-export-stage { position: absolute; left: 0; top: 0; width: 1px; height: 1px; transform-origin: 0 0; will-change: transform; }
219
+ .nova-svg-export-image { display: block; max-width: none; user-select: none; pointer-events: none; box-shadow: 0 6px 24px rgba(0, 0, 0, .14); }
220
+ .nova-svg-export-state { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 12px; background: color-mix(in srgb, var(--nova-color-surface) 88%, transparent); color: var(--nova-color-text-secondary); }
221
+ .nova-svg-export-state[hidden] { display: none; }
222
+ .nova-svg-export-state.is-error { color: var(--nova-tone-danger-foreground); }
223
+ .nova-svg-export-spinner { width: 24px; height: 24px; border: 2px solid var(--nova-color-border); border-top-color: var(--nova-color-primary); border-radius: 50%; animation: nova-svg-export-spin .8s linear infinite; }
224
+ .nova-svg-export-state.is-error .nova-svg-export-spinner { display: none; }
225
+ @keyframes nova-svg-export-spin { to { transform: rotate(360deg); } }
226
+ .nova-svg-export-warnings { max-height: 110px; overflow: auto; padding: 10px 18px; border-top: 1px solid var(--nova-tone-warning-border); background: var(--nova-tone-warning-background); color: var(--nova-tone-warning-foreground); font-size: 11px; }
227
+ .nova-svg-export-warnings[hidden] { display: none; }
228
+ .nova-svg-export-warnings strong { display: block; margin-bottom: 4px; }
229
+ .nova-svg-export-warnings ul { margin: 0; padding-left: 18px; }
230
+ .nova-svg-export-footer { justify-content: space-between; border-top: 1px solid var(--nova-color-divider); }
231
+ .nova-svg-export-summary { min-width: 0; overflow: hidden; color: var(--nova-color-text-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
232
+ .nova-svg-export-actions { display: flex; gap: 8px; }
233
+ .nova-svg-export-actions button { min-height: 34px; padding: 0 14px; border: 1px solid var(--nova-color-border); border-radius: 9px; background: var(--nova-color-surface); color: var(--nova-color-text-secondary); cursor: pointer; }
234
+ .nova-svg-export-actions button.is-primary { border-color: var(--nova-color-primary); background: var(--nova-color-primary); color: var(--nova-color-text-inverse, #fff); }
235
+ .nova-svg-export-actions button:disabled { opacity: .48; cursor: not-allowed; }
236
+ @media (max-width: 720px) {
237
+ .nova-svg-export-preview { padding: 10px; }
238
+ .nova-svg-export-dialog { width: 100%; height: 100%; min-height: 0; border-radius: 12px; }
239
+ .nova-svg-export-toolbar { align-items: flex-start; }
240
+ .nova-svg-export-viewport-tools { order: 3; width: 100%; justify-content: center; }
241
+ .nova-svg-export-summary { display: none; }
242
+ .nova-svg-export-footer { justify-content: flex-end; }
243
+ }
244
+ @media (prefers-reduced-motion: reduce) {
245
+ .nova-svg-export-preview,
246
+ .nova-svg-export-dialog { transition: none; }
247
+ .nova-svg-export-spinner { animation: none; }
248
+ }
249
+
250
+ /* @bpmn-nova/internal/renderer-svg */
161
251
  .mb-diagram-host,
162
252
  .mb-runtime-timeline-host {
163
253
  --mb-primary: var(--nova-color-primary, #635bff);
@@ -898,7 +988,7 @@ button.mb-runtime-action-summary:hover, button.mb-runtime-action-summary:focus-v
898
988
  height: 100%;
899
989
  }
900
990
 
901
- /* @bpmn-nova/palette */
991
+ /* @bpmn-nova/internal/palette */
902
992
  .nova-palette { height: 100%; overflow: auto; padding: 10px; color: var(--nova-color-text, #243047); background: var(--nova-color-surface, #fff); }
903
993
  .nova-palette-section { border-bottom: 1px solid var(--nova-color-divider, #edf0f5); padding: 3px 0 8px; }
904
994
  .nova-palette-section-toggle { width: 100%; min-height: 34px; border: 0; background: transparent; display: grid; grid-template-columns: 16px 1fr auto; align-items: center; gap: 6px; color: inherit; cursor: pointer; text-align: left; }
@@ -917,7 +1007,7 @@ button.mb-runtime-action-summary:hover, button.mb-runtime-action-summary:focus-v
917
1007
  .nova-palette-item-copy strong { font-size: var(--nova-font-size-sm, 12px); line-height: 18px; font-weight: var(--nova-font-weight-semibold, 600); }
918
1008
  .nova-palette-item-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--nova-color-text-muted, #919aad); font-size: var(--nova-font-size-xs, 10px); line-height: 14px; }
919
1009
 
920
- /* @bpmn-nova/properties-renderer */
1010
+ /* @bpmn-nova/internal/properties-renderer */
921
1011
  .nova-properties { min-height: 100%; color: var(--nova-color-text); background: var(--nova-color-surface); color-scheme: inherit; }
922
1012
  .properties-head { position: sticky; top: 0; z-index: 5; padding: 15px 16px 12px; border-bottom: 1px solid var(--nova-color-divider, #e7eaf1); background: color-mix(in srgb, var(--nova-color-surface) 97%, transparent); backdrop-filter: blur(10px); }
923
1013
  .properties-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
@@ -1049,8 +1139,8 @@ textarea.property-control { resize: vertical; }
1049
1139
  .property-branch-target { min-width: 0; flex: 1; display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 5px; }
1050
1140
  .property-branch-target small { color: var(--nova-color-text-muted); font-size: var(--nova-font-size-xs, 10px); line-height: 14px; }.property-branch-target strong { overflow: hidden; color: var(--nova-color-text-secondary); font-size: var(--nova-font-size-sm, 12px); line-height: 18px; font-weight: var(--nova-font-weight-semibold, 600); text-overflow: ellipsis; white-space: nowrap; }.property-branch-target > span { color: var(--nova-color-primary); font-size: var(--nova-font-size-sm, 12px); }
1051
1141
 
1052
- /* @bpmn-nova/studio */
1053
- .nova-studio-shell { --nova-left-width: 244px; --nova-right-width: 360px; --nova-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif; --nova-font-size-xs: 10px; --nova-font-size-sm: 12px; --nova-font-size-md: 14px; --nova-font-size-lg: 16px; --nova-font-weight-regular: 400; --nova-font-weight-medium: 500; --nova-font-weight-semibold: 600; --nova-font-weight-bold: 700; width: 100%; height: 100%; min-height: 520px; overflow: hidden; display: grid; grid-template-rows: 58px minmax(0, 1fr); color: var(--nova-color-text, #263148); background: var(--nova-color-canvas, #f6f7fb); font-family: var(--nova-font-family); }
1142
+ /* @bpmn-nova/internal/studio */
1143
+ .nova-studio-shell { --nova-left-width: 244px; --nova-right-width: 360px; --nova-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif; --nova-font-size-xs: 10px; --nova-font-size-sm: 12px; --nova-font-size-md: 14px; --nova-font-size-lg: 16px; --nova-font-weight-regular: 400; --nova-font-weight-medium: 500; --nova-font-weight-semibold: 600; --nova-font-weight-bold: 700; position: relative; isolation: isolate; width: 100%; height: 100%; min-height: 520px; overflow: hidden; display: grid; grid-template-rows: 58px minmax(0, 1fr); color: var(--nova-color-text, #263148); background: var(--nova-color-canvas, #f6f7fb); font-family: var(--nova-font-family); }
1054
1144
  .nova-studio-header { min-width: 0; border-bottom: 1px solid var(--nova-color-divider, #e4e8f0); background: var(--nova-color-surface, #fff); display: grid; grid-template-columns: minmax(180px, auto) auto minmax(0, 1fr); align-items: center; gap: 18px; padding: 0 12px 0 14px; }
1055
1145
  .nova-studio-brand { min-width: 0; display: flex; align-items: center; gap: 10px; }
1056
1146
  .nova-studio-brand-mark { flex: none; width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: var(--nova-color-text-inverse, #fff); background: linear-gradient(145deg, var(--nova-color-primary, #5362da), var(--nova-color-primary-hover)); font-size: var(--nova-font-size-md); font-weight: var(--nova-font-weight-bold); box-shadow: var(--nova-shadow-sm); }
@@ -1076,6 +1166,17 @@ textarea.property-control { resize: vertical; }
1076
1166
  .nova-studio-tool.is-primary { border-color: var(--nova-color-primary); color: var(--nova-color-text-inverse, #fff); background: var(--nova-color-primary, #5361d6); }
1077
1167
  .nova-studio-tool.is-primary:hover { border-color: var(--nova-color-primary-hover); color: var(--nova-color-text-inverse, #fff); background: var(--nova-color-primary-hover); }
1078
1168
  .nova-studio-tool:disabled { opacity: .4; cursor: default; }
1169
+ .nova-studio-export-menu { position: relative; flex: none; }
1170
+ .nova-studio-export-menu > .nova-studio-tool { display: inline-flex; align-items: center; gap: 7px; }
1171
+ .nova-studio-export-popover { position: absolute; z-index: 90; top: calc(100% + 8px); right: 0; width: 220px; padding: 6px; border: 1px solid var(--nova-color-border); border-radius: 11px; background: var(--nova-color-surface-raised); box-shadow: var(--nova-shadow-md); }
1172
+ .nova-studio-export-popover.is-hidden { display: none; }
1173
+ .nova-studio-export-option { width: 100%; display: grid; grid-template-columns: 28px minmax(0, 1fr); grid-template-rows: auto auto; column-gap: 9px; padding: 9px; border: 0; border-radius: 8px; background: transparent; color: var(--nova-color-text); text-align: left; cursor: pointer; }
1174
+ .nova-studio-export-option:hover { background: var(--nova-color-surface-muted); }
1175
+ .nova-studio-export-option:focus-visible { outline: 2px solid var(--nova-color-focus-ring); outline-offset: -2px; }
1176
+ .nova-studio-export-option > .nova-icon { grid-row: 1 / span 2; align-self: center; color: var(--nova-color-primary); }
1177
+ .nova-studio-export-option strong { font-size: var(--nova-font-size-sm, 12px); }
1178
+ .nova-studio-export-option small { color: var(--nova-color-text-muted); font-size: var(--nova-font-size-xs, 10px); }
1179
+ .nova-studio-export-option.is-hidden { display: none; }
1079
1180
  .nova-studio-import-input { display: none; }
1080
1181
  .nova-studio-beautify-split { position: relative; flex: none; display: flex; }
1081
1182
  .nova-studio-beautify-split .nova-studio-beautify { border-radius: 7px 0 0 7px; }