@bpmn-nova/studio 0.3.2-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/README.md CHANGED
@@ -4,7 +4,7 @@ BPMN Nova 的 Vanilla JavaScript / TypeScript 完整入口,提供流程设计
4
4
 
5
5
  > **English summary:** The complete framework-neutral BPMN Nova package for process design, viewing, approval traces, themes, properties, and pure SVG export.
6
6
 
7
- > 当前版本为 `0.3.2-preview`。请使用 `@preview` 安装,并在生产接入前验证目标 BPMN XML 与引擎扩展。
7
+ > 当前版本为 `0.3.3-preview`。请使用 `@preview` 安装,并在生产接入前验证目标 BPMN XML 与引擎扩展。
8
8
 
9
9
  ![BPMN Nova 流程设计器](https://raw.githubusercontent.com/daxiangme/bpmn-nova/dev/docs/assets/bpmn-nova-designer.jpg)
10
10
 
@@ -85,6 +85,10 @@ const shell = createStudioShell({
85
85
  // 在 Nova Header 左侧挂载宿主的返回入口、业务图标、流程名称和类型。
86
86
  // 完整替换 Header 时改用 slots.header,并继续调用 actions。
87
87
  },
88
+ headerActions({ container, actions, getMode, subscribeMode }) {
89
+ // 替换默认“校验 / 导入 / 导出”,在这里挂载宿主的“校验 / 保存 / 发布”。
90
+ // 校验按钮调用 actions.validate();保存和发布继续调用宿主服务。
91
+ },
88
92
  },
89
93
  theme: 'auto',
90
94
  })
@@ -99,8 +103,67 @@ const xml = shell.actions.exportXml('flowable')
99
103
 
100
104
  `regions` 支持 `header`、`left`、`right`、`footer` 的 `default | hidden` 状态;隐藏区域不占布局轨道。`setRegions()` 原地更新默认 Shell,不重建 Controller 或 Canvas。`layout()` 是完整布局替换,不能与 `regions` 同时使用。
101
105
 
106
+ `headerActions` 只替换 Header 最右侧动作组,`headerStart` 可与其同时使用,完整 `header` 的优先级更高。未提供 `headerActions` 时仍显示默认“校验 / 导入 / 导出”。顶部不再重复显示最佳视图;底部缩放区和 `fitView()` Interface 保持不变。
107
+
108
+ `shell.actions.validate()` 作为 Header 动作,以 `toolbar` 来源先更新 Nova 默认状态栏、再发送 Validation Event,最后返回 issues;程序化调用 `shell.validate()` 使用 `api` 来源。`valid` 表示没有 error,warning 不默认阻止发布:
109
+
110
+ ```js
111
+ const offValidation = shell.subscribeValidation((event) => {
112
+ console.log(event.source, event.valid, event.errorCount, event.warningCount, event.issues)
113
+ })
114
+
115
+ shell.validate()
116
+ offValidation()
117
+ ```
118
+
119
+ 宿主发布按钮可以复用 Toolbar 来源的完整流程;仅 error 默认阻止发布:
120
+
121
+ ```js
122
+ async function publishProcess(actions) {
123
+ const issues = actions.validate()
124
+ if (issues.some((issue) => issue.level === 'error')) return
125
+ await publishXml(actions.exportXml())
126
+ }
127
+ ```
128
+
102
129
  `shell.actions` 只封装撤销、重做、布局、视图、校验和 BPMN/SVG 导入导出。宿主的保存草稿、发布、权限、文件选择和服务端事务不属于 Nova Actions。
103
130
 
