@bpmn-nova/studio 0.3.3-preview → 0.3.5-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 +75 -6
- 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 +82 -62
- 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 +63 -25
- package/dist/modules/viewer/index.d.ts +1 -1
- package/dist/modules/viewer/index.js +86 -35
- 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 +1614 -93
- package/llms.txt +76 -15
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
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 { resolveNodeGeometry, resolvePresentationWaypoints } from '../node-geometry/index.js';
|
|
4
5
|
import { createRuntimePresentation } from '../runtime/index.js';
|
|
5
6
|
import { createRuntimeAppearance } from '../theme/index.js';
|
|
6
7
|
|
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,7 @@ import { NODE_DEFINITIONS, PALETTE_GROUPS, PARALLEL_GATEWAY_PRESETS, edgeWaypoin
|
|
|
2
2
|
import { createRuntimePresentation } from '../runtime/index.js';
|
|
3
3
|
import { createDefaultIconRegistry, createIconElement, resolveNodeVisual } from '../icons/index.js';
|
|
4
4
|
import { resolveNodeSubtitle, supportsNodeSubtitle } from '../node-presentation/index.js';
|
|
5
|
+
import { resolveNodeGeometry, resolvePresentationWaypoints } from '../node-geometry/index.js';
|
|
5
6
|
import { ThemeController, applyRuntimeTone } from '../theme/index.js';
|
|
6
7
|
import { exportDiagramSvg, openSvgExportPreview } from '../export-svg/index.js';
|
|
7
8
|
|
|
@@ -276,6 +277,26 @@ function nodeAccessibleLabel(title, subtitle) {
|
|
|
276
277
|
return subtitle === null || subtitle === '' ? title : `${title}\n${subtitle}`;
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
function applyNodeGeometry(shape, geometry) {
|
|
281
|
+
shape.classList.add('mb-node-scaled-shape');
|
|
282
|
+
shape.style.width = `${geometry.outline.width}px`;
|
|
283
|
+
shape.style.height = `${geometry.outline.height}px`;
|
|
284
|
+
shape.style.setProperty('--mb-node-shape-scale', String(geometry.scale));
|
|
285
|
+
shape.style.setProperty('--mb-node-shape-rotation', `${geometry.outline.rotation}rad`);
|
|
286
|
+
// CSS may quantize a 1.7px border to 1.5px; keep the document fold inside the outer outline.
|
|
287
|
+
if (geometry.kind === 'data') shape.style.clipPath = `inset(0 round ${geometry.outline.radiusX}px)`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function geometryLabel(node, geometry) {
|
|
291
|
+
const label = el('div', 'mb-node-floating-label', node.name);
|
|
292
|
+
if (geometry) {
|
|
293
|
+
label.style.left = `${geometry.label.x - node.x}px`;
|
|
294
|
+
label.style.top = `${geometry.label.y - node.y}px`;
|
|
295
|
+
label.style.lineHeight = `${geometry.label.lineHeight}px`;
|
|
296
|
+
}
|
|
297
|
+
return label;
|
|
298
|
+
}
|
|
299
|
+
|
|
279
300
|
function runtimeStatusIconId(presentation) {
|
|
280
301
|
if (presentation?.status === 'rejected') return 'ui.statusRejected';
|
|
281
302
|
if (presentation?.status === 'completed') return 'ui.statusCompleted';
|
|
@@ -364,17 +385,21 @@ export class DiagramRenderer {
|
|
|
364
385
|
['arrow-failed', 'var(--mb-danger)'],
|
|
365
386
|
];
|
|
366
387
|
for (const [id] of markers) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
388
|
+
for (const docked of [false, true]) {
|
|
389
|
+
const marker = svgEl('marker', { id: `${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' });
|
|
390
|
+
marker.appendChild(svgEl('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'context-stroke' }));
|
|
391
|
+
defs.appendChild(marker);
|
|
392
|
+
}
|
|
370
393
|
}
|
|
371
394
|
this.edgeSvg.appendChild(defs);
|
|
372
395
|
|
|
373
396
|
const runtimeDefs = svgEl('defs');
|
|
374
397
|
for (const id of ['runtime-arrow-forward', 'runtime-arrow-reject', 'runtime-arrow-return', 'runtime-arrow-skip']) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
398
|
+
for (const docked of [false, true]) {
|
|
399
|
+
const marker = svgEl('marker', { id: `${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' });
|
|
400
|
+
marker.appendChild(svgEl('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'context-stroke' }));
|
|
401
|
+
runtimeDefs.appendChild(marker);
|
|
402
|
+
}
|
|
378
403
|
}
|
|
379
404
|
this.runtimeTransitionSvg.appendChild(runtimeDefs);
|
|
380
405
|
|
|
@@ -767,6 +792,7 @@ export class DiagramRenderer {
|
|
|
767
792
|
? presenter({ model: this.model, runtime: this.runtime, appearance: this.options.runtimeAppearance })
|
|
768
793
|
: createRuntimePresentation({ model: this.model, runtime: null, appearance: this.options.runtimeAppearance });
|
|
769
794
|
const subtitles = this._resolveDefinitionSubtitles();
|
|
795
|
+
this._nodeGeometries = new Map(this.model.nodes.map((node) => [node.id, resolveNodeGeometry(node, NODE_DEFINITIONS[node.type])]));
|
|
770
796
|
this._syncSceneBounds();
|
|
771
797
|
this._renderEdges();
|
|
772
798
|
this._renderRuntimeTransitions();
|
|
@@ -832,8 +858,9 @@ export class DiagramRenderer {
|
|
|
832
858
|
labelSize: { width: metrics.width, height: metrics.height },
|
|
833
859
|
});
|
|
834
860
|
if (!route) return;
|
|
835
|
-
const
|
|
836
|
-
|
|
861
|
+
const display = resolvePresentationWaypoints(route.points, this._nodeGeometries.get(transition.sourceElementId), this._nodeGeometries.get(transition.targetElementId));
|
|
862
|
+
const points = display.points;
|
|
863
|
+
occupiedRuntimeRoutes.push(route.points);
|
|
837
864
|
labelObstacles.push({
|
|
838
865
|
x: route.labelPoint.x - metrics.width / 2,
|
|
839
866
|
y: route.labelPoint.y - metrics.height / 2,
|
|
@@ -860,7 +887,7 @@ export class DiagramRenderer {
|
|
|
860
887
|
d: pathData,
|
|
861
888
|
class: 'mb-runtime-transition-path',
|
|
862
889
|
fill: 'none',
|
|
863
|
-
'marker-end': `url(#runtime-arrow-${transition.type})`,
|
|
890
|
+
'marker-end': `url(#runtime-arrow-${transition.type}${display.targetDocked ? '-docked' : ''})`,
|
|
864
891
|
}));
|
|
865
892
|
const labelX = route.labelPoint.x;
|
|
866
893
|
const labelY = route.labelPoint.y;
|
|
@@ -900,9 +927,10 @@ export class DiagramRenderer {
|
|
|
900
927
|
for (const edge of this.model.edges) {
|
|
901
928
|
const points = edgeWaypoints(this.model, edge);
|
|
902
929
|
if (points.length < 2) continue;
|
|
930
|
+
const display = resolvePresentationWaypoints(points, this._nodeGeometries.get(edge.source), this._nodeGeometries.get(edge.target));
|
|
903
931
|
const corner = edge.cornerRadius ?? this.model.settings?.cornerRadius ?? 14;
|
|
904
932
|
const routeStyle = edge.routeStyle || this.model.settings?.edgeStyle || 'rounded';
|
|
905
|
-
const pathData = routePathData(points, routeStyle, corner);
|
|
933
|
+
const pathData = routePathData(display.points, routeStyle, corner);
|
|
906
934
|
|
|
907
935
|
const edgeStatus = this.runtimePresentation.getEdge(edge.id).status;
|
|
908
936
|
const selected = this.selection?.kind === 'edge' && this.selection.id === edge.id;
|
|
@@ -916,7 +944,7 @@ export class DiagramRenderer {
|
|
|
916
944
|
class: 'mb-edge-path',
|
|
917
945
|
fill: 'none',
|
|
918
946
|
};
|
|
919
|
-
if ((edge.type || 'sequenceFlow') === 'sequenceFlow') pathAttrs['marker-end'] = `url(#arrow${edgeStatus === 'idle' ? '' : `-${edgeStatus}`})`;
|
|
947
|
+
if ((edge.type || 'sequenceFlow') === 'sequenceFlow') pathAttrs['marker-end'] = `url(#arrow${edgeStatus === 'idle' ? '' : `-${edgeStatus}`}${display.targetDocked ? '-docked' : ''})`;
|
|
920
948
|
const path = svgEl('path', pathAttrs);
|
|
921
949
|
const hit = svgEl('path', { d: pathData, class: 'mb-edge-hit', fill: 'none' });
|
|
922
950
|
let labelPosition = edgeLabelPoint(points);
|
|
@@ -1109,7 +1137,13 @@ export class DiagramRenderer {
|
|
|
1109
1137
|
}
|
|
1110
1138
|
|
|
1111
1139
|
if (def.kind === 'gateway') {
|
|
1140
|
+
const geometry = this._nodeGeometries.get(node.id);
|
|
1141
|
+
if (!geometry) {
|
|
1142
|
+
nodeEl.appendChild(geometryLabel(node, geometry));
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1112
1145
|
const shape = el('div', 'mb-gateway-shape');
|
|
1146
|
+
applyNodeGeometry(shape, geometry);
|
|
1113
1147
|
const symbol = el('span', 'mb-gateway-symbol');
|
|
1114
1148
|
if (!this._renderCustomNodeContent(symbol, node, def, runtimeState, visual)) {
|
|
1115
1149
|
const iconNode = visual.iconId
|
|
@@ -1131,23 +1165,19 @@ export class DiagramRenderer {
|
|
|
1131
1165
|
nodeEl.appendChild(badge);
|
|
1132
1166
|
}
|
|
1133
1167
|
}
|
|
1134
|
-
nodeEl.appendChild(
|
|
1168
|
+
nodeEl.appendChild(geometryLabel(node, geometry));
|
|
1135
1169
|
return;
|
|
1136
1170
|
}
|
|
1137
1171
|
|
|
1138
|
-
if (def.kind === 'data') {
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
const store = el('div', 'mb-data-store-shape');
|
|
1148
|
-
store.appendChild(el('span', '', '≡'));
|
|
1149
|
-
nodeEl.appendChild(store);
|
|
1150
|
-
nodeEl.appendChild(el('div', 'mb-node-floating-label', node.name));
|
|
1172
|
+
if (def.kind === 'data' || def.kind === 'dataStore') {
|
|
1173
|
+
const geometry = this._nodeGeometries.get(node.id);
|
|
1174
|
+
if (geometry) {
|
|
1175
|
+
const shape = el('div', def.kind === 'data' ? 'mb-data-object-shape' : 'mb-data-store-shape');
|
|
1176
|
+
applyNodeGeometry(shape, geometry);
|
|
1177
|
+
shape.appendChild(el('span', def.kind === 'data' ? 'mb-data-object-lines' : '', '≡'));
|
|
1178
|
+
nodeEl.appendChild(shape);
|
|
1179
|
+
}
|
|
1180
|
+
nodeEl.appendChild(geometryLabel(node, geometry));
|
|
1151
1181
|
return;
|
|
1152
1182
|
}
|
|
1153
1183
|
|
|
@@ -1308,6 +1338,14 @@ export class DiagramRenderer {
|
|
|
1308
1338
|
|
|
1309
1339
|
const appendPort = (side, { target = false, onClick } = {}) => {
|
|
1310
1340
|
const port = el('button', `mb-port mb-port-${side}${target ? ' mb-port-target' : ''}`);
|
|
1341
|
+
const anchor = this._nodeGeometries.get(node.id)?.ports[side];
|
|
1342
|
+
if (anchor) {
|
|
1343
|
+
port.classList.add('mb-port-geometry');
|
|
1344
|
+
port.style.left = `${anchor.x - node.x}px`;
|
|
1345
|
+
port.style.top = `${anchor.y - node.y}px`;
|
|
1346
|
+
port.style.right = 'auto';
|
|
1347
|
+
port.style.bottom = 'auto';
|
|
1348
|
+
}
|
|
1311
1349
|
port.type = 'button';
|
|
1312
1350
|
port.title = target ? '连接到此节点' : (side === 'right' ? '创建连接' : '输入连接点');
|
|
1313
1351
|
port.tabIndex = -1;
|
|
@@ -248,7 +248,7 @@ export class BpmnViewer {
|
|
|
248
248
|
setModel(model: ProcessModel): void
|
|
249
249
|
setRuntime(runtime: ProcessInstanceSnapshot | null): void
|
|
250
250
|
refreshPresentation(): void
|
|
251
|
-
setDisplayOptions(options: { timeline?: ViewerTimelineOptions; runtimeDetails?: RuntimeDetailsOptions; runtimeTraceOptions?: RuntimeTraceProjectionOptions; runtimeAssetResolver?: RuntimeAssetResolver | null }): void
|
|
251
|
+
setDisplayOptions(options: { timeline?: ViewerTimelineOptions; runtimeDetails?: RuntimeDetailsOptions; runtimeTraceOptions?: RuntimeTraceProjectionOptions; runtimeAssetResolver?: RuntimeAssetResolver | null; replace?: boolean }): void
|
|
252
252
|
setProjection(projection: ViewerProjection): void
|
|
253
253
|
setTheme(theme: NovaThemeInput): NovaThemeState
|
|
254
254
|
setThemeMode(mode: NovaThemeMode): NovaThemeState
|