@bpmn-nova/studio 0.3.0-preview → 0.3.2-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.
Files changed (63) hide show
  1. package/README.md +245 -20
  2. package/dist/canvas.js +7 -4
  3. package/dist/context-menu.js +2 -2
  4. package/dist/controller.js +84 -6
  5. package/dist/index.d.ts +138 -38
  6. package/dist/index.js +12 -11
  7. package/dist/interactions.js +1 -1
  8. package/dist/modules/bpmn-model/index.d.ts +11 -0
  9. package/dist/modules/bpmn-model/index.js +703 -0
  10. package/dist/modules/core/containment.js +590 -0
  11. package/dist/modules/core/gateway.js +72 -0
  12. package/dist/modules/core/history.js +32 -0
  13. package/dist/modules/core/index.d.ts +268 -0
  14. package/dist/modules/core/index.js +7 -0
  15. package/dist/modules/core/layout.js +644 -0
  16. package/dist/modules/core/model.js +287 -0
  17. package/dist/modules/core/runtime-transition-route.js +360 -0
  18. package/dist/modules/core/scope.js +99 -0
  19. package/dist/modules/designer/index.d.ts +79 -0
  20. package/dist/modules/designer/index.js +607 -0
  21. package/dist/modules/engine-activiti/index.d.ts +19 -0
  22. package/dist/modules/engine-activiti/index.js +160 -0
  23. package/dist/modules/engine-flowable/index.d.ts +19 -0
  24. package/dist/modules/engine-flowable/index.js +160 -0
  25. package/dist/modules/export-svg/index.d.ts +112 -0
  26. package/dist/modules/export-svg/index.js +2 -0
  27. package/dist/modules/export-svg/preview.js +327 -0
  28. package/dist/modules/export-svg/render.js +718 -0
  29. package/dist/modules/icons/index.d.ts +24 -0
  30. package/dist/modules/icons/index.js +264 -0
  31. package/dist/modules/palette/index.d.ts +74 -0
  32. package/dist/modules/palette/index.js +99 -0
  33. package/dist/modules/palette/panel.js +99 -0
  34. package/dist/modules/properties/index.d.ts +20 -0
  35. package/dist/modules/properties/index.js +19 -0
  36. package/dist/modules/properties-activiti/index.d.ts +3 -0
  37. package/dist/modules/properties-activiti/index.js +97 -0
  38. package/dist/modules/properties-bpmn/index.d.ts +3 -0
  39. package/dist/modules/properties-bpmn/index.js +518 -0
  40. package/dist/modules/properties-core/index.d.ts +124 -0
  41. package/dist/modules/properties-core/index.js +312 -0
  42. package/dist/modules/properties-flowable/index.d.ts +3 -0
  43. package/dist/modules/properties-flowable/index.js +114 -0
  44. package/dist/modules/properties-renderer/index.d.ts +25 -0
  45. package/dist/modules/properties-renderer/index.js +491 -0
  46. package/dist/modules/renderer-svg/index.d.ts +118 -0
  47. package/dist/modules/renderer-svg/index.js +1460 -0
  48. package/dist/modules/runtime/index.d.ts +169 -0
  49. package/dist/modules/runtime/index.js +535 -0
  50. package/dist/modules/theme/index.d.ts +95 -0
  51. package/dist/modules/theme/index.js +368 -0
  52. package/dist/modules/viewer/index.d.ts +265 -0
  53. package/dist/modules/viewer/index.js +1011 -0
  54. package/dist/modules/viewer/runtime-content.js +123 -0
  55. package/dist/modules/viewer/runtime-details-motion.js +228 -0
  56. package/dist/modules/viewer/runtime-trace.js +574 -0
  57. package/dist/modules/viewer/timeline.js +276 -0
  58. package/dist/selection-layout.js +1 -1
  59. package/dist/shell.js +210 -26
  60. package/dist/styles.css +116 -7
  61. package/llms-full.txt +3082 -0
  62. package/llms.txt +225 -0
  63. package/package.json +39 -16