131
+ ## Mode 与节点副标题投影
132
+
133
+ `design`、`viewer`、`instance` 分别表示流程设计、流程展示和审批轨迹。Shell 是唯一 Mode 状态源:
134
+
135
+ ```js
136
+ const unsubscribe = shell.subscribeMode((event) => {
137
+ console.log(event.mode, event.previousMode, event.source, event.allowedModes)
138
+ })
139
+
140
+ shell.getMode()
141
+ shell.setMode('viewer')
142
+ shell.setAllowedModes(['design', 'viewer'])
143
+ ```
144
+
145
+ `setMode()` 仅在实际成功切换时返回 `true`。`setAllowedModes()` 拒绝空数组、重复值和未知值;若移除当前 Mode,会回退到新数组首项并发送一次 `allowed-modes` 事件。切换只改变 Shell 展示,不进入 Model、History 或 Studio State。
146
+
147
+ 标准任务与容器卡片支持定义态副标题 Resolver:
148
+
149
+ ```js
150
+ const summaries = new Map()
151
+ const shell = createStudioShell({
152
+ container,
153
+ studio,
154
+ nodeSubtitleResolver({ node, mode, surface, defaultSubtitle }) {
155
+ return summaries.has(node.id) ? summaries.get(node.id) : undefined
156
+ },
157
+ })
158
+
159
+ summaries.set('ServiceTask_Archive', '归档到采购系统')
160
+ shell.refreshPresentation()
161
+ summaries.set('ServiceTask_Archive', null)
162
+ shell.refreshPresentation()
163
+ ```
164
+
165
+ `undefined` 保留默认副标题,`null` 移除副标题行,字符串(包括空字符串)作为覆盖值。Resolver 同步应用于 Design、Viewer 与标准 SVG 降级视觉;完整自定义 Renderer 优先,Instance 始终使用 Runtime Presentation。`refreshPresentation()` 不写 XML、不创建历史、不改变选择、Scope 或视口。
166
+
104
167
  ## 只读 Viewer
105
168
 
