@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/README.md +16 -298
- package/dist/config.js +285 -0
- package/dist/controller.js +19 -4
- package/dist/index.d.ts +101 -2
- package/dist/modules/export-svg/render.js +84 -64
- package/dist/modules/node-geometry/index.d.ts +19 -0
- package/dist/modules/node-geometry/index.js +147 -0
- package/dist/modules/renderer-svg/index.js +64 -25
- package/dist/modules/runtime/diagnostics.js +22 -0
- package/dist/modules/runtime/index.d.ts +38 -4
- package/dist/modules/runtime/index.js +272 -80
- package/dist/modules/runtime/time.js +41 -0
- package/dist/modules/runtime/visits.js +167 -0
- package/dist/modules/viewer/index.d.ts +1 -1
- package/dist/modules/viewer/index.js +220 -50
- package/dist/modules/viewer/runtime-trace.js +33 -30
- package/dist/modules/viewer/timeline.js +2 -2
- package/dist/panel-selection.js +60 -0
- package/dist/shell.js +315 -87
- package/dist/sidebars.js +195 -0
- package/dist/styles.css +72 -12
- package/llms-full.txt +3924 -826
- package/llms.txt +64 -15
- package/package.json +1 -1
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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 (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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(
|
|
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
|
+
}
|