@@ -0,0 +1,1460 @@
1
+ import { NODE_DEFINITIONS, PALETTE_GROUPS, PARALLEL_GATEWAY_PRESETS, edgeWaypoints, resolveGatewayRole, resolveSwimlaneLabelPlacement, roundedPath, routeRuntimeTransition, smoothPath } from '../core/index.js';
2
+ import { createRuntimePresentation } from '../runtime/index.js';
3
+ import { createDefaultIconRegistry, createIconElement, resolveNodeVisual } from '../icons/index.js';
4
+ import { ThemeController, applyRuntimeTone } from '../theme/index.js';
5
+ import { exportDiagramSvg, openSvgExportPreview } from '../export-svg/index.js';
6
+
7
+ const SVG_NS = 'http://www.w3.org/2000/svg';
8
+ const XHTML_NS = 'http://www.w3.org/1999/xhtml';
9
+
10
+ function clamp(value, min, max) {
11
+ return Math.max(min, Math.min(max, value));
12
+ }
13
+
14
+ function quantizeZoom(value, step = 0.05) {
15
+ const clamped = clamp(value, 0.25, 2.25);
16
+ return Math.round(Math.round(clamped / step) * step * 1000) / 1000;
17
+ }
18
+
19
+ function estimateLabelTextWidth(value) {
20
+ let width = 0;
21
+ for (const ch of Array.from(String(value ?? ''))) {
22
+ const code = ch.codePointAt(0);
23
+ if (/\s/.test(ch)) width += 3.8;
24
+ else if ((code >= 0x2e80 && code <= 0x9fff) || (code >= 0xf900 && code <= 0xfaff)) width += 11.6;
25
+ else if (/[A-Z]/.test(ch)) width += 7.2;
26
+ else if (/[a-z]/.test(ch)) width += 6.2;
27
+ else if (/[0-9]/.test(ch)) width += 6.4;
28
+ else if (/[.,:;]/.test(ch)) width += 4.2;
29
+ else width += 7.4;
30
+ }
31
+ return width;
32
+ }
33
+
34
+ const EDGE_LABEL_TOKENS = Object.freeze({
35
+ paddingX: 10,
36
+ paddingY: 5,
37
+ maxWidth: 144,
38
+ lineHeight: 16,
39
+ radius: 7,
40
+ });
41
+
42
+ const SCENE_MARGIN = 1200;
43
+ const MIN_SCENE_SIZE = 1;
44
+
45
+ /**
46
+ * Computes a render envelope for SVG content without constraining the logical canvas.
47
+ * Node coordinates may be negative or arbitrarily large; the renderer only sizes its
48
+ * SVG layers around currently visible/content coordinates so there is no fixed 5200×3200
49
+ * world boundary.
50
+ */
51
+ export function computeSceneBounds(model, viewportBounds = null, margin = SCENE_MARGIN) {
52
+ const xs = [0];
53
+ const ys = [0];
54
+ const nodes = model?.nodes || [];
55
+ const edges = model?.edges || [];
56
+
57
+ for (const node of nodes) {
58
+ xs.push(node.x, node.x + node.width);
59
+ ys.push(node.y, node.y + node.height);
60
+ }
61
+ for (const edge of edges) {
62
+ for (const point of edgeWaypoints(model, edge)) {
63
+ xs.push(point.x);
64
+ ys.push(point.y);
65
+ }
66
+ }
67
+ if (viewportBounds) {
68
+ xs.push(viewportBounds.left, viewportBounds.right);
69
+ ys.push(viewportBounds.top, viewportBounds.bottom);
70
+ }
71
+
72
+ const left = Math.floor(Math.min(...xs) - margin);
73
+ const top = Math.floor(Math.min(...ys) - margin);
74
+ const right = Math.ceil(Math.max(...xs) + margin);
75
+ const bottom = Math.ceil(Math.max(...ys) + margin);
76
+ return {
77
+ left,
78
+ top,
79
+ right,
80
+ bottom,
81
+ width: Math.max(MIN_SCENE_SIZE, right - left),
82
+ height: Math.max(MIN_SCENE_SIZE, bottom - top),
83
+ };
84
+ }
85
+
86
+ function wrapLabelText(value, maxTextWidth) {
87
+ const paragraphs = String(value ?? '').split(/\r?\n/);
88
+ const lines = [];
89
+ for (const paragraph of paragraphs) {
90
+ if (!paragraph) {
91
+ lines.push('');
92
+ continue;
93
+ }
94
+ let current = '';
95
+ let currentWidth = 0;
96
+ for (const ch of Array.from(paragraph)) {
97
+ const chWidth = estimateLabelTextWidth(ch);
98
+ if (current && currentWidth + chWidth > maxTextWidth) {
99
+ lines.push(current.trimEnd());
100
+ current = ch.trimStart();
101
+ currentWidth = estimateLabelTextWidth(current);
102
+ } else {
103
+ current += ch;
104
+ currentWidth += chWidth;
105
+ }
106
+ }
107
+ if (current || !lines.length) lines.push(current.trimEnd());
108
+ }
109
+ return lines.length ? lines : [''];
110
+ }
111
+
112
+ export function edgeLabelMetrics(value, options = {}) {
113
+ const tokens = { ...EDGE_LABEL_TOKENS, ...(options || {}) };
114
+ const maxTextWidth = Math.max(24, tokens.maxWidth - tokens.paddingX * 2);
115
+ const lines = wrapLabelText(value, maxTextWidth);
116
+ const textWidth = Math.min(maxTextWidth, Math.max(...lines.map(estimateLabelTextWidth), 0));
117
+ return {
118
+ width: Math.ceil(textWidth + tokens.paddingX * 2),
119
+ height: Math.ceil(lines.length * tokens.lineHeight + tokens.paddingY * 2),
120
+ paddingX: tokens.paddingX,
121
+ paddingY: tokens.paddingY,
122
+ maxWidth: tokens.maxWidth,
123
+ lineHeight: tokens.lineHeight,
124
+ radius: tokens.radius,
125
+ lines,
126
+ };
127
+ }
128
+
129
+ function normalizeRect(rect) {
130
+ if (!rect) return null;
131
+ const left = rect.left ?? rect.x ?? 0;
132
+ const top = rect.top ?? rect.y ?? 0;
133
+ const right = rect.right ?? left + (rect.width ?? 0);
134
+ const bottom = rect.bottom ?? top + (rect.height ?? 0);
135
+ return { left, right, top, bottom };
136
+ }
137
+
138
+ function labelBounds(point, metrics) {
139
+ const left = point.x - metrics.width / 2;
140
+ const top = point.y - metrics.height / 2;
141
+ return {
142
+ x: left,
143
+ y: top,
144
+ width: metrics.width,
145
+ height: metrics.height,
146
+ left,
147
+ right: left + metrics.width,
148
+ top,
149
+ bottom: top + metrics.height,
150
+ };
151
+ }
152
+
153
+ function rectsOverlap(a, b, padding = 0) {
154
+ const obstacle = normalizeRect(b);
155
+ if (!obstacle) return false;
156
+ return a.left < obstacle.right + padding
157
+ && a.right > obstacle.left - padding
158
+ && a.top < obstacle.bottom + padding
159
+ && a.bottom > obstacle.top - padding;
160
+ }
161
+
162
+ /**
163
+ * Places an edge label without consuming the Sequence Flow arrow corridor.
164
+ * Short connector segments move the label perpendicular to the path and away
165
+ * from node/label obstacles instead of painting over the terminal marker.
166
+ */
167
+ export function edgeLabelPlacement(points, metrics, options = {}) {
168
+ if (!points?.length) {
169
+ const point = { x: 0, y: 0 };
170
+ return { ...point, bounds: labelBounds(point, metrics), strategy: 'fallback', segmentIndex: -1 };
171
+ }
172
+ if (points.length === 1) {
173
+ const point = { ...points[0] };
174
+ return { ...point, bounds: labelBounds(point, metrics), strategy: 'fallback', segmentIndex: 0 };
175
+ }
176
+
177
+ const startClearance = options.startClearance ?? 8;
178
+ const endClearance = options.endClearance ?? 16;
179
+ const innerClearance = options.innerClearance ?? 6;
180
+ const obstaclePadding = options.obstaclePadding ?? 6;
181
+ const labelGap = options.labelGap ?? 8;
182
+ const offsetStep = options.offsetStep ?? 20;
183
+ const maxOffset = options.maxOffset ?? 168;
184
+ const obstacles = options.obstacles || [];
185
+ const segments = [];
186
+
187
+ for (let index = 0; index < points.length - 1; index += 1) {
188
+ const a = points[index];
189
+ const b = points[index + 1];
190
+ const dx = b.x - a.x;
191
+ const dy = b.y - a.y;
192
+ const length = Math.hypot(dx, dy);
193
+ if (length < 0.01) continue;
194
+ const ux = dx / length;
195
+ const uy = dy / length;
196
+ segments.push({ index, a, b, length, ux, uy, nx: -uy, ny: ux });
197
+ }
198
+ segments.sort((a, b) => b.length - a.length || a.index - b.index);
199
+
200
+ const isClear = (bounds) => !obstacles.some((obstacle) => rectsOverlap(bounds, obstacle, obstaclePadding));
201
+ for (const segment of segments) {
202
+ const before = segment.index === 0 ? startClearance : innerClearance;
203
+ const after = segment.index === points.length - 2 ? endClearance : innerClearance;
204
+ const axisSize = Math.abs(segment.ux) * metrics.width + Math.abs(segment.uy) * metrics.height;
205
+ const minCenter = before + axisSize / 2;
206
+ const maxCenter = segment.length - after - axisSize / 2;
207
+ if (minCenter > maxCenter) continue;
208
+ const distance = clamp(segment.length / 2, minCenter, maxCenter);
209
+ const point = { x: segment.a.x + segment.ux * distance, y: segment.a.y + segment.uy * distance };
210
+ const bounds = labelBounds(point, metrics);
211
+ if (isClear(bounds)) return { ...point, bounds, strategy: 'inline', segmentIndex: segment.index };
212
+ }
213
+
214
+ for (const segment of segments) {
215
+ const midpoint = { x: (segment.a.x + segment.b.x) / 2, y: (segment.a.y + segment.b.y) / 2 };
216
+ const perpendicularSize = Math.abs(segment.nx) * metrics.width + Math.abs(segment.ny) * metrics.height;
217
+ const initialOffset = perpendicularSize / 2 + labelGap;
218
+ for (let offset = initialOffset; offset <= maxOffset; offset += offsetStep) {
219
+ for (const side of [-1, 1]) {
220
+ const point = { x: midpoint.x + segment.nx * offset * side, y: midpoint.y + segment.ny * offset * side };
221
+ const bounds = labelBounds(point, metrics);
222
+ if (isClear(bounds)) return { ...point, bounds, strategy: 'offset', segmentIndex: segment.index };
223
+ }
224
+ }
225
+ }
226
+
227
+ const point = edgeLabelPoint(points);
228
+ return { ...point, bounds: labelBounds(point, metrics), strategy: 'fallback', segmentIndex: -1 };
229
+ }
230
+
231
+ function el(tag, className, text) {
232
+ const node = document.createElement(tag);
233
+ if (className) node.className = className;
234
+ if (text !== undefined) node.textContent = text;
235
+ return node;
236
+ }
237
+
238
+ function uiIconElement(registry, id, className = 'nova-icon nova-icon-sm') {
239
+ const host = el('span', className);
240
+ host.dataset.icon = id;
241
+ const iconNode = createIconElement(registry.resolve(id, null), { className: 'nova-icon-svg' });
242
+ if (iconNode) host.appendChild(iconNode);
243
+ return host;
244
+ }
245
+
246
+ function svgEl(tag, attrs = {}) {
247
+ const node = document.createElementNS(SVG_NS, tag);
248
+ for (const [key, value] of Object.entries(attrs)) {
249
+ if (value !== undefined && value !== null) node.setAttribute(key, String(value));
250
+ }
251
+ return node;
252
+ }
253
+
254
+ function linearPath(points) {
255
+ if (!points.length) return '';
256
+ return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
257
+ }
258
+
259
+ function routePathData(points, routeStyle = 'rounded', cornerRadius = 14) {
260
+ if (routeStyle === 'straight') return linearPath(points);
261
+ if (routeStyle === 'smooth') return smoothPath(points, Math.max(24, cornerRadius * 1.7));
262
+ return roundedPath(points, cornerRadius);
263
+ }
264
+
265
+ function displaySubtitle(node) {
266
+ const p = node.properties || {};
267
+ if (node.type === 'userTask') return p.assignee || p.candidateGroups || p.candidateUsers || '待配置审批人';
268
+ if (['serviceTask', 'sendTask', 'businessRuleTask'].includes(node.type)) return p.implementation || '待配置执行实现';
269
+ if (node.type === 'scriptTask') return p.scriptFormat || 'Script';
270
+ if (node.type === 'callActivity') return p.calledElement || '待配置调用流程';
271
+ if (node.type === 'receiveTask') return '等待消息或外部触发';
272
+ if (node.type === 'manualTask') return '人工线下处理';
273
+ if (node.type === 'subProcess') return '可折叠子流程';
274
+ if (node.type === 'eventSubProcess') return '事件触发子流程';
275
+ if (node.type === 'transaction') return '事务边界';
276
+ return node.bpmnType?.replace('bpmn:', '') || '';
277
+ }
278
+
279
+ function runtimeStatusIconId(presentation) {
280
+ if (presentation?.status === 'rejected') return 'ui.statusRejected';
281
+ if (presentation?.status === 'completed') return 'ui.statusCompleted';
282
+ if (presentation?.status === 'active') return 'ui.statusActive';
283
+ if (presentation?.status === 'failed') return 'ui.statusFailed';
284
+ if (['skipped', 'cancelled'].includes(presentation?.status)) return 'ui.statusSkipped';
285
+ return 'ui.statusIdle';
286
+ }
287
+
288
+ function edgeLabelPoint(points) {
289
+ if (!points.length) return { x: 0, y: 0 };
290
+ if (points.length === 1) return points[0];
291
+ let best = null;
292
+ for (let i = 0; i < points.length - 1; i += 1) {
293
+ const a = points[i];
294
+ const b = points[i + 1];
295
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
296
+ if (!best || len > best.len) best = { len, a, b };
297
+ }
298
+ return { x: (best.a.x + best.b.x) / 2, y: (best.a.y + best.b.y) / 2 };
299
+ }
300
+
301
+ function addActivityMarkers(nodeEl, visual, registry) {
302
+ if (!visual.activityMarkerIds.length) return;
303
+ const wrap = el('div', 'mb-activity-markers');
304
+ wrap.setAttribute('aria-hidden', 'true');
305
+ visual.activityMarkerIds.forEach((markerId) => {
306
+ const marker = el('span', 'mb-activity-marker');
307
+ const iconNode = createIconElement(registry.resolve(markerId, null), { className: 'mb-activity-marker-icon' });
308
+ if (iconNode) marker.appendChild(iconNode);
309
+ wrap.appendChild(marker);
310
+ });
311
+ nodeEl.appendChild(wrap);
312
+ }
313
+
314
+ export class DiagramRenderer {
315
+ constructor(container, options = {}) {
316
+ if (!container) throw new Error('DiagramRenderer requires a container element.');
317
+ this.container = container;
318
+ this.options = options;
319
+ this.themeController = options.themeController || new ThemeController({ root: container, theme: options.theme, onChange: options.onThemeChange });
320
+ this._ownsThemeController = !options.themeController;
321
+ this.iconRegistry = options.iconRegistry || createDefaultIconRegistry();
322
+ this._nodeContentCleanups = [];
323
+ this.model = options.model || { nodes: [], edges: [] };
324
+ this.mode = options.mode || 'design';
325
+ this.runtime = options.runtime || null;
326
+ this.runtimePresentation = null;
327
+ this.selection = null;
328
+ this.interactionMode = options.interactionMode || 'select';
329
+ this.connectingSource = null;
330
+ this.quickMenuNodeId = null;
331
+ this.zoom = 1;
332
+ this.pan = { x: 0, y: 0 };
333
+ this._isPanning = false;
334
+ this._panStart = null;
335
+ this._isMarquee = false;
336
+ this._spacePressed = false;
337
+ this.alignmentGuides = [];
338
+ this.svgExportOptions = options.svgExport || {};
339
+ this._svgExportPreview = null;
340
+ this._buildDom();
341
+ this.render();
342
+ }
343
+
344
+ _buildDom() {
345
+ this.container.innerHTML = '';
346
+ this.container.classList.add('mb-diagram-host');
347
+
348
+ this.viewport = el('div', 'mb-diagram-viewport');
349
+ this.viewport.dataset.interactionMode = this.interactionMode;
350
+ this.marquee = el('div', 'mb-selection-marquee');
351
+ this.marquee.hidden = true;
352
+ this.world = el('div', 'mb-diagram-world');
353
+ this.edgeSvg = svgEl('svg', { class: 'mb-edge-layer' });
354
+ this.runtimeTransitionSvg = svgEl('svg', { class: 'mb-runtime-transition-layer' });
355
+ this.guideSvg = svgEl('svg', { class: 'mb-guide-layer' });
356
+ this.nodeLayer = el('div', 'mb-node-layer');
357
+ this.edgeLabelSvg = svgEl('svg', { class: 'mb-edge-label-layer' });
358
+
359
+ const defs = svgEl('defs');
360
+ const markers = [
361
+ ['arrow', 'var(--mb-edge)'],
362
+ ['arrow-completed', 'var(--mb-success)'],
363
+ ['arrow-active', 'var(--mb-primary)'],
364
+ ['arrow-failed', 'var(--mb-danger)'],
365
+ ];
366
+ for (const [id] of markers) {
367
+ const marker = svgEl('marker', { id, viewBox: '0 0 10 10', refX: '8.5', refY: '5', markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
368
+ marker.appendChild(svgEl('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'context-stroke' }));
369
+ defs.appendChild(marker);
370
+ }
371
+ this.edgeSvg.appendChild(defs);
372
+
373
+ const runtimeDefs = svgEl('defs');
374
+ for (const id of ['runtime-arrow-forward', 'runtime-arrow-reject', 'runtime-arrow-return', 'runtime-arrow-skip']) {
375
+ const marker = svgEl('marker', { id, viewBox: '0 0 10 10', refX: '8.5', refY: '5', markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
376
+ marker.appendChild(svgEl('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'context-stroke' }));
377
+ runtimeDefs.appendChild(marker);
378
+ }
379
+ this.runtimeTransitionSvg.appendChild(runtimeDefs);
380
+
381
+ this.world.append(this.edgeSvg, this.runtimeTransitionSvg, this.guideSvg, this.nodeLayer, this.edgeLabelSvg);
382
+ this.viewport.append(this.world, this.marquee);
383
+ this.emptyState = el('div', 'mb-scope-empty');
384
+ this.emptyState.hidden = true;
385
+ this.emptyState.append(el('strong', '', '空子流程'), el('span', '', '拖入节点或添加开始事件'));
386
+ this.container.append(this.viewport, this.emptyState);
387
+
388
+ this.viewport.addEventListener('wheel', (event) => {
389
+ event.preventDefault();
390
+ if (event.ctrlKey || event.metaKey) {
391
+ const rect = this.viewport.getBoundingClientRect();
392
+ const sx = event.clientX - rect.left;
393
+ const sy = event.clientY - rect.top;
394
+ const before = this.screenToWorld(event.clientX, event.clientY);
395
+ const factor = event.deltaY < 0 ? 1.1 : 0.9;
396
+ this.zoom = quantizeZoom(this.zoom * factor);
397
+ this.pan.x = sx - before.x * this.zoom;
398
+ this.pan.y = sy - before.y * this.zoom;
399
+ this._applyTransform();
400
+ this.options.onViewportChange?.({ zoom: this.zoom, pan: { ...this.pan } });
401
+ } else {
402
+ this.pan.x -= event.deltaX;
403
+ this.pan.y -= event.deltaY;
404
+ this._applyTransform();
405
+ this.options.onViewportChange?.({ zoom: this.zoom, pan: { ...this.pan } });
406
+ }
407
+ }, { passive: false });
408
+
409
+ this.viewport.addEventListener('pointerdown', (event) => {
410
+ const target = event.target;
411
+ if (target.closest?.('button,input,textarea,select,.mb-quick-menu,.mb-edge-bend,.mb-edge-label-editor')) return;
412
+ const elementTarget = target.closest?.('.mb-node') || target.closest?.('.mb-edge-hit') || target.closest?.('.mb-edge-label-group');
413
+ if (elementTarget && this.interactionMode !== 'pan') return;
414
+ if (event.button !== 0 && event.button !== 1) return;
415
+ this.quickMenuNodeId = null;
416
+ const canvasLeftPan = event.button === 0 && (this.mode !== 'design' || this.interactionMode === 'select');
417
+ const panGesture = event.button === 1 || (event.button === 0 && (this._spacePressed || this.interactionMode === 'pan')) || canvasLeftPan;
418
+ const marqueeGesture = event.button === 0 && this.interactionMode === 'marquee' && !panGesture;
419
+ // Single-select and read-only modes treat a plain left press on empty
420
+ // canvas as a pan candidate so a click can still select the process or
421
+ // clear Viewer selection. Dragging starts after the 5px intent threshold.
422
+ this._isPanning = panGesture && !canvasLeftPan;
423
+ this._isMarquee = false;
424
+ this._panStart = {
425
+ clientX: event.clientX,
426
+ clientY: event.clientY,
427
+ x: this.pan.x,
428
+ y: this.pan.y,
429
+ pointerId: event.pointerId,
430
+ button: event.button,
431
+ kind: canvasLeftPan ? 'pan-candidate' : panGesture ? 'pan' : marqueeGesture ? 'marquee' : 'click',
432
+ mode: event.ctrlKey || event.metaKey ? 'toggle' : event.shiftKey ? 'add' : 'replace',
433
+ moved: false,
434
+ };
435
+ if (this._isPanning) {
436
+ this.viewport.setPointerCapture?.(event.pointerId);
437
+ this.viewport.classList.add('is-panning');
438
+ }
439
+ });
440
+
441
+ this.viewport.addEventListener('pointermove', (event) => {
442
+ if (!this._panStart) return;
443
+ const dx = event.clientX - this._panStart.clientX;
444
+ const dy = event.clientY - this._panStart.clientY;
445
+ if (this._panStart.kind === 'click') {
446
+ if (Math.hypot(dx, dy) >= 5) this._panStart.moved = true;
447
+ return;
448
+ }
449
+ if (this._panStart.kind === 'pan-candidate') {
450
+ if (Math.hypot(dx, dy) < 5) return;
451
+ this._panStart.kind = 'pan';
452
+ }
453
+ if (this._panStart.kind === 'marquee') {
454
+ if (!this._isMarquee && Math.hypot(dx, dy) < 5) return;
455
+ if (!this._isMarquee) {
456
+ this._isMarquee = true;
457
+ this.viewport.setPointerCapture?.(event.pointerId);
458
+ this.viewport.classList.add('is-selecting');
459
+ this.marquee.hidden = false;
460
+ }
461
+ const rect = this.viewport.getBoundingClientRect();
462
+ const left = Math.min(this._panStart.clientX, event.clientX) - rect.left;
463
+ const top = Math.min(this._panStart.clientY, event.clientY) - rect.top;
464
+ this.marquee.style.left = `${left}px`;
465
+ this.marquee.style.top = `${top}px`;
466
+ this.marquee.style.width = `${Math.abs(dx)}px`;
467
+ this.marquee.style.height = `${Math.abs(dy)}px`;
468
+ return;
469
+ }
470
+ if (!this._isPanning) {
471
+ this._isPanning = true;
472
+ this.viewport.setPointerCapture?.(event.pointerId);
473
+ this.viewport.classList.add('is-panning');
474
+ }
475
+ this.pan.x = this._panStart.x + dx;
476
+ this.pan.y = this._panStart.y + dy;
477
+ this._applyTransform();
478
+ this.options.onViewportChange?.({ zoom: this.zoom, pan: { ...this.pan } });
479
+ });
480
+
481
+ const finishPan = (event) => {
482
+ if (!this._panStart) return;
483
+ const start = this._panStart;
484
+ const wasPanning = this._isPanning;
485
+ const wasMarquee = this._isMarquee;
486
+ const wasLeftButton = start.button === 0;
487
+ const pointerId = start.pointerId;
488
+ this._isPanning = false;
489
+ this._isMarquee = false;
490
+ this._panStart = null;
491
+ if (this.viewport.hasPointerCapture?.(pointerId)) this.viewport.releasePointerCapture?.(pointerId);
492
+ this.viewport.classList.remove('is-panning');
493
+ this.viewport.classList.remove('is-selecting');
494
+ this.marquee.hidden = true;
495
+ if (event.type === 'pointercancel') return;
496
+ if (wasMarquee) {
497
+ const from = this.screenToWorld(start.clientX, start.clientY);
498
+ const to = this.screenToWorld(event.clientX, event.clientY);
499
+ this.options.onMarqueeSelect?.({ x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y }, { mode: start.mode, event });
500
+ } else if (!wasPanning && wasLeftButton && !start.moved) this.options.onCanvasClick?.(event);
501
+ };
502
+
503
+ this.viewport.addEventListener('pointerup', finishPan);
504
+ this.viewport.addEventListener('pointercancel', finishPan);
505
+ }
506
+
507
+ _applyTransform() {
508
+ // Keep the compositor translation aligned to physical pixels. Fractional
509
+ // pan values plus a transformed HTML layer are a common source of soft
510
+ // text and 1px borders, especially after fit-to-view.
511
+ const dpr = typeof window === 'undefined' ? 1 : (window.devicePixelRatio || 1);
512
+ this.pan.x = Math.round(this.pan.x * dpr) / dpr;
513
+ this.pan.y = Math.round(this.pan.y * dpr) / dpr;
514
+ this.world.style.transform = `translate(${this.pan.x}px, ${this.pan.y}px) scale(${this.zoom})`;
515
+ this.viewport.classList.toggle('is-low-detail', this.zoom < 0.7);
516
+ this.viewport.classList.toggle('is-overview', this.zoom < 0.6);
517
+
518
+ // The grid belongs to world coordinates as well. Moving/scaling its pattern
519
+ // with the viewport makes the canvas feel genuinely unbounded instead of
520
+ // exposing a stationary background behind a finite diagram surface.
521
+ const showGrid = this.model.settings?.showGrid !== false;
522
+ this.viewport.classList.toggle('is-grid-hidden', !showGrid);
523
+ const baseGrid = this.model.settings?.gridSize || 16;
524
+ const scaledGrid = Math.max(4, baseGrid * this.zoom);
525
+ const mod = (value, size) => ((value % size) + size) % size;
526
+ this.viewport.style.backgroundSize = `${scaledGrid}px ${scaledGrid}px`;
527
+ this.viewport.style.backgroundPosition = `${mod(this.pan.x, scaledGrid)}px ${mod(this.pan.y, scaledGrid)}px`;
528
+ }
529
+
530
+ setSpacePressed(active) {
531
+ this._spacePressed = Boolean(active);
532
+ this.viewport.classList.toggle('is-pan-ready', this._spacePressed);
533
+ }
534
+
535
+ setInteractionMode(mode) {
536
+ if (!['select', 'marquee', 'pan'].includes(mode)) return false;
537
+ this.interactionMode = mode;
538
+ this.viewport.dataset.interactionMode = mode;
539
+ this.render();
540
+ return true;
541
+ }
542
+
543
+ _visibleWorldBounds() {
544
+ const rect = this.viewport.getBoundingClientRect();
545
+ return {
546
+ left: -this.pan.x / this.zoom,
547
+ top: -this.pan.y / this.zoom,
548
+ right: (rect.width - this.pan.x) / this.zoom,
549
+ bottom: (rect.height - this.pan.y) / this.zoom,
550
+ };
551
+ }
552
+
553
+ _syncSceneBounds() {
554
+ this.sceneBounds = computeSceneBounds(this.model, this._visibleWorldBounds());
555
+ const bounds = this.sceneBounds;
556
+ for (const layer of [this.edgeSvg, this.runtimeTransitionSvg, this.guideSvg, this.edgeLabelSvg]) {
557
+ layer.style.left = `${bounds.left}px`;
558
+ layer.style.top = `${bounds.top}px`;
559
+ layer.style.width = `${bounds.width}px`;
560
+ layer.style.height = `${bounds.height}px`;
561
+ layer.setAttribute('width', String(bounds.width));
562
+ layer.setAttribute('height', String(bounds.height));
563
+ layer.setAttribute('viewBox', `${bounds.left} ${bounds.top} ${bounds.width} ${bounds.height}`);
564
+ }
565
+ }
566
+
567
+ screenToWorld(clientX, clientY) {
568
+ const rect = this.viewport.getBoundingClientRect();
569
+ return {
570
+ x: (clientX - rect.left - this.pan.x) / this.zoom,
571
+ y: (clientY - rect.top - this.pan.y) / this.zoom,
572
+ };
573
+ }
574
+
575
+ openQuickMenu(nodeId) {
576
+ if (this.mode !== 'design' || !this.model.nodes.some((node) => node.id === nodeId)) return false;
577
+ this.quickMenuNodeId = nodeId;
578
+ this.render();
579
+ return true;
580
+ }
581
+
582
+ editEdgeName(edgeId) {
583
+ if (this.mode !== 'design') return false;
584
+ const edge = this.model.edges.find((candidate) => candidate.id === edgeId);
585
+ if (!edge) return false;
586
+ this._beginEdgeLabelEdit(edge, edgeLabelPoint(edgeWaypoints(this.model, edge)));
587
+ return true;
588
+ }
589
+
590
+ closeTransientOverlays() {
591
+ const hadQuickMenu = Boolean(this.quickMenuNodeId || this.nodeLayer?.querySelector?.('.mb-quick-menu'));
592
+ this.quickMenuNodeId = null;
593
+ this.nodeLayer?.querySelectorAll?.('.mb-quick-menu').forEach((node) => node.remove());
594
+ this.edgeLabelSvg?.querySelectorAll?.('.mb-edge-label-editor').forEach((node) => node.remove());
595
+ return hadQuickMenu;
596
+ }
597
+
598
+ setAlignmentGuides(guides = []) { this.alignmentGuides = guides; }
599
+ setModel(model) { this.model = model; this.render(); }
600
+ setMode(mode) { this.mode = mode; this.render(); }
601
+ setRuntime(runtime) { this.runtime = runtime; this.render(); }
602
+ setSelection(selection) {
603
+ this.selection = selection;
604
+ if (selection?.kind !== 'node' || selection.id !== this.quickMenuNodeId) this.quickMenuNodeId = null;
605
+ this.render();
606
+ }
607
+ setConnectingSource(nodeId) {
608
+ this.connectingSource = nodeId;
609
+ this.container.classList.toggle('is-connecting', Boolean(nodeId));
610
+ this.render();
611
+ }
612
+ getViewportState() { return { zoom: this.zoom, pan: { ...this.pan } }; }
613
+ setViewportState(state) {
614
+ if (!state) return false;
615
+ this.zoom = quantizeZoom(state.zoom || 1);
616
+ this.pan = { x: Number(state.pan?.x || 0), y: Number(state.pan?.y || 0) };
617
+ this._applyTransform();
618
+ this.options.onViewportChange?.({ zoom: this.zoom, pan: { ...this.pan } });
619
+ return true;
620
+ }
621
+ destroy() {
622
+ this._svgExportPreview?.close?.({ immediate: true });
623
+ this._svgExportPreview = null;
624
+ this._nodeContentCleanups.splice(0).forEach((cleanup) => cleanup?.());
625
+ this.container.innerHTML = '';
626
+ this.container.classList.remove('mb-diagram-host', 'is-connecting');
627
+ if (this._ownsThemeController) this.themeController.destroy();
628
+ }
629
+
630
+ setTheme(theme) { return this.themeController.setTheme(theme); }
631
+ setThemeMode(mode) { return this.themeController.setMode(mode); }
632
+ getThemeState() { return this.themeController.getState(); }
633
+
634
+ exportSvg(options = {}) {
635
+ return exportDiagramSvg({
636
+ document: this.container.ownerDocument,
637
+ root: this.container,
638
+ model: this.model,
639
+ visualModel: this.options.getVisualModel?.() || this.options.visualModel || this.model,
640
+ runtime: this.runtime,
641
+ runtimePresentation: this.runtimePresentation,
642
+ runtimeAppearance: this.options.runtimeAppearance,
643
+ themeController: this.themeController,
644
+ iconRegistry: this.iconRegistry,
645
+ nodeRenderers: options.nodeRenderers || this.svgExportOptions.nodeRenderers,
646
+ htmlNodeRenderers: this.options.nodeRenderers,
647
+ htmlNodeRenderer: this.options.nodeRenderer,
648
+ mode: this.mode,
649
+ label: options.label || this.svgExportOptions.label,
650
+ }, { ...this.svgExportOptions, ...options });
651
+ }
652
+
653
+ openSvgExportPreview(options = {}) {
654
+ if (this._svgExportPreview && !this._svgExportPreview.closed) {
655
+ this._svgExportPreview.focus();
656
+ return this._svgExportPreview;
657
+ }
658
+ this._svgExportPreview = openSvgExportPreview({
659
+ container: this.container,
660
+ title: options.previewTitle || '导出 SVG',
661
+ initialTheme: options.theme || 'current',
662
+ createArtifact: (previewOptions) => this.exportSvg({ ...options, ...previewOptions }),
663
+ onClose: () => { this._svgExportPreview = null; },
664
+ onDownload: options.onDownload,
665
+ });
666
+ return this._svgExportPreview;
667
+ }
668
+
669
+ zoomBy(delta) {
670
+ const rect = this.viewport.getBoundingClientRect();
671
+ const cx = rect.left + rect.width / 2;
672
+ const cy = rect.top + rect.height / 2;
673
+ const before = this.screenToWorld(cx, cy);
674
+ this.zoom = quantizeZoom(this.zoom * delta);
675
+ this.pan.x = rect.width / 2 - before.x * this.zoom;
676
+ this.pan.y = rect.height / 2 - before.y * this.zoom;
677
+ this._applyTransform();
678
+ this.options.onViewportChange?.({ zoom: this.zoom, pan: { ...this.pan } });
679
+ }
680
+
681
+ fitView(padding = 90, options = {}) {
682
+ if (!this.model.nodes.length) {
683
+ this.zoom = 1;
684
+ this.pan = { x: 0, y: 0 };
685
+ this._applyTransform();
686
+ this.options.onViewportChange?.({ zoom: this.zoom, pan: { ...this.pan } });
687
+ return;
688
+ }
689
+ const visible = this.model.nodes.filter((n) => !['group'].includes(n.type));
690
+ const minX = Math.min(...visible.map((n) => n.x));
691
+ const minY = Math.min(...visible.map((n) => n.y));
692
+ const maxX = Math.max(...visible.map((n) => n.x + n.width));
693
+ const maxY = Math.max(...visible.map((n) => n.y + n.height));
694
+ const boundsW = maxX - minX || 1;
695
+ const boundsH = maxY - minY || 1;
696
+ const rect = this.viewport.getBoundingClientRect();
697
+ const maxZoom = options.maxZoom ?? 1.15;
698
+ const minZoom = options.minZoom ?? 0.25;
699
+ const rawZoom = Math.min((rect.width - padding * 2) / boundsW, (rect.height - padding * 2) / boundsH, maxZoom);
700
+ let nextZoom = clamp(rawZoom, minZoom, maxZoom);
701
+ if (options.snapZoom !== false) nextZoom = quantizeZoom(nextZoom, options.zoomStep ?? 0.05);
702
+ this.zoom = clamp(nextZoom, minZoom, maxZoom);
703
+
704
+ const renderedW = boundsW * this.zoom;
705
+ const renderedH = boundsH * this.zoom;
706
+ const contentTooWide = renderedW > rect.width - padding * 2;
707
+ const contentTooTall = renderedH > rect.height - padding * 2;
708
+ this.pan.x = options.alignX === 'start' && contentTooWide
709
+ ? padding - minX * this.zoom
710
+ : (rect.width - renderedW) / 2 - minX * this.zoom;
711
+ this.pan.y = options.alignY === 'start' && contentTooTall
712
+ ? padding - minY * this.zoom
713
+ : (rect.height - renderedH) / 2 - minY * this.zoom;
714
+ this._applyTransform();
715
+ this.options.onViewportChange?.({ zoom: this.zoom, pan: { ...this.pan } });
716
+ }
717
+
718
+ fitReadable(padding = 72) {
719
+ // Designer should not automatically shrink a wide process to 40–60%.
720
+ // At that scale transformed HTML text becomes physically tiny and looks
721
+ // blurred. Keep a readable floor and let the user pan horizontally.
722
+ this.fitView(padding, { minZoom: 0.85, maxZoom: 1, alignX: 'start', snapZoom: true });
723
+ }
724
+
725
+ actualSize(padding = 72) {
726
+ if (!this.model.nodes.length) return;
727
+ const visible = this.model.nodes.filter((n) => !['group'].includes(n.type));
728
+ const minX = Math.min(...visible.map((n) => n.x));
729
+ const minY = Math.min(...visible.map((n) => n.y));
730
+ const maxX = Math.max(...visible.map((n) => n.x + n.width));
731
+ const maxY = Math.max(...visible.map((n) => n.y + n.height));
732
+ const boundsW = maxX - minX || 1;
733
+ const boundsH = maxY - minY || 1;
734
+ const rect = this.viewport.getBoundingClientRect();
735
+ this.zoom = 1;
736
+ this.pan.x = boundsW > rect.width - padding * 2
737
+ ? padding - minX
738
+ : (rect.width - boundsW) / 2 - minX;
739
+ this.pan.y = boundsH > rect.height - padding * 2
740
+ ? padding - minY
741
+ : (rect.height - boundsH) / 2 - minY;
742
+ this._applyTransform();
743
+ this.options.onViewportChange?.({ zoom: this.zoom, pan: { ...this.pan } });
744
+ }
745
+
746
+ render() {
747
+ const presenter = this.options.runtimePresenter || ((context) => createRuntimePresentation(context));
748
+ this.runtimePresentation = this.runtime
749
+ ? presenter({ model: this.model, runtime: this.runtime, appearance: this.options.runtimeAppearance })
750
+ : createRuntimePresentation({ model: this.model, runtime: null, appearance: this.options.runtimeAppearance });
751
+ this._syncSceneBounds();
752
+ this._renderEdges();
753
+ this._renderRuntimeTransitions();
754
+ this._renderGuides();
755
+ this._renderNodes();
756
+ this._applyTransform();
757
+ this.container.dataset.mode = this.mode;
758
+ if (this.emptyState) this.emptyState.hidden = this.model.nodes.length > 0 || !this.options.showEmptyState;
759
+ }
760
+
761
+ _renderGuides() {
762
+ this.guideSvg.replaceChildren();
763
+ if (this.model.settings?.alignmentGuides === false) return;
764
+ const bounds = this.sceneBounds || computeSceneBounds(this.model);
765
+ for (const guide of this.alignmentGuides || []) {
766
+ const attrs = guide.orientation === 'vertical'
767
+ ? { x1: guide.position, y1: bounds.top, x2: guide.position, y2: bounds.bottom }
768
+ : { x1: bounds.left, y1: guide.position, x2: bounds.right, y2: guide.position };
769
+ this.guideSvg.appendChild(svgEl('line', { ...attrs, class: `mb-alignment-guide kind-${guide.kind || 'center'}` }));
770
+ }
771
+ }
772
+
773
+ _renderRuntimeTransitions() {
774
+ const defs = this.runtimeTransitionSvg.querySelector('defs');
775
+ this.runtimeTransitionSvg.replaceChildren(defs);
776
+ if (this.mode !== 'instance') return;
777
+ const transitions = this.runtimePresentation.getTransitions()
778
+ .filter((item) => ['reject', 'return'].includes(item.type) && item.visible)
779
+ .sort((a, b) => String(a.occurredAt || '').localeCompare(String(b.occurredAt || '')) || String(a.id).localeCompare(String(b.id)));
780
+ const occupiedRuntimeRoutes = [];
781
+ const labelObstacles = (this._edgeLabelObstacles || []).map((bounds) => ({
782
+ x: bounds.x,
783
+ y: bounds.y,
784
+ width: bounds.width,
785
+ height: bounds.height,
786
+ }));
787
+ transitions.forEach((transition) => {
788
+ const metrics = edgeLabelMetrics(transition.label, { maxWidth: 176 });
789
+ const route = routeRuntimeTransition(this.model, transition, {
790
+ occupiedRoutes: occupiedRuntimeRoutes,
791
+ obstacles: labelObstacles,
792
+ labelSize: { width: metrics.width, height: metrics.height },
793
+ });
794
+ if (!route) return;
795
+ const points = route.points;
796
+ occupiedRuntimeRoutes.push(points);
797
+ labelObstacles.push({
798
+ x: route.labelPoint.x - metrics.width / 2,
799
+ y: route.labelPoint.y - metrics.height / 2,
800
+ width: metrics.width,
801
+ height: metrics.height,
802
+ });
803
+ const group = svgEl('g', {
804
+ class: `mb-runtime-transition type-${transition.type} ${transition.latest ? 'is-latest' : 'is-history'}`,
805
+ 'data-transition-id': transition.id,
806
+ role: 'button',
807
+ tabindex: '0',
808
+ 'aria-label': `${transition.label}${transition.operator ? `,操作人:${transition.operator}` : ''}`,
809
+ });
810
+ applyRuntimeTone(group, this.options.runtimeAppearance?.resolveTransition(transition).tone);
811
+ const corner = this.model.settings?.cornerRadius ?? 14;
812
+ const routeStyle = this.model.settings?.edgeStyle || 'rounded';
813
+ const pathData = routePathData(points, routeStyle, corner);
814
+ group.appendChild(svgEl('path', {
815
+ d: pathData,
816
+ class: 'mb-runtime-transition-hit',
817
+ fill: 'none',
818
+ }));
819
+ group.appendChild(svgEl('path', {
820
+ d: pathData,
821
+ class: 'mb-runtime-transition-path',
822
+ fill: 'none',
823
+ 'marker-end': `url(#runtime-arrow-${transition.type})`,
824
+ }));
825
+ const labelX = route.labelPoint.x;
826
+ const labelY = route.labelPoint.y;
827
+ const label = svgEl('g', { class: 'mb-runtime-transition-label', transform: `translate(${labelX - metrics.width / 2} ${labelY - metrics.height / 2})` });
828
+ label.append(
829
+ svgEl('rect', { x: 0, y: 0, width: metrics.width, height: metrics.height, rx: metrics.radius, ry: metrics.radius }),
830
+ svgEl('text', { x: metrics.width / 2, y: metrics.paddingY + metrics.lineHeight * 0.74, 'text-anchor': 'middle' }),
831
+ );
832
+ label.querySelector('text').textContent = transition.label;
833
+ group.appendChild(label);
834
+ group.addEventListener('click', (event) => {
835
+ event.stopPropagation();
836
+ this.options.onRuntimeTransitionClick?.(transition, event);
837
+ });
838
+ group.addEventListener('keydown', (event) => {
839
+ if (!['Enter', ' '].includes(event.key)) return;
840
+ event.preventDefault();
841
+ event.stopPropagation();
842
+ this.options.onRuntimeTransitionClick?.(transition, event);
843
+ });
844
+ this.runtimeTransitionSvg.appendChild(group);
845
+ });
846
+ }
847
+
848
+ _renderEdges() {
849
+ const defs = this.edgeSvg.querySelector('defs');
850
+ this.edgeSvg.replaceChildren(defs);
851
+ this.edgeLabelSvg.replaceChildren();
852
+ const nodeObstacles = this.model.nodes.map((node) => ({
853
+ x: node.x,
854
+ y: node.y,
855
+ width: node.width,
856
+ height: node.height,
857
+ }));
858
+ const labelObstacles = [];
859
+
860
+ for (const edge of this.model.edges) {
861
+ const points = edgeWaypoints(this.model, edge);
862
+ if (points.length < 2) continue;
863
+ const corner = edge.cornerRadius ?? this.model.settings?.cornerRadius ?? 14;
864
+ const routeStyle = edge.routeStyle || this.model.settings?.edgeStyle || 'rounded';
865
+ const pathData = routePathData(points, routeStyle, corner);
866
+
867
+ const edgeStatus = this.runtimePresentation.getEdge(edge.id).status;
868
+ const selected = this.selection?.kind === 'edge' && this.selection.id === edge.id;
869
+ const classes = [
870
+ 'mb-edge-group', `status-${edgeStatus}`, selected ? 'is-selected' : '', `type-${edge.type || 'sequenceFlow'}`,
871
+ ].filter(Boolean).join(' ');
872
+ const group = svgEl('g', { class: classes, 'data-edge-id': edge.id });
873
+ if (this.mode === 'instance') applyRuntimeTone(group, this.options.runtimeAppearance?.resolveStatus(edgeStatus));
874
+ const pathAttrs = {
875
+ d: pathData,
876
+ class: 'mb-edge-path',
877
+ fill: 'none',
878
+ };
879
+ if ((edge.type || 'sequenceFlow') === 'sequenceFlow') pathAttrs['marker-end'] = `url(#arrow${edgeStatus === 'idle' ? '' : `-${edgeStatus}`})`;
880
+ const path = svgEl('path', pathAttrs);
881
+ const hit = svgEl('path', { d: pathData, class: 'mb-edge-hit', fill: 'none' });
882
+ let labelPosition = edgeLabelPoint(points);
883
+ hit.addEventListener('click', (event) => {
884
+ event.stopPropagation();
885
+ this.options.onEdgeClick?.(edge, event);
886
+ });
887
+ hit.addEventListener('dblclick', (event) => {
888
+ event.stopPropagation();
889
+ this._beginEdgeLabelEdit(edge, labelPosition);
890
+ });
891
+ group.append(path, hit);
892
+
893
+ const sourceNode = this.model.nodes.find((node) => node.id === edge.source);
894
+ const shouldShowPlaceholder = selected && sourceNode && NODE_DEFINITIONS[sourceNode.type]?.kind === 'gateway';
895
+ if (edge.name || shouldShowPlaceholder) {
896
+ const textValue = edge.name || '设置分支名称';
897
+ const metrics = edgeLabelMetrics(textValue);
898
+ const pos = edgeLabelPlacement(points, metrics, {
899
+ obstacles: [...nodeObstacles, ...labelObstacles],
900
+ endClearance: (edge.type || 'sequenceFlow') === 'sequenceFlow' ? 16 : 6,
901
+ });
902
+ labelPosition = pos;
903
+ const { width, height, radius, paddingY, lineHeight, lines } = metrics;
904
+ const labelClasses = [
905
+ 'mb-edge-label-group',
906
+ edge.name ? '' : 'is-placeholder',
907
+ selected ? 'is-selected' : '',
908
+ ].filter(Boolean).join(' ');
909
+ const label = svgEl('g', { class: labelClasses, 'data-edge-id': edge.id, transform: `translate(${pos.x - width / 2} ${pos.y - height / 2})` });
910
+ label.appendChild(svgEl('rect', { x: 0, y: 0, width, height, rx: radius, ry: radius, class: 'mb-edge-label-bg' }));
911
+ const text = svgEl('text', { 'text-anchor': 'middle', class: 'mb-edge-label' });
912
+ lines.forEach((line, index) => {
913
+ const tspan = svgEl('tspan', {
914
+ x: width / 2,
915
+ y: paddingY + lineHeight * (index + 0.74),
916
+ class: 'mb-edge-label-line',
917
+ });
918
+ tspan.textContent = line || ' ';
919
+ text.appendChild(tspan);
920
+ });
921
+ label.appendChild(text);
922
+ label.addEventListener('click', (event) => {
923
+ event.stopPropagation();
924
+ this.options.onEdgeClick?.(edge, event);
925
+ });
926
+ label.addEventListener('dblclick', (event) => {
927
+ event.stopPropagation();
928
+ this._beginEdgeLabelEdit(edge, pos);
929
+ });
930
+ this.edgeLabelSvg.appendChild(label);
931
+ labelObstacles.push(pos.bounds);
932
+ }
933
+
934
+ this.edgeSvg.appendChild(group);
935
+
936
+ if (selected && this.mode === 'design') {
937
+ const handlePoints = edge.waypoints?.length ? edge.waypoints : points;
938
+ for (let index = 1; index < handlePoints.length - 1; index += 1) {
939
+ const point = handlePoints[index];
940
+ const handle = svgEl('circle', { cx: point.x, cy: point.y, r: 5.5, class: 'mb-edge-bend', 'data-index': index });
941
+ handle.addEventListener('pointerdown', (event) => {
942
+ event.stopPropagation();
943
+ event.preventDefault();
944
+ this.options.onEdgeBendPointerDown?.(edge, index, handlePoints.map((p) => ({ ...p })), event);
945
+ });
946
+ this.edgeSvg.appendChild(handle);
947
+ }
948
+ }
949
+ }
950
+ this._edgeLabelObstacles = labelObstacles;
951
+ }
952
+
953
+ _beginEdgeLabelEdit(edge, pos) {
954
+ if (this.mode !== 'design') return;
955
+ this.edgeLabelSvg.querySelectorAll('.mb-edge-label-editor').forEach((node) => node.remove());
956
+ const foreign = svgEl('foreignObject', { x: pos.x - 84, y: pos.y - 18, width: 168, height: 36, class: 'mb-edge-label-editor' });
957
+ const wrap = document.createElementNS(XHTML_NS, 'div');
958
+ wrap.className = 'mb-edge-label-editor-wrap';
959
+ const input = document.createElementNS(XHTML_NS, 'input');
960
+ input.className = 'mb-edge-label-input';
961
+ input.value = edge.name || '';
962
+ input.placeholder = '输入分支名称';
963
+ wrap.appendChild(input);
964
+ foreign.appendChild(wrap);
965
+ this.edgeLabelSvg.appendChild(foreign);
966
+ const commit = () => {
967
+ if (!foreign.isConnected) return;
968
+ const value = input.value.trim();
969
+ foreign.remove();
970
+ this.options.onEdgeNameChange?.(edge, value);
971
+ };
972
+ input.addEventListener('keydown', (event) => {
973
+ if (event.key === 'Enter') { event.preventDefault(); commit(); }
974
+ if (event.key === 'Escape') { event.preventDefault(); foreign.remove(); }
975
+ });
976
+ input.addEventListener('blur', commit, { once: true });
977
+ requestAnimationFrame(() => { input.focus(); input.select(); });
978
+ }
979
+
980
+ _renderNodes() {
981
+ this._nodeContentCleanups.splice(0).forEach((cleanup) => cleanup?.());
982
+ this.nodeLayer.innerHTML = '';
983
+ const zOrder = { participant: 0, lane: 1, group: 2, container: 3, data: 4, dataStore: 4, annotation: 4, task: 5, gateway: 6, event: 7, boundary: 8 };
984
+ const nodes = [...this.model.nodes].sort((a, b) => (zOrder[NODE_DEFINITIONS[a.type]?.kind] ?? 5) - (zOrder[NODE_DEFINITIONS[b.type]?.kind] ?? 5));
985
+
986
+ for (const node of nodes) {
987
+ const def = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
988
+ const runtimeState = this.runtimePresentation.getNode(node.id);
989
+ const selected = (this.selection?.kind === 'node' && this.selection.id === node.id)
990
+ || (this.selection?.kind === 'multi' && this.selection.items?.some((item) => item.kind === 'node' && item.id === node.id));
991
+ const connectingSourceNode = this.connectingSource
992
+ ? this.model.nodes.find((candidate) => candidate.id === this.connectingSource)
993
+ : null;
994
+ const isConnectingSource = this.connectingSource === node.id;
995
+ const isConnectTarget = Boolean(
996
+ connectingSourceNode
997
+ && !isConnectingSource
998
+ && this.options.canConnect?.(connectingSourceNode, node)
999
+ );
1000
+ const swimlaneLabelPlacement = ['participant', 'lane'].includes(def.kind)
1001
+ ? resolveSwimlaneLabelPlacement(this.model, node)
1002
+ : '';
1003
+ const classes = [
1004
+ 'mb-node', `mb-node-${def.kind}`, `mb-node-type-${node.type}`, `status-${runtimeState.pathStatus || runtimeState.status}`,
1005
+ swimlaneLabelPlacement ? `mb-swimlane-label-${swimlaneLabelPlacement}` : '',
1006
+ selected ? 'is-selected' : '',
1007
+ isConnectingSource ? 'is-connecting-source' : '',
1008
+ isConnectTarget ? 'is-connect-target' : '',
1009
+ ].filter(Boolean).join(' ');
1010
+
1011
+ const nodeEl = el('div', classes);
1012
+ nodeEl.dataset.nodeId = node.id;
1013
+ if (this.mode === 'instance') applyRuntimeTone(nodeEl, this.options.runtimeAppearance?.resolveStatus(runtimeState.status));
1014
+ nodeEl.style.left = `${node.x}px`;
1015
+ nodeEl.style.top = `${node.y}px`;
1016
+ nodeEl.style.width = `${node.width}px`;
1017
+ nodeEl.style.height = `${node.height}px`;
1018
+ nodeEl.tabIndex = 0;
1019
+
1020
+ const visual = this._resolveNodeVisual(node);
1021
+ this._renderNodeShape(nodeEl, node, def, runtimeState, visual);
1022
+ if (['task', 'container'].includes(def.kind)) addActivityMarkers(nodeEl, visual, this.iconRegistry);
1023
+
1024
+ if (this.mode === 'design') this._renderDesignControls(nodeEl, node, def, selected && this.selection?.kind !== 'multi' && this.interactionMode !== 'pan');
1025
+
1026
+ nodeEl.addEventListener('click', (event) => {
1027
+ event.stopPropagation();
1028
+ this.options.onNodeClick?.(node, event);
1029
+ });
1030
+ nodeEl.addEventListener('pointerdown', (event) => {
1031
+ if (event.target.closest?.('button,input,.mb-quick-menu')) return;
1032
+ // In connect-existing mode a target click must never accidentally start
1033
+ // dragging the target. This also makes left/right/up/down targets behave
1034
+ // identically instead of depending on the target's visible port.
1035
+ if (this.connectingSource && node.id !== this.connectingSource) {
1036
+ event.preventDefault();
1037
+ return;
1038
+ }
1039
+ this.options.onNodePointerDown?.(node, event);
1040
+ });
1041
+ nodeEl.addEventListener('dblclick', (event) => {
1042
+ event.stopPropagation();
1043
+ this.options.onNodeDoubleClick?.(node, event);
1044
+ });
1045
+ this.nodeLayer.appendChild(nodeEl);
1046
+ }
1047
+
1048
+ if (this.mode === 'design' && this.quickMenuNodeId) this._renderQuickMenu();
1049
+ }
1050
+
1051
+ _renderNodeShape(nodeEl, node, def, runtimeState, visual) {
1052
+ if (def.kind === 'event' || def.kind === 'boundary') {
1053
+ const shape = el('div', 'mb-event-shape');
1054
+ shape.dataset.stage = def.eventStage || 'intermediate';
1055
+ shape.dataset.role = def.eventRole || '';
1056
+ if (def.kind === 'boundary' && node.properties?.cancelActivity === false) shape.classList.add('is-noninterrupting');
1057
+ const glyph = el('span', 'mb-event-glyph');
1058
+ if (!this._renderCustomNodeContent(glyph, node, def, runtimeState, visual)) {
1059
+ const iconNode = visual.iconId
1060
+ ? createIconElement(this.iconRegistry.resolve(visual.iconId, null), { className: 'mb-node-svg-icon', title: def.label })
1061
+ : null;
1062
+ if (iconNode) glyph.appendChild(iconNode);
1063
+ }
1064
+ shape.appendChild(glyph);
1065
+ nodeEl.appendChild(shape);
1066
+ nodeEl.appendChild(el('div', 'mb-node-floating-label', node.name));
1067
+ return;
1068
+ }
1069
+
1070
+ if (def.kind === 'gateway') {
1071
+ const shape = el('div', 'mb-gateway-shape');
1072
+ const symbol = el('span', 'mb-gateway-symbol');
1073
+ if (!this._renderCustomNodeContent(symbol, node, def, runtimeState, visual)) {
1074
+ const iconNode = visual.iconId
1075
+ ? createIconElement(this.iconRegistry.resolve(visual.iconId, null), { className: 'mb-node-svg-icon', title: def.label })
1076
+ : null;
1077
+ if (iconNode) symbol.appendChild(iconNode);
1078
+ }
1079
+ shape.appendChild(symbol);
1080
+ nodeEl.appendChild(shape);
1081
+ if (this.mode === 'design' && node.type === 'parallelGateway') {
1082
+ const role = resolveGatewayRole(this.model, node);
1083
+ if (!role.topologyComplete) {
1084
+ const marker = role.effective === 'Diverging' ? '分' : role.effective === 'Converging' ? '汇' : '?';
1085
+ const badge = el('span', `mb-gateway-role-badge is-${role.status}`, marker);
1086
+ badge.dataset.gatewayRole = role.effective;
1087
+ badge.setAttribute('aria-hidden', 'true');
1088
+ const statusLabel = { incomplete: '结构未完成', conflict: '结构冲突', ambiguous: '角色待判断', valid: '结构正常' }[role.status];
1089
+ badge.title = `${role.effective === 'Diverging' ? '并行分支' : role.effective === 'Converging' ? '并行汇聚' : '角色待判断'} · ${statusLabel}`;
1090
+ nodeEl.appendChild(badge);
1091
+ }
1092
+ }
1093
+ nodeEl.appendChild(el('div', 'mb-node-floating-label', node.name));
1094
+ return;
1095
+ }
1096
+
1097
+ if (def.kind === 'data') {
1098
+ const page = el('div', 'mb-data-object-shape');
1099
+ page.appendChild(el('span', 'mb-data-object-lines', '≡'));
1100
+ nodeEl.appendChild(page);
1101
+ nodeEl.appendChild(el('div', 'mb-node-floating-label', node.name));
1102
+ return;
1103
+ }
1104
+
1105
+ if (def.kind === 'dataStore') {
1106
+ const store = el('div', 'mb-data-store-shape');
1107
+ store.appendChild(el('span', '', '≡'));
1108
+ nodeEl.appendChild(store);
1109
+ nodeEl.appendChild(el('div', 'mb-node-floating-label', node.name));
1110
+ return;
1111
+ }
1112
+
1113
+ if (def.kind === 'annotation') {
1114
+ const annotation = el('div', 'mb-annotation-shape');
1115
+ annotation.appendChild(el('div', 'mb-annotation-text', node.properties?.text || node.name || '说明'));
1116
+ nodeEl.appendChild(annotation);
1117
+ return;
1118
+ }
1119
+
1120
+ if (def.kind === 'group') {
1121
+ nodeEl.appendChild(el('div', 'mb-container-caption', node.properties?.categoryValue || node.name || '分组'));
1122
+ return;
1123
+ }
1124
+
1125
+ if (def.kind === 'participant' || def.kind === 'lane') {
1126
+ const rail = el('div', 'mb-swimlane-rail');
1127
+ rail.appendChild(el('div', 'mb-swimlane-title', node.name || def.label));
1128
+ nodeEl.appendChild(rail);
1129
+ return;
1130
+ }
1131
+
1132
+ const accent = el('div', 'mb-node-accent');
1133
+ nodeEl.appendChild(accent);
1134
+ const customHost = el('div', 'mb-node-custom-content');
1135
+ if (this._renderCustomNodeContent(customHost, node, def, runtimeState, visual)) {
1136
+ nodeEl.appendChild(customHost);
1137
+ return;
1138
+ }
1139
+ const header = el('div', 'mb-node-header');
1140
+ const icon = visual.iconId ? el('span', 'mb-node-icon') : null;
1141
+ const iconNode = visual.iconId
1142
+ ? createIconElement(this.iconRegistry.resolve(visual.iconId, null), { className: 'mb-node-svg-icon', title: def.label })
1143
+ : null;
1144
+ if (iconNode) icon.appendChild(iconNode);
1145
+ const titleWrap = el('div', `mb-node-title-wrap${this.mode === 'instance' ? ' mb-node-runtime-copy' : ''}`);
1146
+ if (this.mode === 'instance') {
1147
+ const requestDetails = (event) => {
1148
+ event.stopPropagation();
1149
+ this.options.onRuntimeDetailsRequest?.({ node, presentation: runtimeState, anchor: event.currentTarget, event });
1150
+ };
1151
+ titleWrap.appendChild(el('div', 'mb-node-title', node.name || def.label));
1152
+ const status = runtimeState.hasDetails ? el('button', `mb-runtime-status-icon status-${runtimeState.status}`) : el('span', `mb-runtime-status-icon status-${runtimeState.status}`);
1153
+ status.appendChild(uiIconElement(this.iconRegistry, runtimeStatusIconId(runtimeState), 'nova-icon nova-icon-sm'));
1154
+ status.title = runtimeState.statusLabel;
1155
+ status.dataset.runtimeStatus = runtimeState.status;
1156
+ applyRuntimeTone(status, this.options.runtimeAppearance?.resolveStatus(runtimeState.status));
1157
+ if (status instanceof HTMLButtonElement) {
1158
+ status.type = 'button';
1159
+ status.setAttribute('aria-label', `查看${node.name || def.label}审批详情,当前状态:${runtimeState.statusLabel}`);
1160
+ status.addEventListener('click', requestDetails);
1161
+ } else {
1162
+ status.setAttribute('aria-label', runtimeState.statusLabel);
1163
+ }
1164
+ nodeEl.appendChild(status);
1165
+ if (runtimeState.summary) {
1166
+ const summary = runtimeState.hasDetails ? el('button', 'mb-node-subtitle mb-runtime-summary', runtimeState.summary) : el('div', 'mb-node-subtitle mb-runtime-summary', runtimeState.summary);
1167
+ summary.title = runtimeState.fullSummary || runtimeState.summary;
1168
+ summary.setAttribute('aria-label', runtimeState.fullSummary || runtimeState.summary);
1169
+ if (summary instanceof HTMLButtonElement) {
1170
+ summary.type = 'button';
1171
+ summary.addEventListener('click', requestDetails);
1172
+ }
1173
+ titleWrap.appendChild(summary);
1174
+ }
1175
+ if (runtimeState.actionSummary) {
1176
+ const action = runtimeState.hasDetails
1177
+ ? el('button', 'mb-runtime-action-summary', runtimeState.actionSummary)
1178
+ : el('div', 'mb-runtime-action-summary', runtimeState.actionSummary);
1179
+ action.title = runtimeState.actionSummary;
1180
+ action.setAttribute('aria-label', runtimeState.actionSummary);
1181
+ if (action instanceof HTMLButtonElement) {
1182
+ action.type = 'button';
1183
+ action.addEventListener('click', requestDetails);
1184
+ }
1185
+ titleWrap.appendChild(action);
1186
+ }
1187
+ } else {
1188
+ titleWrap.appendChild(el('div', 'mb-node-title', node.name || def.label));
1189
+ titleWrap.appendChild(el('div', 'mb-node-subtitle', displaySubtitle(node)));
1190
+ }
1191
+ if (icon) header.append(icon);
1192
+ header.append(titleWrap);
1193
+ nodeEl.appendChild(header);
1194
+ }
1195
+
1196
+ _resolveNodeVisual(node, surface = this.mode === 'instance' ? 'viewer' : 'canvas') {
1197
+ const visualModel = this.options.getVisualModel?.() || this.options.visualModel || this.model;
1198
+ return resolveNodeVisual(node, { surface, model: visualModel });
1199
+ }
1200
+
1201
+ _renderCustomNodeContent(container, node, definition, runtimeState, visual = this._resolveNodeVisual(node)) {
1202
+ const renderers = this.options.nodeRenderers || {};
1203
+ const renderer = renderers[node.type] || renderers[definition.kind] || this.options.nodeRenderer;
1204
+ if (typeof renderer !== 'function') return false;
1205
+ const cleanup = renderer({
1206
+ container,
1207
+ node,
1208
+ definition,
1209
+ runtimeState,
1210
+ runtimePresentation: runtimeState,
1211
+ visual,
1212
+ semanticIconId: visual.iconId,
1213
+ mode: this.mode,
1214
+ model: this.model,
1215
+ themeState: this.themeController.getState(),
1216
+ runtimeAppearance: this.options.runtimeAppearance || null,
1217
+ select: () => this.options.onNodeClick?.(node),
1218
+ openRuntimeDetails: (anchor = container) => this.options.onRuntimeDetailsRequest?.({ node, presentation: runtimeState, anchor }),
1219
+ renderSemanticIcon: (target = container, options = {}) => {
1220
+ if (!visual.iconId || !target?.appendChild) return null;
1221
+ const iconNode = createIconElement(this.iconRegistry.resolve(visual.iconId, null), {
1222
+ className: options.className || 'mb-node-svg-icon',
1223
+ title: options.title ?? definition.label,
1224
+ });
1225
+ if (iconNode) target.appendChild(iconNode);
1226
+ return iconNode;
1227
+ },
1228
+ });
1229
+ if (typeof cleanup === 'function') this._nodeContentCleanups.push(cleanup);
1230
+ return true;
1231
+ }
1232
+
1233
+ _renderDesignControls(nodeEl, node, def, selected) {
1234
+ const flowKinds = new Set(['event', 'boundary', 'task', 'container', 'gateway']);
1235
+ const canIncoming = flowKinds.has(def.kind) && def.kind !== 'boundary' && !['start'].includes(def.eventStage);
1236
+ const canOutgoing = flowKinds.has(def.kind) && !['end'].includes(def.eventStage);
1237
+ const sourceNode = this.connectingSource
1238
+ ? this.model.nodes.find((candidate) => candidate.id === this.connectingSource)
1239
+ : null;
1240
+ const isConnectSource = this.connectingSource === node.id;
1241
+ const isConnectTarget = Boolean(
1242
+ sourceNode
1243
+ && !isConnectSource
1244
+ && canIncoming
1245
+ && this.options.canConnect?.(sourceNode, node)
1246
+ );
1247
+
1248
+ const appendPort = (side, { target = false, onClick } = {}) => {
1249
+ const port = el('button', `mb-port mb-port-${side}${target ? ' mb-port-target' : ''}`);
1250
+ port.type = 'button';
1251
+ port.title = target ? '连接到此节点' : (side === 'right' ? '创建连接' : '输入连接点');
1252
+ port.tabIndex = -1;
1253
+ port.addEventListener('pointerdown', (event) => {
1254
+ event.stopPropagation();
1255
+ if (target) event.preventDefault();
1256
+ });
1257
+ if (onClick) {
1258
+ port.addEventListener('click', (event) => {
1259
+ event.preventDefault();
1260
+ event.stopPropagation();
1261
+ onClick(event);
1262
+ });
1263
+ }
1264
+ nodeEl.appendChild(port);
1265
+ return port;
1266
+ };
1267
+
1268
+ if (this.connectingSource && !isConnectSource) {
1269
+ // While choosing an existing target, expose four equivalent target handles.
1270
+ // The user does not need to reason about source/target sides; the router will
1271
+ // pick the correct anchor after the target is selected.
1272
+ if (isConnectTarget) {
1273
+ const complete = (event) => this.options.onNodeClick?.(node, event);
1274
+ appendPort('left', { target: true, onClick: complete });
1275
+ appendPort('right', { target: true, onClick: complete });
1276
+ appendPort('top', { target: true, onClick: complete });
1277
+ appendPort('bottom', { target: true, onClick: complete });
1278
+ }
1279
+ return;
1280
+ }
1281
+
1282
+ if (canIncoming) appendPort('left');
1283
+ if (canOutgoing) {
1284
+ appendPort('right', { onClick: (event) => this.options.onStartConnect?.(node, event) });
1285
+ }
1286
+
1287
+ if (def.kind === 'lane' && node.containerId) {
1288
+ const siblings = this.model.nodes
1289
+ .filter((candidate) => candidate.type === 'lane' && candidate.containerId === node.containerId)
1290
+ .sort((a, b) => a.y - b.y || a.id.localeCompare(b.id));
1291
+ if (siblings.at(-1)?.id !== node.id) {
1292
+ const divider = el('span', 'mb-lane-divider');
1293
+ divider.title = '调整相邻泳道高度';
1294
+ divider.addEventListener('pointerdown', (event) => {
1295
+ event.preventDefault();
1296
+ event.stopPropagation();
1297
+ this.options.onLaneDividerPointerDown?.(node, event);
1298
+ });
1299
+ nodeEl.appendChild(divider);
1300
+ }
1301
+ }
1302
+ if (!selected) return;
1303
+ const resizeKinds = new Set(['group', 'participant', 'lane']);
1304
+ if (resizeKinds.has(def.kind) && !(def.kind === 'lane' && node.containerId)) {
1305
+ for (const handle of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']) {
1306
+ const resize = el('span', `mb-resize-handle mb-resize-${handle}`);
1307
+ resize.dataset.resizeHandle = handle;
1308
+ resize.setAttribute('aria-hidden', 'true');
1309
+ resize.addEventListener('pointerdown', (event) => {
1310
+ event.preventDefault();
1311
+ event.stopPropagation();
1312
+ this.options.onNodeResizePointerDown?.(node, handle, event);
1313
+ });
1314
+ nodeEl.appendChild(resize);
1315
+ }
1316
+ }
1317
+ const actions = el('div', 'mb-node-actions');
1318
+ if (def.kind === 'container' && this.options.onEnterSubProcess) {
1319
+ const enter = el('button', 'mb-action-btn mb-action-enter');
1320
+ enter.type = 'button';
1321
+ enter.title = '进入子流程';
1322
+ enter.setAttribute('aria-label', '进入子流程');
1323
+ enter.appendChild(uiIconElement(this.iconRegistry, 'ui.enterScope'));
1324
+ enter.addEventListener('pointerdown', (event) => event.stopPropagation());
1325
+ enter.addEventListener('click', (event) => {
1326
+ event.stopPropagation();
1327
+ this.options.onEnterSubProcess?.(node, event);
1328
+ });
1329
+ actions.appendChild(enter);
1330
+ }
1331
+ if (canOutgoing) {
1332
+ const quick = el('button', 'mb-action-btn mb-action-primary');
1333
+ quick.type = 'button';
1334
+ quick.title = '快捷添加下一节点';
1335
+ quick.setAttribute('aria-label', '快捷添加下一节点');
1336
+ quick.appendChild(uiIconElement(this.iconRegistry, 'ui.add'));
1337
+ quick.addEventListener('pointerdown', (event) => event.stopPropagation());
1338
+ quick.addEventListener('click', (event) => {
1339
+ event.stopPropagation();
1340
+ this.quickMenuNodeId = this.quickMenuNodeId === node.id ? null : node.id;
1341
+ this.render();
1342
+ });
1343
+ actions.appendChild(quick);
1344
+
1345
+ const connect = el('button', `mb-action-btn mb-action-connect${isConnectSource ? ' is-active' : ''}`);
1346
+ connect.type = 'button';
1347
+ connect.title = isConnectSource ? '取消连接模式(Esc)' : '连接到已有节点';
1348
+ connect.setAttribute('aria-label', connect.title);
1349
+ connect.appendChild(uiIconElement(this.iconRegistry, 'ui.connect'));
1350
+ connect.setAttribute('aria-pressed', isConnectSource ? 'true' : 'false');
1351
+ connect.addEventListener('pointerdown', (event) => event.stopPropagation());
1352
+ connect.addEventListener('click', (event) => {
1353
+ event.stopPropagation();
1354
+ this.options.onStartConnect?.(node, event);
1355
+ });
1356
+ actions.appendChild(connect);
1357
+ }
1358
+ const isGroup = def.kind === 'group';
1359
+ const remove = el('button', 'mb-action-btn mb-action-danger');
1360
+ remove.type = 'button';
1361
+ remove.title = isGroup ? '取消分组' : '删除节点';
1362
+ remove.setAttribute('aria-label', remove.title);
1363
+ remove.appendChild(uiIconElement(this.iconRegistry, 'ui.delete'));
1364
+ remove.addEventListener('pointerdown', (event) => event.stopPropagation());
1365
+ remove.addEventListener('click', (event) => {
1366
+ event.stopPropagation();
1367
+ if (isGroup) this.options.onUngroup?.(node, event);
1368
+ else this.options.onDeleteNode?.(node, event);
1369
+ });
1370
+ actions.appendChild(remove);
1371
+ nodeEl.appendChild(actions);
1372
+
1373
+ if (isConnectSource) {
1374
+ const hint = el('div', 'mb-connect-mode-hint', '选择目标节点 · Esc 取消');
1375
+ nodeEl.appendChild(hint);
1376
+ }
1377
+ }
1378
+
1379
+ _renderQuickMenu() {
1380
+ const source = this.model.nodes.find((node) => node.id === this.quickMenuNodeId);
1381
+ if (!source) return;
1382
+ const menu = el('div', 'mb-quick-menu');
1383
+ menu.style.left = `${source.x + source.width + 58}px`;
1384
+ menu.style.top = `${source.y + source.height / 2 - 206}px`;
1385
+ menu.addEventListener('pointerdown', (event) => event.stopPropagation());
1386
+ menu.addEventListener('click', (event) => event.stopPropagation());
1387
+
1388
+ const head = el('div', 'mb-quick-menu-head');
1389
+ const headText = el('div');
1390
+ headText.appendChild(el('div', 'mb-quick-menu-title', '添加下一节点'));
1391
+ headText.appendChild(el('div', 'mb-quick-menu-subtitle', '自动连线并智能摆放'));
1392
+ const close = el('button', 'mb-quick-menu-close');
1393
+ close.type = 'button';
1394
+ close.title = '关闭';
1395
+ close.setAttribute('aria-label', '关闭');
1396
+ close.appendChild(uiIconElement(this.iconRegistry, 'ui.close'));
1397
+ close.addEventListener('click', () => { this.quickMenuNodeId = null; this.render(); });
1398
+ head.append(headText, close);
1399
+ menu.appendChild(head);
1400
+
1401
+ const search = el('input', 'mb-quick-menu-search');
1402
+ search.placeholder = '搜索 BPMN 节点…';
1403
+ menu.appendChild(search);
1404
+
1405
+ const body = el('div', 'mb-quick-menu-body');
1406
+ const groups = [
1407
+ PALETTE_GROUPS.find((group) => group.id === 'favorites'),
1408
+ PALETTE_GROUPS.find((group) => group.id === 'tasks'),
1409
+ PALETTE_GROUPS.find((group) => group.id === 'gateways'),
1410
+ PALETTE_GROUPS.find((group) => group.id === 'intermediateEvents'),
1411
+ PALETTE_GROUPS.find((group) => group.id === 'endEvents'),
1412
+ PALETTE_GROUPS.find((group) => group.id === 'activities'),
1413
+ ].filter(Boolean);
1414
+
1415
+ for (const group of groups) {
1416
+ const section = el('div', 'mb-quick-menu-section');
1417
+ section.dataset.group = group.id;
1418
+ section.appendChild(el('div', 'mb-quick-menu-section-title', group.label));
1419
+ const grid = el('div', 'mb-quick-menu-grid');
1420
+ const quickItems = group.types.flatMap((type) => type === 'parallelGateway'
1421
+ ? [PARALLEL_GATEWAY_PRESETS.diverging, PARALLEL_GATEWAY_PRESETS.converging]
1422
+ : [{ id: type, label: NODE_DEFINITIONS[type]?.label, iconId: resolveNodeVisual(type, { surface: 'palette' }).iconId, nodeType: type, preset: undefined }]);
1423
+ for (const quickItem of quickItems) {
1424
+ const type = quickItem.nodeType;
1425
+ if (type === 'startEvent') continue;
1426
+ const def = NODE_DEFINITIONS[type];
1427
+ if (!def || this.options.canCreateNodeType?.(type) === false) continue;
1428
+ const item = el('button', 'mb-quick-menu-item');
1429
+ item.dataset.search = `${quickItem.id} ${type} ${quickItem.label}`.toLowerCase();
1430
+ const miniIcon = el('span', `mb-mini-type mb-mini-kind-${def.kind}`);
1431
+ const miniSvg = createIconElement(this.iconRegistry.resolve(quickItem.iconId || resolveNodeVisual(type, { surface: 'palette' }).iconId, null), { className: 'mb-node-svg-icon', title: quickItem.label });
1432
+ if (miniSvg) miniIcon.appendChild(miniSvg);
1433
+ item.appendChild(miniIcon);
1434
+ item.appendChild(el('span', 'mb-quick-menu-item-text', quickItem.label));
1435
+ item.addEventListener('click', () => {
1436
+ this.quickMenuNodeId = null;
1437
+ this.options.onQuickAdd?.(source, type, quickItem.preset);
1438
+ });
1439
+ grid.appendChild(item);
1440
+ }
1441
+ section.appendChild(grid);
1442
+ body.appendChild(section);
1443
+ }
1444
+ menu.appendChild(body);
1445
+
1446
+ search.addEventListener('input', () => {
1447
+ const q = search.value.trim().toLowerCase();
1448
+ body.querySelectorAll('.mb-quick-menu-item').forEach((item) => {
1449
+ item.classList.toggle('is-hidden', q && !item.dataset.search.includes(q));
1450
+ });
1451
+ body.querySelectorAll('.mb-quick-menu-section').forEach((section) => {
1452
+ const visible = [...section.querySelectorAll('.mb-quick-menu-item')].some((item) => !item.classList.contains('is-hidden'));
1453
+ section.classList.toggle('is-hidden', !visible);
1454
+ });
1455
+ });
1456
+
1457
+ this.nodeLayer.appendChild(menu);
1458
+ requestAnimationFrame(() => search.focus());
1459
+ }
1460
+ }