106
169
  ```js
package/dist/canvas.js CHANGED
@@ -35,7 +35,7 @@ function toolbarIcon(id) {
35
35
  }
36
36
 
37
37
  export class BpmnCanvas {
38
- constructor({ container, studio, rendererOptions = {}, interactions = null, selectionToolbar = null, contextMenu = null, contextMenuRegistry = createDefaultContextMenuRegistry(), pointerMode = 'select', themeController = null, theme = null, onThemeChange = null } = {}) {
38
+ constructor({ container, studio, rendererOptions = {}, nodeSubtitleResolver = rendererOptions.nodeSubtitleResolver, interactions = null, selectionToolbar = null, contextMenu = null, contextMenuRegistry = createDefaultContextMenuRegistry(), pointerMode = 'select', themeController = null, theme = null, onThemeChange = null } = {}) {
39
39
  if (!container) throw new Error('BpmnCanvas requires a container.');
40
40
  if (!studio) throw new Error('BpmnCanvas requires a studio controller.');
41
41
  this.container = container;
@@ -51,6 +51,7 @@ export class BpmnCanvas {
51
51
  const onViewportChange = rendererOptions.onViewportChange;
52
52
  this.renderer = new DiagramRenderer(container, {
53
53
  ...rendererOptions,
54
+ nodeSubtitleResolver,
54
55
  themeController,
55
56
  theme,
56
57
  onThemeChange,
@@ -905,6 +906,7 @@ export class BpmnCanvas {
905
906
  startConnect(nodeId) { this.renderer.closeTransientOverlays?.(); return this.studio.startConnect(nodeId); }
906
907
  editEdgeName(edgeId) { return this.renderer.editEdgeName?.(edgeId) || false; }
907
908
  fitView(padding, options) { this.renderer.fitView(padding, { minZoom: 0.7, ...(options || {}) }); }
909
+ refreshPresentation() { this.renderer.refreshPresentation(); }
908
910
  exportSvg(options) { return this.renderer.exportSvg(options); }
909
911
  openSvgExportPreview(options) { return this.renderer.openSvgExportPreview(options); }
910
912
  fitSelection(padding = 72) {
package/dist/index.d.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  import type { IconRegistry } from './modules/icons/index.js'
27
27
  import type { PaletteRegistry } from './modules/palette/index.js'
28
28
  import type { PropertiesRegistry } from './modules/properties/index.js'
29
+ import type { NodeSubtitleResolver } from './modules/node-presentation/index.js'
29
30
  import type { DiagramNodeRenderer, DiagramRendererOptions } from './modules/renderer-svg/index.js'
30
31
  import type { RuntimeTimelineSvgRenderer, SvgExportArtifact, SvgExportOptions, SvgExportPreviewController, SvgNodeRenderer } from './modules/export-svg/index.js'
31
32
  import type {
@@ -38,6 +39,8 @@ import type {
38
39
  RuntimeTransitionDetailsRenderer,
39
40
  } from './modules/viewer/index.js'
40
41
 
42
+ export type { NodeSubtitleResolver, NodeSubtitleResolverContext } from './modules/node-presentation/index.js'
43
+
41
44
  // Aggregate compatibility surface. Studio re-exports the package-level types,
42
45
  // and keeps these frequently consumed declarations visible at its own entry.
43
46
  export interface ScopedElement { scopeId?: string }
@@ -141,6 +144,7 @@ export interface ViewerOptions {
141
144
  iconRegistry?: IconRegistry
142
145
  nodeRenderers?: Record<string, Function>
143
146
  nodeRenderer?: Function
147
+ nodeSubtitleResolver?: NodeSubtitleResolver
144
148
  projection?: ViewerProjection
145
149
  responsive?: boolean
146
150
  timeline?: ViewerTimelineOptions
@@ -173,6 +177,7 @@ export class BpmnViewer {
173
177
  traceProjection: RuntimeTraceProjection | null
174
178
  setModel(model: ProcessModel): void
175
179
  setRuntime(runtime: ProcessInstanceSnapshot | null): void
180
+ refreshPresentation(): void
176
181
  setDisplayOptions(options: { timeline?: ViewerTimelineOptions; runtimeDetails?: RuntimeDetailsOptions; runtimeTraceOptions?: RuntimeTraceProjectionOptions; runtimeAssetResolver?: import('./modules/viewer/index.js').RuntimeAssetResolver | null }): void
177
182
  setProjection(projection: ViewerProjection): void
178
183
  setTheme(theme: NovaThemeInput): NovaThemeState
@@ -414,6 +419,7 @@ export interface BpmnCanvasOptions {
414
419
  container: HTMLElement
415
420
  studio: BpmnStudioController
416
421
  rendererOptions?: DiagramRendererOptions
422
+ nodeSubtitleResolver?: NodeSubtitleResolver
417
423
  interactions?: InteractionController | null
418
424
  selectionToolbar?: HTMLElement | SelectionToolbarSlot | null
419
425
  contextMenu?: HTMLElement | ContextMenuSlot | null
@@ -427,6 +433,7 @@ export class BpmnCanvas {
427
433
  clientToWorld(clientX: number, clientY: number): Point
428
434
  getViewportCenterWorld(): Point
429
435
  fitView(padding?: number, options?: Record<string, unknown>): void
436
+ refreshPresentation(): void
430
437
  fitSelection(padding?: number): boolean
431
438
  setPointerMode(mode: 'select' | 'marquee' | 'pan'): boolean
432
439
  getPointerMode(): 'select' | 'marquee' | 'pan'
@@ -458,6 +465,22 @@ export interface StudioShellActions {
458
465
  onDownload?: (artifact: SvgExportArtifact) => void
459
466
  }): SvgExportPreviewController | null
460
467
  }
468
+ export type StudioMode = 'design' | 'viewer' | 'instance'
469
+ export type StudioModeChangeSource = 'toolbar' | 'api' | 'allowed-modes'
470
+ export interface StudioModeChangeEvent {
471
+ readonly mode: StudioMode
472
+ readonly previousMode: StudioMode
473
+ readonly source: StudioModeChangeSource
474
+ readonly allowedModes: readonly StudioMode[]
475
+ }
476
+ export type StudioValidationSource = 'toolbar' | 'api'
477
+ export interface StudioValidationEvent {
478
+ readonly source: StudioValidationSource
479
+ readonly valid: boolean
480
+ readonly errorCount: number
481
+ readonly warningCount: number
482
+ readonly issues: readonly StudioValidationIssue[]
483
+ }
461
484
  export interface StudioShellSlotContext {
462
485
  studio: BpmnStudioController
463
486
  shell: BpmnStudioShell
@@ -465,6 +488,10 @@ export interface StudioShellSlotContext {
465
488
  actions: StudioShellActions
466
489
  getState(): StudioState
467
490
  subscribe(listener: (event: StudioEvent) => void): () => void
491
+ getMode(): StudioMode
492
+ getAllowedModes(): readonly StudioMode[]
493
+ subscribeMode(listener: (event: StudioModeChangeEvent) => void): () => void
494
+ subscribeValidation(listener: (event: StudioValidationEvent) => void): () => void
468
495
  }
469
496
  export type StudioShellDomSlot =
470
497
  | HTMLElement
@@ -473,6 +500,7 @@ export interface StudioShellSlots {
473
500
  left?: StudioShellDomSlot
474
501
  right?: StudioShellDomSlot
475
502
  headerStart?: StudioShellDomSlot
503
+ headerActions?: StudioShellDomSlot
476
504
  header?: StudioShellDomSlot
477
505
  footer?: StudioShellDomSlot
478
506
  contextMenu?: ContextMenuSlot
@@ -507,12 +535,13 @@ export interface StudioShellOptions {
507
535
  runtimeTraceOptions?: RuntimeTraceProjectionOptions
508
536
  timeline?: ViewerTimelineOptions
509
537
  }
538
+ nodeSubtitleResolver?: NodeSubtitleResolver
510
539
  slots?: StudioShellSlots
511
540
  regions?: StudioShellRegions
512
541
  layout?: (context: Record<string, unknown>) => void | (() => void)
513
542
  runtime?: ProcessInstanceSnapshot | null
514
- mode?: 'design' | 'viewer' | 'instance'
515
- allowedModes?: Array<'design' | 'viewer' | 'instance'>
543
+ mode?: StudioMode
544
+ allowedModes?: readonly StudioMode[]
516
545
  projection?: ViewerProjection
517
546
  responsive?: boolean
518
547
  projectionOptions?: Array<{ value: ViewerProjection; label: string }>
@@ -528,9 +557,14 @@ export class BpmnStudioShell {
528
557
  readonly actions: StudioShellActions
529
558
  canvas?: BpmnCanvas
530
559
  viewer?: BpmnViewer
560
+ getMode(): StudioMode
561
+ getAllowedModes(): readonly StudioMode[]
562
+ subscribeMode(listener: (event: StudioModeChangeEvent) => void): () => void
563
+ subscribeValidation(listener: (event: StudioValidationEvent) => void): () => void
564
+ setAllowedModes(modes: readonly StudioMode[]): readonly StudioMode[]
531
565
  getRegions(): Readonly<StudioShellRegionState>
532
566
  setRegions(regions: StudioShellRegions): Readonly<StudioShellRegionState>
533
- setMode(mode: 'design' | 'viewer' | 'instance'): void
567
+ setMode(mode: StudioMode): boolean
534
568
  setRuntime(runtime: ProcessInstanceSnapshot | null): void
535
569
  setProjection(projection: ViewerProjection): void
536
570
  setTheme(theme: NovaThemeInput): NovaThemeState
@@ -538,6 +572,8 @@ export class BpmnStudioShell {
538
572
  getThemeState(): NovaThemeState
539
573
  subscribeTheme(listener: (state: NovaThemeState) => void): () => void
540
574
  setRuntimeAppearance(options: RuntimeAppearanceOptions | null): void
575
+ refreshPresentation(): void
576
+ validate(): StudioValidationIssue[]
541
577
  fitView(): void
542
578
  zoomBy(factor: number): void
543
579
  exportSvg(options?: SvgExportOptions): Promise<SvgExportArtifact>
@@ -2,6 +2,7 @@ import type { BpmnNode, ProcessModel } from '../core/index.js'
2
2
  import type { IconRegistry, NodeVisualResolution } from '../icons/index.js'
3
3
  import type { NodeRuntimePresentation, ProcessInstanceSnapshot, RuntimeApprovalActionPresentation, RuntimePresentation, RuntimeAssetRef } from '../runtime/index.js'
4
4
  import type { NovaResolvedTheme, NovaThemeSnapshot, RuntimeAppearanceOptions, RuntimeAppearanceResolver, ThemeController } from '../theme/index.js'
5
+ import type { NodeSubtitleResolver } from '../node-presentation/index.js'
5
6
 
6
7
  export type SvgExportTheme = 'current' | NovaResolvedTheme
7
8
  export interface SvgExportOptions {
@@ -65,6 +66,7 @@ export interface DiagramSvgExportContext {
65
66
  nodeRenderers?: Record<string, SvgNodeRenderer>
66
67
  htmlNodeRenderers?: Record<string, Function>
67
68
  htmlNodeRenderer?: Function
69
+ nodeSubtitleResolver?: NodeSubtitleResolver
68
70
  mode?: 'design' | 'viewer' | 'instance'
69
71
  label?: string
70
72
  }
@@ -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
+ }
@@ -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> }
@@ -98,6 +100,7 @@ export class DiagramRenderer {
98
100
  setModel(model: ProcessModel): void
99
101
  setMode(mode: 'design' | 'viewer' | 'instance'): void
100
102
  setRuntime(runtime: ProcessInstanceSnapshot | null): void
103
+ refreshPresentation(): void
101
104
  setSelection(selection: ElementSelection | null): void
102
105
  setConnectingSource(nodeId: string | null): void
103
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 });