@bpmn-nova/studio 0.3.4-preview → 0.3.6-preview

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -178,7 +178,7 @@ export class BpmnViewer {
178
178
  setModel(model: ProcessModel): void
179
179
  setRuntime(runtime: ProcessInstanceSnapshot | null): void
180
180
  refreshPresentation(): void
181
- setDisplayOptions(options: { timeline?: ViewerTimelineOptions; runtimeDetails?: RuntimeDetailsOptions; runtimeTraceOptions?: RuntimeTraceProjectionOptions; runtimeAssetResolver?: import('./modules/viewer/index.js').RuntimeAssetResolver | null }): void
181
+ setDisplayOptions(options: { timeline?: ViewerTimelineOptions; runtimeDetails?: RuntimeDetailsOptions; runtimeTraceOptions?: RuntimeTraceProjectionOptions; runtimeAssetResolver?: import('./modules/viewer/index.js').RuntimeAssetResolver | null; replace?: boolean }): void
182
182
  setProjection(projection: ViewerProjection): void
183
183
  setTheme(theme: NovaThemeInput): NovaThemeState
184
184
  setThemeMode(mode: NovaThemeMode): NovaThemeState
@@ -231,12 +231,64 @@ export interface StudioExtension {
231
231
  id: string
232
232
  setup(context: { studio: BpmnStudioController }): void | (() => void)
233
233
  }
234
+ export type StudioControlSize = 'small' | 'medium' | 'large' | `${number}px`
235
+ export interface StudioModelingConfig {
236
+ propertiesProfile?: 'business' | 'developer'
237
+ allowedNodeTypes?: readonly NodeType[]
238
+ allowedEdgeTypes?: readonly EdgeType[]
239
+ }
240
+ export interface StudioSidebarConfig {
241
+ /** Defaults to true. Disabling collapse keeps an available sidebar expanded. */
242
+ collapsible?: boolean
243
+ /** Initial wide-container state only; later config updates do not reset user choices. */
244
+ defaultCollapsed?: boolean
245
+ }
246
+ export interface StudioRightPanelConfig extends StudioSidebarConfig {
247
+ /** Defaults to flex. In scroll mode Nova scrolls the entire supplied content. */
248
+ layout?: 'flex' | 'scroll'
249
+ }
250
+ export interface StudioUiConfig {
251
+ controlSize?: StudioControlSize
252
+ regions?: StudioShellRegions
253
+ leftPanel?: StudioSidebarConfig
254
+ rightPanel?: StudioRightPanelConfig
255
+ sidebarWidth?: {
256
+ left?: number
257
+ right?: number
258
+ }
259
+ }
260
+ export interface StudioViewerConfig {
261
+ responsive?: boolean
262
+ projectionOptions?: readonly { value: ViewerProjection; label: string }[]
263
+ timeline?: ViewerTimelineOptions
264
+ runtimeDetails?: RuntimeDetailsOptions
265
+ runtimeTraceOptions?: RuntimeTraceProjectionOptions
266
+ }
267
+ export type StudioSvgExportOptions = SvgExportOptions & {
268
+ label?: string
269
+ nodeRenderers?: Record<string, SvgNodeRenderer>
270
+ runtimeTimelineRenderer?: RuntimeTimelineSvgRenderer
271
+ }
272
+ export interface BpmnStudioConfig {
273
+ modeling?: StudioModelingConfig
274
+ ui?: StudioUiConfig
275
+ viewer?: StudioViewerConfig
276
+ export?: StudioSvgExportOptions
277
+ }
278
+ export interface StudioUiContext {
279
+ readonly controlSize: StudioControlSize
280
+ readonly controlHeight: `${number}px`
281
+ }
234
282
  export interface StudioControllerOptions {
235
283
  model: ProcessModel
236
284
  historyLimit?: number
237
285
  extensions?: StudioExtension[]
286
+ config?: BpmnStudioConfig
287
+ /** @deprecated Use config.modeling.propertiesProfile. */
238
288
  propertiesProfile?: 'business' | 'developer'
289
+ /** @deprecated Use config.modeling.allowedNodeTypes. */
239
290
  allowedNodeTypes?: Iterable<NodeType> | null
291
+ /** @deprecated Use config.modeling.allowedEdgeTypes. */
240
292
  allowedEdgeTypes?: Iterable<EdgeType> | null
241
293
  }
242
294
  export interface StudioCommands {
@@ -466,6 +518,25 @@ export interface StudioShellActions {
466
518
  }): SvgExportPreviewController | null
467
519
  }
468
520
  export type StudioMode = 'design' | 'viewer' | 'instance'
