@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.
@@ -1,5 +1,6 @@
1
1
  import { NODE_DEFINITIONS, edgeWaypoints, roundedPath, routeRuntimeTransition, smoothPath } from '../core/index.js';
2
2
  import { createDefaultIconRegistry, resolveNodeVisual } from '../icons/index.js';
3
+ import { resolveNodeSubtitle, supportsNodeSubtitle } from '../node-presentation/index.js';
3
4
  import { createRuntimePresentation } from '../runtime/index.js';
4
5
  import { createRuntimeAppearance } from '../theme/index.js';
5
6
 
@@ -304,7 +305,7 @@ function nodeToneName(node) {
304
305
  return ({ userTask: 'user-task', serviceTask: 'service-task', scriptTask: 'script-task', businessRuleTask: 'rule-task', sendTask: 'message-task', receiveTask: 'message-task', manualTask: 'manual-task' })[node.type] || 'primary';
305
306
  }
306
307
 
307
- function renderDefaultNode({ document, group, node, definition, visual, presentation, theme, iconRegistry }) {
308
+ function renderDefaultNode({ document, group, node, definition, visual, presentation, subtitle: definitionSubtitle, theme, iconRegistry }) {
308
309
  const colors = theme.colors;
309
310
  const statusTone = tone(theme, presentation?.status === 'rejected' ? 'danger' : presentation?.status === 'active' ? 'primary' : presentation?.status === 'completed' ? 'success' : 'neutral');
310
311
  const typeTone = tone(theme, nodeToneName(node));
@@ -360,7 +361,10 @@ function renderDefaultNode({ document, group, node, definition, visual, presenta
360
361
  const iconY = y + (height - iconSize) / 2;
361
362
  const copyX = iconX + iconSize + 12;
362
363
  const title = node.name || definition.label;
363
- const subtitle = presentation?.actionSummary || presentation?.summary || (node.properties?.assignee || node.properties?.candidateGroups || node.properties?.candidateUsers || '');
364
+ const subtitle = definitionSubtitle === undefined
365
+ ? presentation?.actionSummary || presentation?.summary || (node.properties?.assignee || node.properties?.candidateGroups || node.properties?.candidateUsers || '')
366
+ : definitionSubtitle;
367
+ const hasSubtitleRow = subtitle !== null;
364
368
  const hasStatus = Boolean(presentation?.status && presentation.status !== 'idle');
365
369
  const label = hasStatus ? String(presentation.statusLabel || presentation.status) : '';
366
370
  const statusTypography = { fontSize: 10.5, fontWeight: 650, fontFamily: theme.fontFamily };
@@ -371,18 +375,24 @@ function renderDefaultNode({ document, group, node, definition, visual, presenta
371
375
  const titleTypography = { fontSize: 14, fontWeight: 650, fontFamily: theme.fontFamily };
372
376
  const subtitleTypography = { fontSize: 11.5, fontWeight: 400, fontFamily: theme.fontFamily };
373
377
  const fittedTitle = fitText(document, title, titleMaxWidth, titleTypography);
374
- const fittedSubtitle = fitText(document, subtitle, subtitleMaxWidth, subtitleTypography);
375
- const tooltip = [title, subtitle].filter(Boolean).join('\n');
378
+ const fittedSubtitle = hasSubtitleRow ? fitText(document, subtitle, subtitleMaxWidth, subtitleTypography) : '';
379
+ const tooltip = subtitle === null || subtitle === '' ? title : `${title}\n${subtitle}`;
380
+ group.setAttribute('role', 'group');
381
+ group.setAttribute('aria-label', tooltip);
376
382
  if (tooltip && (fittedTitle !== title || fittedSubtitle !== String(subtitle || ''))) group.appendChild(svgElement(document, 'title', {}, tooltip));
377
383
 
378
384
  const clipId = safeSvgId(node.id);
379
- const titleClip = appendClipPath(document, group, `nova-node-${clipId}-title-clip`, { x: copyX, y: y + 9, width: titleMaxWidth, height: 23 });
380
- const subtitleClip = appendClipPath(document, group, `nova-node-${clipId}-subtitle-clip`, { x: copyX, y: y + 33, width: subtitleMaxWidth, height: 21 });
385
+ const titleY = hasSubtitleRow ? y + 27 : y + height / 2 + 5;
386
+ const titleClipY = hasSubtitleRow ? y + 9 : y + (height - 23) / 2;
387
+ const titleClip = appendClipPath(document, group, `nova-node-${clipId}-title-clip`, { x: copyX, y: titleClipY, width: titleMaxWidth, height: 23 });
388
+ const subtitleClip = hasSubtitleRow
389
+ ? appendClipPath(document, group, `nova-node-${clipId}-subtitle-clip`, { x: copyX, y: y + 33, width: subtitleMaxWidth, height: 21 })
390
+ : null;
381
391
  group.appendChild(svgElement(document, 'rect', { x, y, width, height, rx: 12, fill: colors.surface, stroke: hasStatus ? statusTone.border : colors.border, 'stroke-width': presentation?.status === 'active' ? 2 : 1.2, filter: 'url(#nova-export-shadow)' }));
382
392
  group.appendChild(svgElement(document, 'rect', { x, y: y + 12, width: 3, height: Math.max(12, height - 24), rx: 1.5, fill: typeTone.strong }));
383
393
  group.appendChild(svgElement(document, 'rect', { x: iconX, y: iconY, width: iconSize, height: iconSize, rx: 9, fill: typeTone.background }));
384
394
  appendIcon(document, group, icon, { x: iconX + 8, y: iconY + 8, width: iconSize - 16, height: iconSize - 16 }, typeTone.foreground);
385
- if (fittedTitle) group.appendChild(svgElement(document, 'text', { x: copyX, y: y + 27, fill: colors.text, 'font-size': titleTypography.fontSize, 'font-weight': titleTypography.fontWeight, 'font-family': titleTypography.fontFamily, 'clip-path': titleClip }, fittedTitle));
395
+ if (fittedTitle) group.appendChild(svgElement(document, 'text', { x: copyX, y: titleY, fill: colors.text, 'font-size': titleTypography.fontSize, 'font-weight': titleTypography.fontWeight, 'font-family': titleTypography.fontFamily, 'clip-path': titleClip }, fittedTitle));
386
396
  if (fittedSubtitle) group.appendChild(svgElement(document, 'text', { x: copyX, y: y + 49, fill: colors.textSecondary, 'font-size': subtitleTypography.fontSize, 'font-family': subtitleTypography.fontFamily, 'clip-path': subtitleClip }, fittedSubtitle));
387
397
  if (hasStatus) {
388
398
  const fittedLabel = fitText(document, label, labelWidth - 16, statusTypography);
@@ -479,6 +489,8 @@ export async function exportDiagramSvg(context = {}, options = {}) {
479
489
  }
480
490
 
481
491
  const nodeLayer = svgElement(document, 'g', { 'data-layer': 'nodes' });
492
+ const runtimeMode = context.mode === 'instance' || Boolean(context.runtime);
493
+ const definitionMode = context.mode === 'viewer' ? 'viewer' : 'design';
482
494
  const order = { participant: 0, lane: 1, group: 2, container: 3, data: 4, dataStore: 4, annotation: 4, task: 5, gateway: 6, event: 7, boundary: 8 };
483
495
  const nodes = [...(model.nodes || [])].sort((a, b) => (order[NODE_DEFINITIONS[a.type]?.kind] ?? 5) - (order[NODE_DEFINITIONS[b.type]?.kind] ?? 5));
484
496
  for (const node of nodes) {
@@ -500,7 +512,19 @@ export async function exportDiagramSvg(context = {}, options = {}) {
500
512
  } else if (context.htmlNodeRenderers?.[node.type] || context.htmlNodeRenderers?.[definition.kind] || context.htmlNodeRenderer) {
501
513
  warnings.push({ code: 'custom-node-renderer-fallback', message: `节点“${node.name || node.id}”没有 SVG Renderer,已使用标准视觉。`, elementId: node.id });
502
514
  }
503
- if (!rendered) renderDefaultNode({ document, group, node, definition, visual, presentation: nodePresentation, theme, iconRegistry });
515
+ if (!rendered) {
516
+ const subtitle = !runtimeMode && supportsNodeSubtitle(definition)
517
+ ? resolveNodeSubtitle({
518
+ node,
519
+ definition,
520
+ model,
521
+ mode: definitionMode,
522
+ surface: 'svg-export',
523
+ resolver: context.nodeSubtitleResolver,
524
+ })
525
+ : undefined;
526
+ renderDefaultNode({ document, group, node, definition, visual, presentation: nodePresentation, subtitle, theme, iconRegistry });
527
+ }
504
528
  nodeLayer.appendChild(group);
505
529
  }
506
530
  svg.appendChild(nodeLayer);
@@ -0,0 +1,25 @@
1
+ import type { BpmnNode, NodeDefinition, ProcessModel } from '../core/index.js'
2
+
3
+ export interface NodeSubtitleResolverContext {
4
+ readonly node: BpmnNode
5
+ readonly definition: NodeDefinition
6
+ readonly model: ProcessModel
7
+ readonly mode: 'design' | 'viewer'
8
+ readonly surface: 'canvas' | 'svg-export'
9
+ readonly defaultSubtitle: string
10
+ }
11
+
12
+ export type NodeSubtitleResolver = (
13
+ context: Readonly<NodeSubtitleResolverContext>,
14
+ ) => string | null | undefined
15
+
16
+ export function resolveDefaultNodeSubtitle(node: BpmnNode): string
17
+ export function supportsNodeSubtitle(definition: NodeDefinition): boolean
18
+ export function resolveNodeSubtitle(context: {
19
+ node: BpmnNode
20
+ definition: NodeDefinition
21
+ model: ProcessModel
22
+ mode: 'design' | 'viewer'
23
+ surface: 'canvas' | 'svg-export'
24
+ resolver?: NodeSubtitleResolver | null
25
+ }): string | null
@@ -0,0 +1,51 @@
1
+ function resolverError(node, reason) {
2
+ const nodeId = node?.id || '<unknown>';
3
+ const detail = reason instanceof Error ? reason.message : String(reason);
4
+ const error = new Error(`Node subtitle resolver failed for node "${nodeId}": ${detail}`);
5
+ if (reason instanceof Error) error.cause = reason;
6
+ return error;
7
+ }
8
+
9
+ export function resolveDefaultNodeSubtitle(node) {
10
+ const properties = node?.properties || {};
11
+ if (node?.type === 'userTask') return properties.assignee || properties.candidateGroups || properties.candidateUsers || '待配置审批人';
12
+ if (['serviceTask', 'sendTask', 'businessRuleTask'].includes(node?.type)) return properties.implementation || '待配置执行实现';
13
+ if (node?.type === 'scriptTask') return properties.scriptFormat || 'Script';
14
+ if (node?.type === 'callActivity') return properties.calledElement || '待配置调用流程';
15
+ if (node?.type === 'receiveTask') return '等待消息或外部触发';
16
+ if (node?.type === 'manualTask') return '人工线下处理';
17
+ if (node?.type === 'subProcess') return '可折叠子流程';
18
+ if (node?.type === 'eventSubProcess') return '事件触发子流程';
19
+ if (node?.type === 'transaction') return '事务边界';
20
+ return node?.bpmnType?.replace('bpmn:', '') || '';
21
+ }
22
+
23
+ export function supportsNodeSubtitle(definition) {
24
+ return definition?.kind === 'task' || definition?.kind === 'container';
25
+ }
26
+
27
+ export function resolveNodeSubtitle({ node, definition, model, mode, surface, resolver }) {
28
+ const defaultSubtitle = resolveDefaultNodeSubtitle(node);
29
+ if (typeof resolver !== 'function') return defaultSubtitle;
30
+
31
+ let result;
32
+ try {
33
+ result = resolver(Object.freeze({
34
+ node,
35
+ definition,
36
+ model,
37
+ mode,
38
+ surface,
39
+ defaultSubtitle,
40
+ }));
41
+ } catch (error) {
42
+ throw resolverError(node, error);
43
+ }
44
+
45
+ if (result === undefined) return defaultSubtitle;
46
+ if (result === null || typeof result === 'string') return result;
47
+ if (result && typeof result.then === 'function') {
48
+ throw resolverError(node, 'Promise results are not supported; return a string, null, or undefined.');
49
+ }
50
+ throw resolverError(node, `invalid result type "${typeof result}"; return a string, null, or undefined.`);
51
+ }
@@ -39,6 +39,18 @@ export const defaultBpmnPaletteProvider: PaletteProvider
39
39
  export function createPaletteRegistry(): PaletteRegistry
40
40
  export function createDefaultPaletteRegistry(options?: { providers?: PaletteProvider[] }): PaletteRegistry
41
41
 
42
+ export type PaletteItemRenderer = (context: {
43
+ container: HTMLElement
44
+ item: PaletteItem
45
+ panel: PalettePanel
46
+ beginCreate(): unknown
47
+ }) => void | (() => void)
48
+ export type PaletteSectionRenderer = (context: {
49
+ container: HTMLElement
50
+ section: PaletteSection
51
+ panel: PalettePanel
52
+ }) => void | (() => void)
53
+
42
54
  export interface PalettePanelOptions {
43
55
  container: HTMLElement
44
56
  registry: PaletteRegistry
@@ -46,8 +58,8 @@ export interface PalettePanelOptions {
46
58
  interactions: { beginCreate(intent: PaletteCreateIntent): unknown; serialize(session: unknown): string }
47
59
  canvas?: unknown
48
60
  iconRegistry?: IconRegistry
49
- renderItem?: ((context: { item: PaletteItem; panel: PalettePanel }) => HTMLElement | null) | null
50
- renderSection?: ((context: { section: PaletteSection; panel: PalettePanel }) => HTMLElement | null) | null
61
+ renderItem?: PaletteItemRenderer | null
62
+ renderSection?: PaletteSectionRenderer | null
51
63
  themeController?: ThemeController | null
52
64
  theme?: NovaThemeInput | null
53
65
  onThemeChange?: ((state: NovaThemeState) => void) | null
@@ -56,10 +56,10 @@ export class PaletteRegistry {
56
56
  export const defaultBpmnPaletteProvider = {
57
57
  id: 'bpmn-default-palette',
58
58
  priority: 100,
59
- getSections() {
59
+ getSections(context = {}) {
60
60
  return PALETTE_GROUPS.map((group) => {
61
61
  const items = [];
62
- for (const nodeType of group.types) {
62
+ for (const nodeType of group.types.filter((type) => context.studio?.allowsNodeType?.(type) !== false)) {
63
63
  if (nodeType === 'parallelGateway') {
64
64
  items.push({
65
65
  id: 'node-parallelGateway',
@@ -85,7 +85,7 @@ export const defaultBpmnPaletteProvider = {
85
85
  });
86
86
  }
87
87
  return { id: group.id, label: group.label, collapsed: group.id !== 'favorites', items };
88
- });
88
+ }).filter((section) => section.items.length);
89
89
  },
90
90
  };
91
91
 
@@ -17,7 +17,9 @@ function nodeTypeOptions(context) {
17
17
  ? ['task', 'container']
18
18
  : [currentKind];
19
19
  return Object.entries(NODE_DEFINITIONS)
20
- .filter(([type, def]) => type !== 'generic' && compatible.includes(def.kind))
20
+ .filter(([type, def]) => type !== 'generic'
21
+ && compatible.includes(def.kind)
22
+ && context.studio?.allowsNodeType?.(type) !== false)
21
23
  .map(([value, def]) => ({ value, label: def.label }));
22
24
  }
23
25
 
@@ -3,6 +3,7 @@ import type { IconRegistry, NodeVisualResolution } from '../icons/index.js'
3
3
  import type { NodeRuntimePresentation, ProcessInstanceSnapshot, RuntimePresentation } from '../runtime/index.js'
4
4
  import type { NovaThemeInput, NovaThemeMode, NovaThemeState, RuntimeAppearanceResolver, ThemeController } from '../theme/index.js'
5
5
  import type { SvgExportArtifact, SvgExportOptions, SvgExportPreviewController, SvgNodeRenderer } from '../export-svg/index.js'
6
+ import type { NodeSubtitleResolver } from '../node-presentation/index.js'
6
7
 
7
8
  export interface SceneBounds {
8
9
  left: number
@@ -73,6 +74,7 @@ export interface DiagramRendererOptions {
73
74
  iconRegistry?: IconRegistry
74
75
  nodeRenderers?: Record<string, DiagramNodeRenderer>
75
76
  nodeRenderer?: DiagramNodeRenderer
77
+ nodeSubtitleResolver?: NodeSubtitleResolver
76
78
  runtimePresenter?: (context: { model: ProcessModel; runtime: ProcessInstanceSnapshot | null; appearance: RuntimeAppearanceResolver | null }) => RuntimePresentation
77
79
  runtimeAppearance?: RuntimeAppearanceResolver | null
78
80
  svgExport?: SvgExportOptions & { label?: string; nodeRenderers?: Record<string, SvgNodeRenderer> }
@@ -84,6 +86,7 @@ export interface DiagramRendererOptions {
84
86
  onEdgeClick?: (edge: BpmnEdge, event: Event) => void
85
87
  onCanvasClick?: (event: Event) => void
86
88
  onViewportChange?: (state: { zoom: number; pan: Point }) => void
89
+ canCreateNodeType?: (nodeType: string) => boolean
87
90
  [key: string]: unknown
88
91
  }
89
92
  export class DiagramRenderer {
@@ -97,6 +100,7 @@ export class DiagramRenderer {
97
100
  setModel(model: ProcessModel): void
98
101
  setMode(mode: 'design' | 'viewer' | 'instance'): void
99
102
  setRuntime(runtime: ProcessInstanceSnapshot | null): void
103
+ refreshPresentation(): void
100
104
  setSelection(selection: ElementSelection | null): void
101
105
  setConnectingSource(nodeId: string | null): void
102
106
  setSpacePressed(active: boolean): void
@@ -1,6 +1,7 @@
1
1
  import { NODE_DEFINITIONS, PALETTE_GROUPS, PARALLEL_GATEWAY_PRESETS, edgeWaypoints, resolveGatewayRole, resolveSwimlaneLabelPlacement, roundedPath, routeRuntimeTransition, smoothPath } from '../core/index.js';
2
2
  import { createRuntimePresentation } from '../runtime/index.js';
3
3
  import { createDefaultIconRegistry, createIconElement, resolveNodeVisual } from '../icons/index.js';
4
+ import { resolveNodeSubtitle, supportsNodeSubtitle } from '../node-presentation/index.js';
4
5
  import { ThemeController, applyRuntimeTone } from '../theme/index.js';
5
6
  import { exportDiagramSvg, openSvgExportPreview } from '../export-svg/index.js';
6
7
 
@@ -16,6 +17,15 @@ function quantizeZoom(value, step = 0.05) {
16
17
  return Math.round(Math.round(clamped / step) * step * 1000) / 1000;
17
18
  }
18
19
 
20
+ function eventShapeDiameter(node, definition) {
21
+ const maximum = definition.kind === 'boundary' ? 40 : 46;
22
+ const width = Number(node?.width);
23
+ const height = Number(node?.height);
24
+ const availableWidth = Number.isFinite(width) ? Math.max(0, width) : maximum;
25
+ const availableHeight = Number.isFinite(height) ? Math.max(0, height) : maximum;
26
+ return Math.min(maximum, availableWidth, availableHeight);
27
+ }
28
+
19
29
  function estimateLabelTextWidth(value) {
20
30
  let width = 0;
21
31
  for (const ch of Array.from(String(value ?? ''))) {
@@ -262,18 +272,8 @@ function routePathData(points, routeStyle = 'rounded', cornerRadius = 14) {
262
272
  return roundedPath(points, cornerRadius);
263
273
  }
264
274
 
265
- function displaySubtitle(node) {
266
- const p = node.properties || {};
267
- if (node.type === 'userTask') return p.assignee || p.candidateGroups || p.candidateUsers || '待配置审批人';
268
- if (['serviceTask', 'sendTask', 'businessRuleTask'].includes(node.type)) return p.implementation || '待配置执行实现';
269
- if (node.type === 'scriptTask') return p.scriptFormat || 'Script';
270
- if (node.type === 'callActivity') return p.calledElement || '待配置调用流程';
271
- if (node.type === 'receiveTask') return '等待消息或外部触发';
272
- if (node.type === 'manualTask') return '人工线下处理';
273
- if (node.type === 'subProcess') return '可折叠子流程';
274
- if (node.type === 'eventSubProcess') return '事件触发子流程';
275
- if (node.type === 'transaction') return '事务边界';
276
- return node.bpmnType?.replace('bpmn:', '') || '';
275
+ function nodeAccessibleLabel(title, subtitle) {
276
+ return subtitle === null || subtitle === '' ? title : `${title}\n${subtitle}`;
277
277
  }
278
278
 
279
279
  function runtimeStatusIconId(presentation) {
@@ -599,6 +599,23 @@ export class DiagramRenderer {
599
599
  setModel(model) { this.model = model; this.render(); }
600
600
  setMode(mode) { this.mode = mode; this.render(); }
601
601
  setRuntime(runtime) { this.runtime = runtime; this.render(); }
602
+ refreshPresentation() {
603
+ if (this.mode === 'instance') return;
604
+ const subtitles = this._resolveDefinitionSubtitles();
605
+ const nodeElements = new Map(
606
+ [...this.nodeLayer.querySelectorAll('[data-node-id]')]
607
+ .map((nodeEl) => [nodeEl.dataset.nodeId, nodeEl]),
608
+ );
609
+ const updates = [];
610
+ for (const node of this.model.nodes) {
611
+ if (!subtitles.has(node.id)) continue;
612
+ const nodeEl = nodeElements.get(node.id);
613
+ if (!nodeEl || nodeEl.dataset.nodePresentation !== 'standard') continue;
614
+ const definition = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
615
+ updates.push({ nodeEl, node, definition, subtitle: subtitles.get(node.id) });
616
+ }
617
+ updates.forEach((update) => this._applyDefinitionPresentation(update));
618
+ }
602
619
  setSelection(selection) {
603
620
  this.selection = selection;
604
621
  if (selection?.kind !== 'node' || selection.id !== this.quickMenuNodeId) this.quickMenuNodeId = null;
@@ -645,6 +662,7 @@ export class DiagramRenderer {
645
662
  nodeRenderers: options.nodeRenderers || this.svgExportOptions.nodeRenderers,
646
663
  htmlNodeRenderers: this.options.nodeRenderers,
647
664
  htmlNodeRenderer: this.options.nodeRenderer,
665
+ nodeSubtitleResolver: this.options.nodeSubtitleResolver,
648
666
  mode: this.mode,
649
667
  label: options.label || this.svgExportOptions.label,
650
668
  }, { ...this.svgExportOptions, ...options });
@@ -748,16 +766,38 @@ export class DiagramRenderer {
748
766
  this.runtimePresentation = this.runtime
749
767
  ? presenter({ model: this.model, runtime: this.runtime, appearance: this.options.runtimeAppearance })
750
768
  : createRuntimePresentation({ model: this.model, runtime: null, appearance: this.options.runtimeAppearance });
769
+ const subtitles = this._resolveDefinitionSubtitles();
751
770
  this._syncSceneBounds();
752
771
  this._renderEdges();
753
772
  this._renderRuntimeTransitions();
754
773
  this._renderGuides();
755
- this._renderNodes();
774
+ this._renderNodes(subtitles);
756
775
  this._applyTransform();
757
776
  this.container.dataset.mode = this.mode;
758
777
  if (this.emptyState) this.emptyState.hidden = this.model.nodes.length > 0 || !this.options.showEmptyState;
759
778
  }
760
779
 
780
+ _resolveDefinitionSubtitles() {
781
+ const subtitles = new Map();
782
+ if (this.mode === 'instance') return subtitles;
783
+ const mode = this.mode === 'viewer' ? 'viewer' : 'design';
784
+ for (const node of this.model.nodes) {
785
+ const definition = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
786
+ if (!supportsNodeSubtitle(definition)) continue;
787
+ const renderers = this.options.nodeRenderers || {};
788
+ if (typeof (renderers[node.type] || renderers[definition.kind] || this.options.nodeRenderer) === 'function') continue;
789
+ subtitles.set(node.id, resolveNodeSubtitle({
790
+ node,
791
+ definition,
792
+ model: this.model,
793
+ mode,
794
+ surface: 'canvas',
795
+ resolver: this.options.nodeSubtitleResolver,
796
+ }));
797
+ }
798
+ return subtitles;
799
+ }
800
+
761
801
  _renderGuides() {
762
802
  this.guideSvg.replaceChildren();
763
803
  if (this.model.settings?.alignmentGuides === false) return;
@@ -977,7 +1017,7 @@ export class DiagramRenderer {
977
1017
  requestAnimationFrame(() => { input.focus(); input.select(); });
978
1018
  }
979
1019
 
980
- _renderNodes() {
1020
+ _renderNodes(subtitles = new Map()) {
981
1021
  this._nodeContentCleanups.splice(0).forEach((cleanup) => cleanup?.());
982
1022
  this.nodeLayer.innerHTML = '';
983
1023
  const zOrder = { participant: 0, lane: 1, group: 2, container: 3, data: 4, dataStore: 4, annotation: 4, task: 5, gateway: 6, event: 7, boundary: 8 };
@@ -1018,7 +1058,7 @@ export class DiagramRenderer {
1018
1058
  nodeEl.tabIndex = 0;
1019
1059
 
1020
1060
  const visual = this._resolveNodeVisual(node);
1021
- this._renderNodeShape(nodeEl, node, def, runtimeState, visual);
1061
+ this._renderNodeShape(nodeEl, node, def, runtimeState, visual, subtitles.get(node.id));
1022
1062
  if (['task', 'container'].includes(def.kind)) addActivityMarkers(nodeEl, visual, this.iconRegistry);
1023
1063
 
1024
1064
  if (this.mode === 'design') this._renderDesignControls(nodeEl, node, def, selected && this.selection?.kind !== 'multi' && this.interactionMode !== 'pan');
@@ -1048,9 +1088,10 @@ export class DiagramRenderer {
1048
1088
  if (this.mode === 'design' && this.quickMenuNodeId) this._renderQuickMenu();
1049
1089
  }
1050
1090
 
1051
- _renderNodeShape(nodeEl, node, def, runtimeState, visual) {
1091
+ _renderNodeShape(nodeEl, node, def, runtimeState, visual, subtitle) {
1052
1092
  if (def.kind === 'event' || def.kind === 'boundary') {
1053
1093
  const shape = el('div', 'mb-event-shape');
1094
+ shape.style.setProperty('--mb-event-shape-size', `${eventShapeDiameter(node, def)}px`);
1054
1095
  shape.dataset.stage = def.eventStage || 'intermediate';
1055
1096
  shape.dataset.role = def.eventRole || '';
1056
1097
  if (def.kind === 'boundary' && node.properties?.cancelActivity === false) shape.classList.add('is-noninterrupting');
@@ -1136,6 +1177,7 @@ export class DiagramRenderer {
1136
1177
  nodeEl.appendChild(customHost);
1137
1178
  return;
1138
1179
  }
1180
+ nodeEl.dataset.nodePresentation = 'standard';
1139
1181
  const header = el('div', 'mb-node-header');
1140
1182
  const icon = visual.iconId ? el('span', 'mb-node-icon') : null;
1141
1183
  const iconNode = visual.iconId
@@ -1185,14 +1227,33 @@ export class DiagramRenderer {
1185
1227
  titleWrap.appendChild(action);
1186
1228
  }
1187
1229
  } else {
1188
- titleWrap.appendChild(el('div', 'mb-node-title', node.name || def.label));
1189
- titleWrap.appendChild(el('div', 'mb-node-subtitle', displaySubtitle(node)));
1230
+ const title = node.name || def.label;
1231
+ titleWrap.appendChild(el('div', 'mb-node-title', title));
1232
+ if (subtitle !== null) titleWrap.appendChild(el('div', 'mb-node-subtitle', subtitle));
1233
+ const label = nodeAccessibleLabel(title, subtitle);
1234
+ nodeEl.title = label;
1235
+ nodeEl.setAttribute('aria-label', label);
1190
1236
  }
1191
1237
  if (icon) header.append(icon);
1192
1238
  header.append(titleWrap);
1193
1239
  nodeEl.appendChild(header);
1194
1240
  }
1195
1241
 
1242
+ _applyDefinitionPresentation({ nodeEl, node, definition, subtitle }) {
1243
+ const title = node.name || definition.label;
1244
+ const titleWrap = nodeEl.querySelector('.mb-node-title-wrap');
1245
+ const titleEl = titleWrap?.querySelector('.mb-node-title');
1246
+ if (!titleWrap || !titleEl) return;
1247
+ titleEl.textContent = title;
1248
+ const currentSubtitle = titleWrap.querySelector('.mb-node-subtitle');
1249
+ if (subtitle === null) currentSubtitle?.remove();
1250
+ else if (currentSubtitle) currentSubtitle.textContent = subtitle;
1251
+ else titleWrap.appendChild(el('div', 'mb-node-subtitle', subtitle));
1252
+ const label = nodeAccessibleLabel(title, subtitle);
1253
+ nodeEl.title = label;
1254
+ nodeEl.setAttribute('aria-label', label);
1255
+ }
1256
+
1196
1257
  _resolveNodeVisual(node, surface = this.mode === 'instance' ? 'viewer' : 'canvas') {
1197
1258
  const visualModel = this.options.getVisualModel?.() || this.options.visualModel || this.model;
1198
1259
  return resolveNodeVisual(node, { surface, model: visualModel });
@@ -1424,7 +1485,7 @@ export class DiagramRenderer {
1424
1485
  const type = quickItem.nodeType;
1425
1486
  if (type === 'startEvent') continue;
1426
1487
  const def = NODE_DEFINITIONS[type];
1427
- if (!def) continue;
1488
+ if (!def || this.options.canCreateNodeType?.(type) === false) continue;
1428
1489
  const item = el('button', 'mb-quick-menu-item');
1429
1490
  item.dataset.search = `${quickItem.id} ${type} ${quickItem.label}`.toLowerCase();
1430
1491
  const miniIcon = el('span', `mb-mini-type mb-mini-kind-${def.kind}`);
@@ -20,6 +20,7 @@ import type {
20
20
  RuntimeAppearanceResolver,
21
21
  } from '../theme/index.js'
22
22
  import type { RuntimeTimelineSvgRenderer, SvgExportArtifact, SvgExportOptions, SvgExportPreviewController, SvgNodeRenderer } from '../export-svg/index.js'
23
+ import type { NodeSubtitleResolver } from '../node-presentation/index.js'
23
24
 
24
25
  export type ViewerProjection = 'auto' | 'standard' | 'approval' | 'compact'
25
26
  export interface RuntimeTraceItem {
@@ -200,6 +201,7 @@ export interface ViewerOptions {
200
201
  iconRegistry?: IconRegistry
201
202
  nodeRenderers?: Record<string, Function>
202
203
  nodeRenderer?: Function
204
+ nodeSubtitleResolver?: NodeSubtitleResolver
203
205
  projection?: ViewerProjection
204
206
  responsive?: boolean
205
207
  runtimeTraceOptions?: RuntimeTraceProjectionOptions
@@ -245,6 +247,7 @@ export class BpmnViewer {
245
247
  traceProjection: RuntimeTraceProjection | null
246
248
  setModel(model: ProcessModel): void
247
249
  setRuntime(runtime: ProcessInstanceSnapshot | null): void
250
+ refreshPresentation(): void
248
251
  setDisplayOptions(options: { timeline?: ViewerTimelineOptions; runtimeDetails?: RuntimeDetailsOptions; runtimeTraceOptions?: RuntimeTraceProjectionOptions; runtimeAssetResolver?: RuntimeAssetResolver | null }): void
249
252
  setProjection(projection: ViewerProjection): void
250
253
  setTheme(theme: NovaThemeInput): NovaThemeState
@@ -367,7 +367,7 @@ export class BpmnViewer {
367
367
  this.responsive = options.responsive === true;
368
368
  this.projection = options.projection || (this.runtime && this.responsive ? 'auto' : this.runtime ? 'approval' : 'standard');
369
369
  this.activeProjection = 'standard';
370
- this.mode = this.runtime ? 'instance' : 'viewer';
370
+ this.mode = options.mode === 'instance' || this.runtime ? 'instance' : 'viewer';
371
371
  this.onElementClick = options.onElementClick || (() => {});
372
372
  this.onTraceClick = options.onTraceClick || (() => {});
373
373
  this.runtimePresenter = options.runtimePresenter || ((context) => createRuntimePresentation(context));
@@ -414,8 +414,15 @@ export class BpmnViewer {
414
414
  };
415
415
  document.addEventListener('pointerdown', this._outsideHandler, true);
416
416
  document.addEventListener('keydown', this._keyHandler);
417
- this._rebuildProjection();
418
- this._mountRenderer();
417
+ try {
418
+ this._rebuildProjection();
419
+ this._mountRenderer();
420
+ } catch (error) {
421
+ document.removeEventListener('pointerdown', this._outsideHandler, true);
422
+ document.removeEventListener('keydown', this._keyHandler);
423
+ if (this._ownsThemeController) this.themeController.destroy();
424
+ throw error;
425
+ }
419
426
  this._resizeObserver = typeof ResizeObserver === 'function'
420
427
  ? new ResizeObserver(() => {
421
428
  if (this.projection !== 'auto') return;
@@ -576,6 +583,7 @@ export class BpmnViewer {
576
583
  iconRegistry: this.options.iconRegistry,
577
584
  nodeRenderers: this.options.nodeRenderers,
578
585
  nodeRenderer: this.options.nodeRenderer,
586
+ nodeSubtitleResolver: this.options.nodeSubtitleResolver,
579
587
  svgExport: this.svgExportOptions,
580
588
  runtimeAppearance: this.runtimeAppearance,
581
589
  onNodeClick: (node, event) => this._select('node', node, event),
@@ -918,7 +926,12 @@ export class BpmnViewer {
918
926
  }
919
927
 
920
928
  setModel(model) { this.model = model; this._svgExportAssetCache.clear(); this.refresh(); }
921
- setRuntime(runtime) { this.runtime = runtime ? normalizeRuntime(runtime) : null; this._svgExportAssetCache.clear(); this.refresh(); }
929
+ setRuntime(runtime) {
930
+ this.runtime = runtime ? normalizeRuntime(runtime) : null;
931
+ this.mode = this.options.mode === 'instance' || this.runtime ? 'instance' : 'viewer';
932
+ this._svgExportAssetCache.clear();
933
+ this.refresh();
934
+ }
922
935
  setProjection(projection) {
923
936
  if (!['auto', 'standard', 'approval', 'compact'].includes(projection)) return;
924
937
  this.projection = projection;
@@ -954,6 +967,10 @@ export class BpmnViewer {
954
967
  this.runtimeAppearance = createRuntimeAppearance(this.runtimeAppearanceOptions);
955
968
  this.refresh();
956
969
  }
970
+ refreshPresentation() {
971
+ if (this.mode !== 'viewer' || (this.runtime && this.activeProjection === 'compact')) return;
972
+ this.renderer?.refreshPresentation?.();
973
+ }
957
974
  exportSvg(options = {}) {
958
975
  const isTimeline = Boolean(this.runtime && this.activeProjection === 'compact' && this.traceProjection);
959
976
  const label = isTimeline ? '移动时间线' : this.activeProjection === 'approval' ? '实际路径' : this.runtime ? '完整 BPMN' : '流程展示';