521
+ export type StudioSidebarSide = 'left' | 'right'
522
+ export type StudioSidebarState = Readonly<Record<StudioSidebarSide, {
523
+ readonly collapsed: boolean
524
+ readonly hidden: boolean
525
+ readonly collapsible: boolean
526
+ }>>
527
+ export type StudioSidebarChangeSource = 'button' | 'api' | 'responsive' | 'config'
528
+ export interface StudioSidebarChangeEvent {
529
+ readonly side: StudioSidebarSide
530
+ readonly collapsed: boolean
531
+ readonly previousCollapsed: boolean
532
+ readonly mode: StudioMode
533
+ readonly source: StudioSidebarChangeSource
534
+ }
535
+ export interface StudioPanelSelection {
536
+ readonly selection: ElementSelection | null
537
+ readonly selectedElement: StudioState['selectedElement']
538
+ readonly trace: RuntimeTraceClickEvent | null
539
+ }
469
540
  export type StudioModeChangeSource = 'toolbar' | 'api' | 'allowed-modes'
470
541
  export interface StudioModeChangeEvent {
471
542
  readonly mode: StudioMode
@@ -486,12 +557,18 @@ export interface StudioShellSlotContext {
486
557
  shell: BpmnStudioShell
487
558
  canvas: BpmnCanvas
488
559
  actions: StudioShellActions
560
+ readonly ui: StudioUiContext
489
561
  getState(): StudioState
490
562
  subscribe(listener: (event: StudioEvent) => void): () => void
491
563
  getMode(): StudioMode
492
564
  getAllowedModes(): readonly StudioMode[]
493
565
  subscribeMode(listener: (event: StudioModeChangeEvent) => void): () => void
494
566
  subscribeValidation(listener: (event: StudioValidationEvent) => void): () => void
567
+ getSidebarState(): StudioSidebarState
568
+ setSidebarCollapsed(side: StudioSidebarSide, collapsed: boolean): boolean
569
+ subscribeSidebarChange(listener: (event: StudioSidebarChangeEvent) => void): () => void
570
+ getPanelSelection(): StudioPanelSelection
571
+ subscribePanelSelection(listener: (selection: StudioPanelSelection) => void): () => void
495
572
  }
496
573
  export type StudioShellDomSlot =
497
574
  | HTMLElement
@@ -526,6 +603,7 @@ export interface StudioShellRegionState {
526
603
  export interface StudioShellOptions {
527
604
  container: HTMLElement
528
605
  studio: BpmnStudioController
606
+ config?: BpmnStudioConfig
529
607
  iconRegistry?: IconRegistry
530
608
  paletteRegistry?: PaletteRegistry
531
609
  propertiesRegistry?: PropertiesRegistry
@@ -534,33 +612,54 @@ export interface StudioShellOptions {
534
612
  rendererOptions?: DiagramRendererOptions & {
535
613
  runtimeTraceOptions?: RuntimeTraceProjectionOptions
536
614
  timeline?: ViewerTimelineOptions
615
+ runtimeDetails?: RuntimeDetailsOptions
537
616
  }
538
617
  nodeSubtitleResolver?: NodeSubtitleResolver
539
618
  slots?: StudioShellSlots
619
+ /** @deprecated Use config.ui.regions. */
540
620
  regions?: StudioShellRegions
541
621
  layout?: (context: Record<string, unknown>) => void | (() => void)
542
622
  runtime?: ProcessInstanceSnapshot | null
543
623
  mode?: StudioMode
544
624
  allowedModes?: readonly StudioMode[]
545
625
  projection?: ViewerProjection
626
+ /** @deprecated Use config.viewer.responsive. */
546
627
  responsive?: boolean
628
+ /** @deprecated Use config.viewer.projectionOptions. */
547
629
  projectionOptions?: Array<{ value: ViewerProjection; label: string }>
630
+ /** @deprecated Use config.ui.sidebarWidth.left. */
548
631
  leftWidth?: number
632
+ /** @deprecated Use config.ui.sidebarWidth.right. */
549
633
  rightWidth?: number
634
+ /** @deprecated Use config.viewer.timeline. */
635
+ timeline?: ViewerTimelineOptions
636
+ /** @deprecated Use config.viewer.runtimeDetails. */
637
+ runtimeDetails?: RuntimeDetailsOptions
638
+ /** @deprecated Use config.viewer.runtimeTraceOptions. */
639
+ runtimeTraceOptions?: RuntimeTraceProjectionOptions
550
640
  theme?: NovaThemeInput
551
641
  runtimeAppearance?: RuntimeAppearanceOptions
552
- svgExport?: SvgExportOptions & { label?: string; nodeRenderers?: Record<string, SvgNodeRenderer>; runtimeTimelineRenderer?: RuntimeTimelineSvgRenderer }
642
+ /** @deprecated Use config.export. */
643
+ svgExport?: StudioSvgExportOptions
553
644
  onThemeChange?: (state: NovaThemeState) => void
554
645
  }
555
646
  export class BpmnStudioShell {
556
647
  constructor(options: StudioShellOptions)
557
648
  readonly actions: StudioShellActions
649
+ readonly ui: StudioUiContext
558
650
  canvas?: BpmnCanvas
559
651
  viewer?: BpmnViewer
560
652
  getMode(): StudioMode
561
653
  getAllowedModes(): readonly StudioMode[]
562
654
  subscribeMode(listener: (event: StudioModeChangeEvent) => void): () => void
563
655
  subscribeValidation(listener: (event: StudioValidationEvent) => void): () => void
656
+ getSidebarState(): StudioSidebarState
657
+ setSidebarCollapsed(side: StudioSidebarSide, collapsed: boolean): boolean
658
+ subscribeSidebarChange(listener: (event: StudioSidebarChangeEvent) => void): () => void
659
+ getPanelSelection(): StudioPanelSelection
660
+ subscribePanelSelection(listener: (selection: StudioPanelSelection) => void): () => void
661
+ getConfig(): Readonly<BpmnStudioConfig>
662
+ setConfig(config?: BpmnStudioConfig): Readonly<BpmnStudioConfig>
564
663
  setAllowedModes(modes: readonly StudioMode[]): readonly StudioMode[]
565
664
  getRegions(): Readonly<StudioShellRegionState>
566
665
  setRegions(regions: StudioShellRegions): Readonly<StudioShellRegionState>
@@ -1,7 +1,8 @@
1
1
  import { NODE_DEFINITIONS, edgeWaypoints, roundedPath, routeRuntimeTransition, smoothPath } from '../core/index.js';
2
2
  import { createDefaultIconRegistry, resolveNodeVisual } from '../icons/index.js';
3
3
  import { resolveNodeSubtitle, supportsNodeSubtitle } from '../node-presentation/index.js';
4
- import { createRuntimePresentation } from '../runtime/index.js';
4
+ import { resolveNodeGeometry, resolvePresentationWaypoints } from '../node-geometry/index.js';
5
+ import { createRuntimePresentation, formatRuntimeInstant } from '../runtime/index.js';
5
6
  import { createRuntimeAppearance } from '../theme/index.js';
6
7
 
7
8
  const SVG_NS = 'http://www.w3.org/2000/svg';
@@ -169,7 +170,7 @@ function finite(value, fallback = 0) {
169
170
  }
170
171
 
171
172
  function formatTime(value) {
172
- return value ? String(value).replace('T', ' ') : '';
173
+ return formatRuntimeInstant(value);
173
174
  }
174
175
 
175
176
  function formatSize(value) {
@@ -294,9 +295,11 @@ function addDefinitions(document, svg, theme) {
294
295
  if (/^[a-z][a-z0-9-]{0,47}$/.test(name)) markers.set(name, tone(theme, name).strong);
295
296
  }
296
297
  for (const [id, color] of markers) {
297
- const marker = svgElement(document, 'marker', { id: `nova-arrow-${id}`, viewBox: '0 0 10 10', refX: 8.5, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
298
- marker.appendChild(svgElement(document, 'path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: color }));
299
- defs.appendChild(marker);
298
+ for (const docked of [false, true]) {
299
+ const marker = svgElement(document, 'marker', { id: `nova-arrow-${id}${docked ? '-docked' : ''}`, viewBox: '0 0 10 10', refX: docked ? 10 : 8.5, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
300
+ marker.appendChild(svgElement(document, 'path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: color }));
301
+ defs.appendChild(marker);
302
+ }
300
303
  }
301
304
  svg.appendChild(defs);
302
305
  }
@@ -305,7 +308,29 @@ function nodeToneName(node) {
305
308
  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';
306
309
  }
307
310
 
308
- function renderDefaultNode({ document, group, node, definition, visual, presentation, subtitle: definitionSubtitle, theme, iconRegistry }) {
311
+ function renderGeometryShape(document, group, geometry, fill, stroke) {
312
+ const { outline, center, scale } = geometry;
313
+ const { width, height, radiusX, radiusY, rotation, strokeWidth } = outline;
314
+ const inset = strokeWidth / 2;
315
+ const shape = svgElement(document, 'g', { 'data-node-shape': geometry.kind, transform: `translate(${center.x} ${center.y}) rotate(${rotation * 180 / Math.PI}) scale(${scale})` });
316
+ // Paint the border inward, as CSS does. A centred SVG stroke around an inset ellipse
317
+ // is not the same outer curve (notably for data stores), so use two nested fills.
318
+ shape.appendChild(svgElement(document, 'rect', { x: -width / 2, y: -height / 2, width, height, rx: radiusX, ry: radiusY, fill: stroke, filter: geometry.kind === 'gateway' ? 'url(#nova-export-shadow)' : null }));
319
+ shape.appendChild(svgElement(document, 'rect', { x: -width / 2 + strokeWidth, y: -height / 2 + strokeWidth, width: width - 2 * strokeWidth, height: height - 2 * strokeWidth, rx: Math.max(0, radiusX - strokeWidth), ry: Math.max(0, radiusY - strokeWidth), fill }));
320
+ if (geometry.kind === 'data') {
321
+ const right = width / 2 - inset;
322
+ const top = -height / 2 + inset;
323
+ shape.appendChild(svgElement(document, 'path', { d: `M ${right - 15} ${top} L ${right} ${top + 15}`, fill: 'none', stroke, 'stroke-width': strokeWidth }));
324
+ } else if (geometry.kind === 'dataStore') {
325
+ for (const offset of [9, 19]) {
326
+ const top = -height / 2 + strokeWidth + offset;
327
+ shape.appendChild(svgElement(document, 'path', { d: `M ${-width / 2 + inset} ${top + 4.5} A ${width / 2 - inset} 4.5 0 0 1 ${width / 2 - inset} ${top + 4.5}`, fill: 'none', stroke, 'stroke-width': 1.5 }));
328
+ }
329
+ }
330
+ group.appendChild(shape);
331
+ }
332
+
333
+ function renderDefaultNode({ document, group, node, definition, geometry, visual, presentation, subtitle: definitionSubtitle, theme, iconRegistry }) {
309
334
  const colors = theme.colors;
310
335
  const statusTone = tone(theme, presentation?.status === 'rejected' ? 'danger' : presentation?.status === 'active' ? 'primary' : presentation?.status === 'completed' ? 'success' : 'neutral');
311
336
  const typeTone = tone(theme, nodeToneName(node));
@@ -319,11 +344,12 @@ function renderDefaultNode({ document, group, node, definition, visual, presenta
319
344
  appendTextLines(document, group, wrapText(node.name || definition.label, 150, 12), { x: x + width / 2, y: y + height + 20, lineHeight: 15, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
320
345
  return;
321
346
  }
322
- if (definition.kind === 'gateway') {
323
- const points = `${x + width / 2},${y + 2} ${x + width - 2},${y + height / 2} ${x + width / 2},${y + height - 2} ${x + 2},${y + height / 2}`;
324
- group.appendChild(svgElement(document, 'polygon', { points, fill: colors.surface, stroke: presentation?.status && presentation.status !== 'idle' ? statusTone.strong : colors.borderStrong, 'stroke-width': 2, filter: 'url(#nova-export-shadow)' }));
325
- appendIcon(document, group, icon, { x: x + width * 0.28, y: y + height * 0.28, width: width * 0.44, height: height * 0.44 }, colors.textSecondary);
326
- appendTextLines(document, group, wrapText(node.name || definition.label, 160, 12), { x: x + width / 2, y: y + height + 20, lineHeight: 15, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
347
+ if (['gateway', 'data', 'dataStore'].includes(definition.kind)) {
348
+ if (!geometry) return;
349
+ const stroke = definition.kind === 'gateway' && presentation?.status && presentation.status !== 'idle' ? statusTone.strong : colors.borderStrong;
350
+ renderGeometryShape(document, group, geometry, colors.surface, stroke);
351
+ appendIcon(document, group, icon, geometry.iconBounds, colors.textSecondary);
352
+ appendTextLines(document, group, wrapText(node.name || definition.label, definition.kind === 'gateway' ? 160 : 150, 12), { x: geometry.label.x, y: geometry.label.y + 12, lineHeight: geometry.label.lineHeight, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
327
353
  return;
328
354
  }
329
355
  if (definition.kind === 'participant' || definition.kind === 'lane') {
@@ -344,17 +370,6 @@ function renderDefaultNode({ document, group, node, definition, visual, presenta
344
370
  appendTextLines(document, group, wrapText(node.properties?.text || node.name || '说明', width - 24, 12), { x: x + 16, y: y + 20, lineHeight: 17, attributes: { fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
345
371
  return;
346
372
  }
347
- if (definition.kind === 'data' || definition.kind === 'dataStore') {
348
- if (definition.kind === 'data') group.appendChild(svgElement(document, 'path', { d: `M ${x + 8} ${y + 2} H ${x + width - 18} L ${x + width - 2} ${y + 18} V ${y + height - 2} H ${x + 8} Z`, fill: colors.surface, stroke: colors.borderStrong, 'stroke-width': 1.5 }));
349
- else {
350
- group.appendChild(svgElement(document, 'rect', { x: x + 4, y: y + 10, width: width - 8, height: height - 20, fill: colors.surface, stroke: colors.borderStrong, 'stroke-width': 1.5 }));
351
- group.appendChild(svgElement(document, 'ellipse', { cx: x + width / 2, cy: y + 10, rx: width / 2 - 4, ry: 9, fill: colors.surface, stroke: colors.borderStrong, 'stroke-width': 1.5 }));
352
- group.appendChild(svgElement(document, 'ellipse', { cx: x + width / 2, cy: y + height - 10, rx: width / 2 - 4, ry: 9, fill: 'none', stroke: colors.borderStrong, 'stroke-width': 1.5 }));
353
- }
354
- appendIcon(document, group, icon, { x: x + width * 0.33, y: y + height * 0.31, width: width * 0.34, height: height * 0.34 }, colors.textSecondary);
355
- appendTextLines(document, group, wrapText(node.name || definition.label, 150, 12), { x: x + width / 2, y: y + height + 20, lineHeight: 15, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
356
- return;
357
- }
358
373
 
359
374
  const iconSize = Math.min(36, height - 24);
360
375
  const iconX = x + 14;
@@ -401,6 +416,45 @@ function renderDefaultNode({ document, group, node, definition, visual, presenta
401
416
  }
402
417
  }
403
418
 
419
+ function prepareNodes({ document, context, options, model, presentation, theme, iconRegistry, warnings }) {
420
+ const nodeLayer = svgElement(document, 'g', { 'data-layer': 'nodes' });
421
+ const geometries = new Map();
422
+ const runtimeMode = context.mode === 'instance' || Boolean(context.runtime);
423
+ const definitionMode = context.mode === 'viewer' ? 'viewer' : 'design';
424
+ const order = { participant: 0, lane: 1, group: 2, container: 3, data: 4, dataStore: 4, annotation: 4, task: 5, gateway: 6, event: 7, boundary: 8 };
425
+ const nodes = [...(model.nodes || [])].sort((a, b) => (order[NODE_DEFINITIONS[a.type]?.kind] ?? 5) - (order[NODE_DEFINITIONS[b.type]?.kind] ?? 5));
426
+ for (const node of nodes) {
427
+ options.signal?.throwIfAborted?.();
428
+ const definition = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
429
+ const nodePresentation = presentation.getNode?.(node.id) || null;
430
+ const visual = resolveNodeVisual(node, { surface: context.mode === 'instance' ? 'viewer' : 'canvas', model: context.visualModel || model });
431
+ const group = svgElement(document, 'g', { 'data-node-id': node.id });
432
+ const customRenderer = context.nodeRenderers?.[node.type] || context.nodeRenderers?.[definition.kind];
433
+ let rendered = false;
434
+ if (typeof customRenderer === 'function') {
435
+ try {
436
+ rendered = customRenderer({ container: group, node, definition, runtimePresentation: nodePresentation, visual, model, themeSnapshot: theme, iconRegistry }) !== false;
437
+ if (!rendered) group.replaceChildren();
438
+ } catch (error) {
439
+ group.replaceChildren();
440
+ warnings.push({ code: 'custom-node-renderer-failed', message: `节点“${node.name || node.id}”的 SVG Renderer 执行失败,已使用标准视觉。`, elementId: node.id });
441
+ }
442
+ } else if (context.htmlNodeRenderers?.[node.type] || context.htmlNodeRenderers?.[definition.kind] || context.htmlNodeRenderer) {
443
+ warnings.push({ code: 'custom-node-renderer-fallback', message: `节点“${node.name || node.id}”没有 SVG Renderer,已使用标准视觉。`, elementId: node.id });
444
+ }
445
+ if (!rendered) {
446
+ const geometry = resolveNodeGeometry(node, definition);
447
+ geometries.set(node.id, geometry);
448
+ const subtitle = !runtimeMode && supportsNodeSubtitle(definition)
449
+ ? resolveNodeSubtitle({ node, definition, model, mode: definitionMode, surface: 'svg-export', resolver: context.nodeSubtitleResolver })
450
+ : undefined;
451
+ renderDefaultNode({ document, group, node, definition, geometry, visual, presentation: nodePresentation, subtitle, theme, iconRegistry });
452
+ }
453
+ nodeLayer.appendChild(group);
454
+ }
455
+ return { nodeLayer, geometries };
456
+ }
457
+
404
458
  export async function exportDiagramSvg(context = {}, options = {}) {
405
459
  options.signal?.throwIfAborted?.();
406
460
  const document = resolvedDocument(context);
@@ -435,18 +489,22 @@ export async function exportDiagramSvg(context = {}, options = {}) {
435
489
  const svg = createRoot(document, { minX, minY, width, height, title, theme, transparentBackground: options.transparentBackground === true });
436
490
  addDefinitions(document, svg, theme);
437
491
 
492
+ // Resolve custom renderers once before docking edges, without changing the final layer order.
493
+ const { nodeLayer, geometries } = prepareNodes({ document, context, options, model, presentation, theme, iconRegistry, warnings });
494
+
438
495
  const edgeLayer = svgElement(document, 'g', { 'data-layer': 'edges' });
439
496
  for (const edge of model.edges || []) {
440
497
  const points = edgeWaypoints(model, edge);
498
+ const display = resolvePresentationWaypoints(points, geometries.get(edge.source), geometries.get(edge.target));
441
499
  const edgeState = presentation.getEdge?.(edge.id) || { status: 'idle' };
442
500
  const completed = edgeState.status === 'completed';
443
501
  const stroke = completed ? tone(theme, 'success').strong : theme.colors.edge;
444
502
  const type = edge.type || 'sequenceFlow';
445
503
  const path = svgElement(document, 'path', {
446
- d: routePath(points, edge.routeStyle || model.settings?.edgeStyle || 'rounded', edge.cornerRadius ?? model.settings?.cornerRadius ?? 14),
504
+ d: routePath(display.points, edge.routeStyle || model.settings?.edgeStyle || 'rounded', edge.cornerRadius ?? model.settings?.cornerRadius ?? 14),
447
505
  fill: 'none', stroke, 'stroke-width': completed ? 2.3 : 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
448
506
  'stroke-dasharray': type === 'messageFlow' ? '8 7' : type === 'association' ? '2 6' : null,
449
- 'marker-end': type === 'association' ? null : `url(#nova-arrow-${completed ? 'success' : 'edge'})`,
507
+ 'marker-end': type === 'association' ? null : `url(#nova-arrow-${completed ? 'success' : 'edge'}${display.targetDocked ? '-docked' : ''})`,
450
508
  });
451
509
  edgeLayer.appendChild(path);
452
510
  if (edge.name) {
@@ -471,10 +529,11 @@ export async function exportDiagramSvg(context = {}, options = {}) {
471
529
  const route = routeRuntimeTransition(model, transition, { occupiedRoutes: occupied });
472
530
  if (!route) continue;
473
531
  occupied.push(route.points);
532
+ const display = resolvePresentationWaypoints(route.points, geometries.get(transition.sourceElementId), geometries.get(transition.targetElementId));
474
533
  const toneName = appearance.resolveTransition?.(transition)?.tone || (transition.type === 'reject' ? 'danger' : 'warning');
475
534
  const transitionTone = tone(theme, toneName);
476
535
  const markerTone = Object.hasOwn(theme.tones || {}, toneName) && /^[a-z][a-z0-9-]{0,47}$/.test(toneName) ? toneName : 'neutral';
477
- transitionLayer.appendChild(svgElement(document, 'path', { d: routePath(route.points, model.settings?.edgeStyle || 'rounded', model.settings?.cornerRadius || 14), fill: 'none', stroke: transitionTone.strong, 'stroke-width': 2.2, 'stroke-dasharray': '8 6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'marker-end': `url(#nova-arrow-${markerTone})`, opacity: transition.latest ? 1 : 0.42 }));
536
+ transitionLayer.appendChild(svgElement(document, 'path', { d: routePath(display.points, model.settings?.edgeStyle || 'rounded', model.settings?.cornerRadius || 14), fill: 'none', stroke: transitionTone.strong, 'stroke-width': 2.2, 'stroke-dasharray': '8 6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'marker-end': `url(#nova-arrow-${markerTone}${display.targetDocked ? '-docked' : ''})`, opacity: transition.latest ? 1 : 0.42 }));
478
537
  const label = transition.label || (transition.type === 'reject' ? '驳回' : '退回');
479
538
  const labelTypography = { fontSize: 11, fontWeight: 650, fontFamily: theme.fontFamily };
480
539
  const labelWidth = Math.max(68, Math.min(210, Math.ceil(measureText(document, label, labelTypography) + 20)));
@@ -488,45 +547,6 @@ export async function exportDiagramSvg(context = {}, options = {}) {
488
547
  svg.appendChild(transitionLayer);
489
548
  }
490
549
 
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';
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 };
495
- const nodes = [...(model.nodes || [])].sort((a, b) => (order[NODE_DEFINITIONS[a.type]?.kind] ?? 5) - (order[NODE_DEFINITIONS[b.type]?.kind] ?? 5));
496
- for (const node of nodes) {
497
- options.signal?.throwIfAborted?.();
498
- const definition = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
499
- const nodePresentation = presentation.getNode?.(node.id) || null;
500
- const visual = resolveNodeVisual(node, { surface: context.mode === 'instance' ? 'viewer' : 'canvas', model: context.visualModel || model });
501
- const group = svgElement(document, 'g', { 'data-node-id': node.id });
502
- const customRenderer = context.nodeRenderers?.[node.type] || context.nodeRenderers?.[definition.kind];
503
- let rendered = false;
504
- if (typeof customRenderer === 'function') {
505
- try {
506
- rendered = customRenderer({ container: group, node, definition, runtimePresentation: nodePresentation, visual, model, themeSnapshot: theme, iconRegistry }) !== false;
507
- if (!rendered) group.replaceChildren();
508
- } catch (error) {
509
- group.replaceChildren();
510
- warnings.push({ code: 'custom-node-renderer-failed', message: `节点“${node.name || node.id}”的 SVG Renderer 执行失败,已使用标准视觉。`, elementId: node.id });
511
- }
512
- } else if (context.htmlNodeRenderers?.[node.type] || context.htmlNodeRenderers?.[definition.kind] || context.htmlNodeRenderer) {
513
- warnings.push({ code: 'custom-node-renderer-fallback', message: `节点“${node.name || node.id}”没有 SVG Renderer,已使用标准视觉。`, elementId: node.id });
514
- }
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
- }
528
- nodeLayer.appendChild(group);
529
- }
530
550
  svg.appendChild(nodeLayer);
531
551
  const filename = sanitizeSvgFilename(options.filename || `${model.name || model.id || 'process'}-${context.label || (context.mode === 'instance' ? '审批轨迹' : context.mode === 'viewer' ? '流程展示' : '流程设计')}`);
532
552
  return serializeArtifact(document, svg, { filename, width, height, viewBox: { x: minX, y: minY, width, height }, warnings });
@@ -0,0 +1,19 @@
1
+ import type { BpmnNode, NodeDefinition, Point } from '../core/index.js'
2
+
3
+ export interface NodeGeometry {
4
+ kind: 'gateway' | 'data' | 'dataStore'
5
+ scale: number
6
+ center: Point
7
+ outline: Readonly<{ width: number; height: number; radiusX: number; radiusY: number; rotation: number; strokeWidth: number; iconSize: number }>
8
+ bounds: { x: number; y: number; width: number; height: number }
9
+ iconBounds: { x: number; y: number; width: number; height: number }
10
+ label: Point & { lineHeight: number }
11
+ ports: Record<'left' | 'right' | 'top' | 'bottom', Point | null>
12
+ }
13
+
14
+ export function resolveNodeGeometry(node: BpmnNode, definition: NodeDefinition): NodeGeometry | null
15
+ export function resolvePresentationWaypoints(points: readonly Point[], sourceGeometry?: NodeGeometry | null, targetGeometry?: NodeGeometry | null): {
16
+ points: Point[]
17
+ sourceDocked: boolean
18
+ targetDocked: boolean
19
+ }
@@ -0,0 +1,147 @@
1
+ const EPSILON = 1e-9;
2
+ const BASE_SHAPES = Object.freeze({
3
+ gateway: Object.freeze({ width: 49, height: 49, radiusX: 7, radiusY: 7, rotation: Math.PI / 4, strokeWidth: 1.8, iconSize: 24 }),
4
+ data: Object.freeze({ width: 50, height: 62, radiusX: 4, radiusY: 4, rotation: 0, strokeWidth: 1.7, iconSize: 18 }),
5
+ dataStore: Object.freeze({ width: 56, height: 50, radiusX: 28, radiusY: 7, rotation: 0, strokeWidth: 1.7, iconSize: 16 }),
6
+ });
7
+
8
+ function finitePoint(point) {
9
+ return Number.isFinite(point?.x) && Number.isFinite(point?.y);
10
+ }
11
+
12
+ function toLocal(geometry, point) {
13
+ const x = (point.x - geometry.center.x) / geometry.scale;
14
+ const y = (point.y - geometry.center.y) / geometry.scale;
15
+ const { rotation } = geometry.outline;
16
+ return { x: x * Math.cos(rotation) + y * Math.sin(rotation), y: -x * Math.sin(rotation) + y * Math.cos(rotation) };
17
+ }
18
+
19
+ function contains(outline, point) {
20
+ const x = Math.abs(point.x);
21
+ const y = Math.abs(point.y);
22
+ const halfWidth = outline.width / 2;
23
+ const halfHeight = outline.height / 2;
24
+ if (x > halfWidth + EPSILON || y > halfHeight + EPSILON) return false;
25
+ const dx = Math.max(0, x - halfWidth + outline.radiusX) / outline.radiusX;
26
+ const dy = Math.max(0, y - halfHeight + outline.radiusY) / outline.radiusY;
27
+ return dx * dx + dy * dy <= 1 + EPSILON;
28
+ }
29
+
30
+ /** Intersect an incoming ray with the outer painted rounded rectangle, in base coordinates. */
31
+ function outlineIntersection(outline, from, toward) {
32
+ if (contains(outline, from)) return null;
33
+ const dx = toward.x - from.x;
34
+ const dy = toward.y - from.y;
35
+ if (Math.hypot(dx, dy) <= EPSILON) return null;
36
+ const { width, height, radiusX: rx, radiusY: ry } = outline;
37
+ const hw = width / 2;
38
+ const hh = height / 2;
39
+ const candidates = [];
40
+ const accept = (t, valid) => { if (Number.isFinite(t) && t >= 0 && valid) candidates.push(t); };
41
+ for (const sign of [-1, 1]) {
42
+ if (Math.abs(dx) > EPSILON) {
43
+ const t = (sign * hw - from.x) / dx;
44
+ accept(t, Math.abs(from.y + t * dy) <= hh - ry + EPSILON);
45
+ }
46
+ if (Math.abs(dy) > EPSILON) {
47
+ const t = (sign * hh - from.y) / dy;
48
+ accept(t, Math.abs(from.x + t * dx) <= hw - rx + EPSILON);
49
+ }
50
+ }
51
+ for (const sx of [-1, 1]) {
52
+ for (const sy of [-1, 1]) {
53
+ const cx = sx * (hw - rx);
54
+ const cy = sy * (hh - ry);
55
+ const x = (from.x - cx) / rx;
56
+ const y = (from.y - cy) / ry;
57
+ const vx = dx / rx;
58
+ const vy = dy / ry;
59
+ const a = vx * vx + vy * vy;
60
+ const b = 2 * (x * vx + y * vy);
61
+ const c = x * x + y * y - 1;
62
+ const discriminant = b * b - 4 * a * c;
63
+ if (discriminant < -EPSILON || a === 0) continue;
64
+ const root = Math.sqrt(Math.max(0, discriminant));
65
+ for (const t of [(-b - root) / (2 * a), (-b + root) / (2 * a)]) {
66
+ accept(t, (from.x + t * dx - cx) * sx >= -EPSILON && (from.y + t * dy - cy) * sy >= -EPSILON);
67
+ }
68
+ }
69
+ }
70
+ return candidates.length ? Math.min(...candidates) : null;
71
+ }
72
+
73
+ /** Display-only intersection. A missing intersection must never invent a new route. */
74
+ function dockPoint(geometry, from, toward) {
75
+ if (!geometry || !finitePoint(from) || !finitePoint(toward)) return null;
76
+ const localFrom = toLocal(geometry, from);
77
+ const localToward = toLocal(geometry, toward);
78
+ if (!finitePoint(localFrom) || !finitePoint(localToward)) return null;
79
+ const t = outlineIntersection(geometry.outline, localFrom, localToward);
80
+ if (t === null) return null;
81
+ const point = { x: from.x + (toward.x - from.x) * t, y: from.y + (toward.y - from.y) * t };
82
+ return finitePoint(point) ? point : null;
83
+ }
84
+
85
+ /**
86
+ * Standard gateway/data geometry shared by DOM and pure SVG. Model Bounds remain authoritative;
87
+ * scaling only affects presentation, including borders and decorations. Unsupported/invalid input
88
+ * deliberately has no geometry rather than silently rewriting a BPMN element's dimensions.
89
+ */
90
+ export function resolveNodeGeometry(node, definition) {
91
+ const base = BASE_SHAPES[definition?.kind];
92
+ if (!base || !finitePoint(node) || !Number.isFinite(node.width) || !Number.isFinite(node.height) || node.width <= 0 || node.height <= 0) return null;
93
+ const extentWidth = base.rotation ? base.width * Math.SQRT2 : base.width;
94
+ const extentHeight = base.rotation ? base.height * Math.SQRT2 : base.height;
95
+ const scale = Math.min(1, node.width / extentWidth, node.height / extentHeight);
96
+ const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
97
+ const label = { x: center.x, y: node.y + node.height + 6, lineHeight: 16 };
98
+ if (!finitePoint(center) || !finitePoint(label) || !Number.isFinite(scale) || scale <= 0) return null;
99
+ const iconSize = (node.type === 'complexGateway' ? 20 : base.iconSize) * scale;
100
+ const geometry = {
101
+ kind: definition.kind,
102
+ scale,
103
+ center,
104
+ outline: base,
105
+ bounds: { x: center.x - extentWidth * scale / 2, y: center.y - extentHeight * scale / 2, width: extentWidth * scale, height: extentHeight * scale },
106
+ iconBounds: { x: center.x - iconSize / 2, y: center.y - iconSize / 2, width: iconSize, height: iconSize },
107
+ label,
108
+ ports: {},
109
+ };
110
+ for (const [side, dx, dy] of [['left', -1, 0], ['right', 1, 0], ['top', 0, -1], ['bottom', 0, 1]]) {
111
+ const distance = Math.max(extentWidth, extentHeight) * scale;
112
+ geometry.ports[side] = dockPoint(geometry, { x: center.x + dx * distance, y: center.y + dy * distance }, center);
113
+ }
114
+ return geometry;
115
+ }
116
+
117
+ function endpointProjection(points, geometry, target) {
118
+ const start = target ? points.length - 1 : 0;
119
+ const step = target ? -1 : 1;
120
+ let next = start + step;
121
+ while (next >= 0 && next < points.length && Math.hypot(points[next].x - points[start].x, points[next].y - points[start].y) <= EPSILON) next += step;
122
+ if (next < 0 || next >= points.length) return null;
123
+ const point = dockPoint(geometry, points[next], points[start]);
124
+ return point ? { point, start, next, step } : null;
125
+ }
126
+
127
+ /** Clone routes; keep bends, collinearity and travel direction, including repeated endpoint entries. */
128
+ export function resolvePresentationWaypoints(points, sourceGeometry = null, targetGeometry = null) {
129
+ const copy = points.map((point) => ({ ...point }));
130
+ const unchanged = () => ({ points: points.map((point) => ({ ...point })), sourceDocked: false, targetDocked: false });
131
+ if (points.length < 2 || !points.every(finitePoint)) return unchanged();
132
+ const source = endpointProjection(points, sourceGeometry, false);
133
+ const target = endpointProjection(points, targetGeometry, true);
134
+ for (const projection of [source, target]) {
135
+ if (!projection) continue;
136
+ for (let index = projection.start; index !== projection.next; index += projection.step) copy[index] = { ...projection.point };
137
+ }
138
+ // Closely spaced/overlapping nodes must not reverse the first or last segment after docking.
139
+ for (const projection of [source, target]) {
140
+ if (!projection) continue;
141
+ const { start, next } = projection;
142
+ const before = { x: points[next].x - points[start].x, y: points[next].y - points[start].y };
143
+ const after = { x: copy[next].x - copy[start].x, y: copy[next].y - copy[start].y };
144
+ if (before.x * after.x + before.y * after.y <= EPSILON) return unchanged();
145
+ }
146
+ return { points: copy, sourceDocked: Boolean(source), targetDocked: Boolean(target) };
147
+ }