@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,607 @@
1
+ import { applyContainmentOperation, createNode, createEdge, getNode, getEdge, clearConnectedWaypoints, cloneModel, HistoryStack, autoLayout, beautify, snapNodePosition, NODE_DEFINITIONS, LAYOUT_DENSITIES, resolveSwimlaneLabelPlacement, validateParallelGateway } from '../core/index.js';
2
+ import { DiagramRenderer } from '../renderer-svg/index.js';
3
+ import { importBpmn, exportBpmn } from '../bpmn-model/index.js';
4
+
5
+ const SEQUENCE_FLOW_KINDS = new Set(['event', 'boundary', 'task', 'container', 'gateway']);
6
+
7
+ export function normalizeCanvasCoordinate(value) {
8
+ const numeric = Number(value);
9
+ return Number.isFinite(numeric) ? Math.round(numeric * 100) / 100 : 0;
10
+ }
11
+
12
+ /**
13
+ * Basic BPMN sequence-flow connection guard shared by the designer and renderer.
14
+ * Connection eligibility is intentionally geometry-agnostic: a node can be to the
15
+ * left, right, above or below the source. The router chooses the appropriate sides.
16
+ */
17
+ export function canSequenceConnect(model, sourceId, targetId) {
18
+ if (!model || !sourceId || !targetId || sourceId === targetId) return false;
19
+ const source = getNode(model, sourceId);
20
+ const target = getNode(model, targetId);
21
+ if (!source || !target) return false;
22
+
23
+ const sourceDef = NODE_DEFINITIONS[source.type] || NODE_DEFINITIONS.generic;
24
+ const targetDef = NODE_DEFINITIONS[target.type] || NODE_DEFINITIONS.generic;
25
+ if (!SEQUENCE_FLOW_KINDS.has(sourceDef.kind) || !SEQUENCE_FLOW_KINDS.has(targetDef.kind)) return false;
26
+ if (sourceDef.eventStage === 'end') return false;
27
+ if (targetDef.eventStage === 'start') return false;
28
+ // Boundary events are attached to an activity and cannot receive a sequence flow.
29
+ if (targetDef.kind === 'boundary') return false;
30
+
31
+ const duplicate = model.edges.some((edge) =>
32
+ (edge.type || 'sequenceFlow') === 'sequenceFlow'
33
+ && edge.source === sourceId
34
+ && edge.target === targetId
35
+ );
36
+ return !duplicate;
37
+ }
38
+
39
+ export class BpmnDesigner {
40
+ constructor(options = {}) {
41
+ if (!options.container) throw new Error('BpmnDesigner requires container.');
42
+ this.model = options.model;
43
+ this.onChange = options.onChange || (() => {});
44
+ this.onSelectionChange = options.onSelectionChange || (() => {});
45
+ this.onStateChange = options.onStateChange || (() => {});
46
+ this.history = new HistoryStack(options.historyLimit || 80);
47
+ this.selection = null;
48
+ this.connectingSource = null;
49
+ this.dragState = null;
50
+ this._listeners = new Map();
51
+
52
+ this.renderer = new DiagramRenderer(options.container, {
53
+ themeController: options.themeController,
54
+ theme: options.theme,
55
+ onThemeChange: options.onThemeChange,
56
+ svgExport: options.svgExport,
57
+ model: this.model,
58
+ mode: 'design',
59
+ onNodeClick: (node) => this._handleNodeClick(node),
60
+ onNodePointerDown: (node, event) => this._startDrag(node, event),
61
+ onEdgeClick: (edge) => this.select({ kind: 'edge', id: edge.id }),
62
+ onCanvasClick: () => this.clearSelection(),
63
+ onStartConnect: (node) => this.startConnect(node.id),
64
+ canConnect: (sourceNode, targetNode) => canSequenceConnect(this.model, sourceNode?.id, targetNode?.id),
65
+ onQuickAdd: (node, type, preset) => this.quickAdd(node.id, type, preset),
66
+ onDeleteNode: (node) => { this.select({ kind: 'node', id: node.id }); this.removeSelection(); },
67
+ onEdgeNameChange: (edge, name) => this.updateEdge(edge.id, { name }),
68
+ onEdgeBendPointerDown: (edge, index, points, event) => this._startEdgeBend(edge, index, points, event),
69
+ onViewportChange: options.onViewportChange,
70
+ });
71
+
72
+ this._keyHandler = (event) => this._handleKey(event);
73
+ window.addEventListener('keydown', this._keyHandler);
74
+ }
75
+
76
+ destroy() {
77
+ window.removeEventListener('keydown', this._keyHandler);
78
+ this._listeners.clear();
79
+ this.renderer.destroy();
80
+ }
81
+
82
+ setTheme(theme) { return this.renderer.setTheme(theme); }
83
+ setThemeMode(mode) { return this.renderer.setThemeMode(mode); }
84
+ getThemeState() { return this.renderer.getThemeState(); }
85
+
86
+ on(event, handler) {
87
+ if (typeof handler !== 'function') return () => {};
88
+ if (!this._listeners.has(event)) this._listeners.set(event, new Set());
89
+ this._listeners.get(event).add(handler);
90
+ return () => this.off(event, handler);
91
+ }
92
+
93
+ off(event, handler) {
94
+ this._listeners.get(event)?.delete(handler);
95
+ }
96
+
97
+ _notify(event, payload) {
98
+ for (const handler of this._listeners.get(event) || []) handler(payload);
99
+ }
100
+
101
+ _emit(reason = 'change') {
102
+ this.renderer.setModel(this.model);
103
+ this.onChange(this.model, reason);
104
+ const state = this.getState();
105
+ this.onStateChange(state);
106
+ this._notify('change', { model: this.model, reason, state });
107
+ }
108
+
109
+ getState() {
110
+ return {
111
+ selection: this.selection,
112
+ connectingSource: this.connectingSource,
113
+ canUndo: this.history.canUndo,
114
+ canRedo: this.history.canRedo,
115
+ engine: this.model.engine,
116
+ };
117
+ }
118
+
119
+ setModel(model, { resetHistory = true } = {}) {
120
+ this.model = model;
121
+ if (resetHistory) this.history = new HistoryStack(80);
122
+ this.selection = null;
123
+ this.connectingSource = null;
124
+ this.renderer.setSelection(null);
125
+ this.renderer.setConnectingSource(null);
126
+ this._emit('set-model');
127
+ }
128
+
129
+ select(selection) {
130
+ this.selection = selection;
131
+ this.renderer.setSelection(selection);
132
+ const element = this.getSelectedElement();
133
+ this.onSelectionChange(selection, element);
134
+ const state = this.getState();
135
+ this.onStateChange(state);
136
+ this._notify('selection', { selection, element, state });
137
+ }
138
+
139
+ clearSelection() {
140
+ this.selection = null;
141
+ this.connectingSource = null;
142
+ this.renderer.setSelection(null);
143
+ this.renderer.setConnectingSource(null);
144
+ this.onSelectionChange(null, null);
145
+ const state = this.getState();
146
+ this.onStateChange(state);
147
+ this._notify('selection', { selection: null, element: null, state });
148
+ }
149
+
150
+ getSelectedElement() {
151
+ if (!this.selection) return null;
152
+ return this.selection.kind === 'node' ? getNode(this.model, this.selection.id) : getEdge(this.model, this.selection.id);
153
+ }
154
+
155
+ _commit(reason, mutator) {
156
+ this.history.capture(this.model);
157
+ mutator();
158
+ this._emit(reason);
159
+ }
160
+
161
+ addNode(type, x, y, overrides = {}) {
162
+ let created;
163
+ this._commit('add-node', () => {
164
+ created = createNode(type, x, y, overrides);
165
+ this.model.nodes.push(created);
166
+ });
167
+ this.select({ kind: 'node', id: created.id });
168
+ return created;
169
+ }
170
+
171
+ quickAdd(sourceId, type = 'userTask', preset = {}) {
172
+ const source = getNode(this.model, sourceId);
173
+ if (!source) return null;
174
+ const def = NODE_DEFINITIONS[type] || NODE_DEFINITIONS.userTask;
175
+ const sourceDef = NODE_DEFINITIONS[source.type] || NODE_DEFINITIONS.generic;
176
+ const outgoingCount = this.model.edges.filter((edge) => edge.source === sourceId).length;
177
+ const direction = this.model.settings?.direction || 'horizontal';
178
+ const density = this.model.settings?.layoutDensity || 'balanced';
179
+ const spacing = LAYOUT_DENSITIES[density] || LAYOUT_DENSITIES.balanced;
180
+ // Quick-add must visually match one-click beautify. The previous fixed 128 /
181
+ // 118px offsets made manually built flows noticeably looser than auto layout.
182
+ const primaryGap = spacing.rankGap;
183
+ const branchStep = Math.max(def.height, 72) + spacing.nodeGap;
184
+ let x;
185
+ let y;
186
+ if (direction === 'vertical') {
187
+ x = source.x + source.width / 2 - def.width / 2;
188
+ if (sourceDef.kind === 'gateway' && outgoingCount > 0) {
189
+ const branchIndex = Math.ceil(outgoingCount / 2);
190
+ const branchSign = outgoingCount % 2 ? -1 : 1;
191
+ x += branchSign * branchIndex * (Math.max(def.width, 140) + spacing.nodeGap);
192
+ }
193
+ y = source.y + source.height + primaryGap;
194
+ } else {
195
+ x = source.x + source.width + primaryGap;
196
+ y = source.y + source.height / 2 - def.height / 2;
197
+ if (sourceDef.kind === 'gateway' && outgoingCount > 0) {
198
+ const offsets = [-branchStep, branchStep, -branchStep * 2, branchStep * 2];
199
+ y += offsets[outgoingCount - 1] ?? (outgoingCount - 2) * branchStep;
200
+ }
201
+ }
202
+
203
+ let created;
204
+ let edge;
205
+ this._commit('quick-add', () => {
206
+ created = createNode(type, Math.round(x), Math.round(y), preset);
207
+ this.model.nodes.push(created);
208
+ edge = createEdge(sourceId, created.id, {
209
+ name: sourceDef.kind === 'gateway' ? `分支 ${outgoingCount + 1}` : '',
210
+ });
211
+ this.model.edges.push(edge);
212
+ });
213
+ this.connectingSource = null;
214
+ this.renderer.setConnectingSource(null);
215
+ this.select({ kind: 'node', id: created.id });
216
+ return created;
217
+ }
218
+
219
+ startConnect(sourceId) {
220
+ const source = getNode(this.model, sourceId);
221
+ if (!source) return;
222
+ const sourceDef = NODE_DEFINITIONS[source.type] || NODE_DEFINITIONS.generic;
223
+ if (!SEQUENCE_FLOW_KINDS.has(sourceDef.kind) || sourceDef.eventStage === 'end') return;
224
+
225
+ const nextSource = this.connectingSource === sourceId ? null : sourceId;
226
+ this.connectingSource = nextSource;
227
+ if (nextSource && (this.selection?.kind !== 'node' || this.selection.id !== sourceId)) {
228
+ this.selection = { kind: 'node', id: sourceId };
229
+ this.renderer.setSelection(this.selection);
230
+ this.onSelectionChange(this.selection, source);
231
+ }
232
+ this.renderer.setConnectingSource(this.connectingSource);
233
+ this.onStateChange(this.getState());
234
+ }
235
+
236
+ connect(source, target, overrides = {}) {
237
+ if (!canSequenceConnect(this.model, source, target)) return null;
238
+ let edge;
239
+ this._commit('connect', () => {
240
+ edge = createEdge(source, target, overrides);
241
+ this.model.edges.push(edge);
242
+ });
243
+ return edge;
244
+ }
245
+
246
+ _handleNodeClick(node) {
247
+ if (this.connectingSource) {
248
+ // Clicking the source itself keeps connection mode active. Clicking any valid
249
+ // target completes the connection regardless of which side of the source it is on.
250
+ if (this.connectingSource === node.id) {
251
+ this.select({ kind: 'node', id: node.id });
252
+ return;
253
+ }
254
+ const edge = this.connect(this.connectingSource, node.id);
255
+ if (edge) {
256
+ this.connectingSource = null;
257
+ this.renderer.setConnectingSource(null);
258
+ this.select({ kind: 'edge', id: edge.id });
259
+ }
260
+ return;
261
+ }
262
+ this.select({ kind: 'node', id: node.id });
263
+ }
264
+
265
+ updateProcess(patch) {
266
+ this._commit('update-process', () => {
267
+ if (patch.properties) this.model.properties = { ...(this.model.properties || {}), ...patch.properties };
268
+ if (patch.settings) this.model.settings = { ...(this.model.settings || {}), ...patch.settings };
269
+ if (patch.resources) this.model.resources = { ...(this.model.resources || {}), ...patch.resources };
270
+ const rest = { ...patch };
271
+ delete rest.properties;
272
+ delete rest.settings;
273
+ delete rest.resources;
274
+ Object.assign(this.model, rest);
275
+ });
276
+ }
277
+
278
+ changeElementId(kind, oldId, nextId) {
279
+ const value = String(nextId || '').trim();
280
+ if (!value || value === oldId) return false;
281
+ const nodeConflict = this.model.nodes.some((node) => node.id === value && !(kind === 'node' && node.id === oldId));
282
+ const edgeConflict = this.model.edges.some((edge) => edge.id === value && !(kind === 'edge' && edge.id === oldId));
283
+ const processConflict = kind !== 'process' && this.model.id === value;
284
+ if (nodeConflict || edgeConflict || processConflict) return false;
285
+
286
+ this._commit('change-element-id', () => {
287
+ if (kind === 'process') {
288
+ const previous = this.model.id;
289
+ this.model.id = value;
290
+ for (const node of this.model.nodes) {
291
+ if (node.type === 'participant' && node.properties?.processRef === previous) node.properties.processRef = value;
292
+ }
293
+ return;
294
+ }
295
+ if (kind === 'node') {
296
+ const node = getNode(this.model, oldId);
297
+ if (!node) return;
298
+ node.id = value;
299
+ for (const edge of this.model.edges) {
300
+ if (edge.source === oldId) edge.source = value;
301
+ if (edge.target === oldId) edge.target = value;
302
+ }
303
+ for (const item of this.model.nodes) {
304
+ if (item.properties?.attachedToRef === oldId) item.properties.attachedToRef = value;
305
+ if (Array.isArray(item.properties?.flowNodeRefs)) item.properties.flowNodeRefs = item.properties.flowNodeRefs.map((ref) => ref === oldId ? value : ref);
306
+ }
307
+ return;
308
+ }
309
+ if (kind === 'edge') {
310
+ const edge = getEdge(this.model, oldId);
311
+ if (!edge) return;
312
+ edge.id = value;
313
+ for (const node of this.model.nodes) if (node.defaultFlowId === oldId) node.defaultFlowId = value;
314
+ }
315
+ });
316
+ if (kind === 'node' || kind === 'edge') this.select({ kind, id: value });
317
+ return true;
318
+ }
319
+
320
+ updateNode(id, patch) {
321
+ const node = getNode(this.model, id);
322
+ if (!node) return;
323
+ const previousLabelPlacement = resolveSwimlaneLabelPlacement(this.model, node);
324
+ this._commit('update-node', () => {
325
+ if (patch.properties) node.properties = { ...node.properties, ...patch.properties };
326
+ const rest = { ...patch };
327
+ delete rest.properties;
328
+ Object.assign(node, rest);
329
+ clearConnectedWaypoints(this.model, id);
330
+ const nextLabelPlacement = resolveSwimlaneLabelPlacement(this.model, node);
331
+ if (previousLabelPlacement !== nextLabelPlacement) {
332
+ const result = applyContainmentOperation(this.model, {
333
+ type: 'update-label-placement',
334
+ nodeId: id,
335
+ previousPlacement: previousLabelPlacement,
336
+ });
337
+ if (result.changed) for (const edge of this.model.edges) edge.waypoints = null;
338
+ }
339
+ });
340
+ this.select({ kind: 'node', id });
341
+ }
342
+
343
+ updateEdge(id, patch) {
344
+ const edge = getEdge(this.model, id);
345
+ if (!edge) return;
346
+ this._commit('update-edge', () => {
347
+ if (patch.properties) edge.properties = { ...(edge.properties || {}), ...patch.properties };
348
+ const rest = { ...patch };
349
+ delete rest.properties;
350
+ Object.assign(edge, rest);
351
+ });
352
+ this.select({ kind: 'edge', id });
353
+ }
354
+
355
+ updateEdges(patches = []) {
356
+ const valid = patches.filter((item) => item?.id && getEdge(this.model, item.id));
357
+ if (!valid.length) return;
358
+ this._commit('update-edges', () => {
359
+ for (const { id, patch = {} } of valid) {
360
+ const edge = getEdge(this.model, id);
361
+ if (patch.properties) edge.properties = { ...(edge.properties || {}), ...patch.properties };
362
+ const rest = { ...patch };
363
+ delete rest.properties;
364
+ Object.assign(edge, rest);
365
+ }
366
+ });
367
+ if (this.selection?.kind === 'edge' && getEdge(this.model, this.selection.id)) this.select(this.selection);
368
+ }
369
+
370
+ removeSelection() {
371
+ if (!this.selection) return;
372
+ const { kind, id } = this.selection;
373
+ this._commit('delete', () => {
374
+ if (kind === 'node') {
375
+ this.model.nodes = this.model.nodes.filter((node) => node.id !== id);
376
+ this.model.edges = this.model.edges.filter((edge) => edge.source !== id && edge.target !== id);
377
+ } else {
378
+ this.model.edges = this.model.edges.filter((edge) => edge.id !== id);
379
+ }
380
+ });
381
+ this.clearSelection();
382
+ }
383
+
384
+ setEngine(engine) {
385
+ if (engine === this.model.engine) return;
386
+ this._commit('set-engine', () => { this.model.engine = engine; });
387
+ }
388
+
389
+ autoLayout(direction = this.model.settings?.direction || 'horizontal') {
390
+ this._commit('auto-layout', () => autoLayout(this.model, { direction }));
391
+ requestAnimationFrame(() => this.renderer.fitReadable(72));
392
+ }
393
+
394
+ beautify(options = {}) {
395
+ this._commit('beautify', () => beautify(this.model, options));
396
+ requestAnimationFrame(() => this.renderer.fitReadable(72));
397
+ }
398
+
399
+ rerouteEdges(options = {}) {
400
+ const edgeStyle = options.edgeStyle || this.model.settings?.edgeStyle || 'rounded';
401
+ const cornerRadius = options.cornerRadius ?? this.model.settings?.cornerRadius ?? 16;
402
+ this._commit('reroute-edges', () => {
403
+ this.model.settings = {
404
+ ...(this.model.settings || {}),
405
+ edgeStyle,
406
+ cornerRadius,
407
+ };
408
+ for (const edge of this.model.edges) {
409
+ edge.waypoints = null;
410
+ edge.routeStyle = edgeStyle;
411
+ edge.cornerRadius = cornerRadius;
412
+ }
413
+ });
414
+ }
415
+
416
+ resetEdgeRoute(id) {
417
+ const edge = getEdge(this.model, id);
418
+ if (!edge) return;
419
+ this._commit('reset-edge-route', () => { edge.waypoints = null; });
420
+ this.select({ kind: 'edge', id });
421
+ }
422
+
423
+ changeNodeType(id, type) {
424
+ const node = getNode(this.model, id);
425
+ const def = NODE_DEFINITIONS[type];
426
+ if (!node || !def || node.type === type) return;
427
+ this._commit('change-node-type', () => {
428
+ const next = createNode(type, node.x, node.y, { id: node.id, name: node.name });
429
+ node.type = type;
430
+ node.bpmnType = def.bpmnType;
431
+ node.width = def.width;
432
+ node.height = def.height;
433
+ node.properties = { ...next.properties, ...node.properties };
434
+ node.originalLocalName = def.localName;
435
+ clearConnectedWaypoints(this.model, id);
436
+ });
437
+ this.select({ kind: 'node', id });
438
+ }
439
+
440
+ undo() {
441
+ const previous = this.history.undo(this.model);
442
+ if (!previous) return;
443
+ this.model = previous;
444
+ this.clearSelection();
445
+ this._emit('undo');
446
+ }
447
+
448
+ redo() {
449
+ const next = this.history.redo(this.model);
450
+ if (!next) return;
451
+ this.model = next;
452
+ this.clearSelection();
453
+ this._emit('redo');
454
+ }
455
+
456
+ importXml(xml, engineHint) {
457
+ const model = importBpmn(xml, engineHint);
458
+ this.setModel(model, { resetHistory: true });
459
+ requestAnimationFrame(() => this.renderer.fitReadable(72));
460
+ return model;
461
+ }
462
+
463
+ exportXml(engineOverride) {
464
+ return exportBpmn(this.model, engineOverride);
465
+ }
466
+
467
+ exportSvg(options) { return this.renderer.exportSvg(options); }
468
+ openSvgExportPreview(options) { return this.renderer.openSvgExportPreview(options); }
469
+
470
+ validate() {
471
+ const issues = [];
472
+ const starts = this.model.nodes.filter((n) => NODE_DEFINITIONS[n.type]?.eventStage === 'start');
473
+ const ends = this.model.nodes.filter((n) => NODE_DEFINITIONS[n.type]?.eventStage === 'end');
474
+ if (!starts.length) issues.push({ level: 'error', message: '流程至少需要一个开始事件。' });
475
+ if (!ends.length) issues.push({ level: 'warning', message: '建议至少配置一个结束事件。' });
476
+ for (const edge of this.model.edges) {
477
+ if (!getNode(this.model, edge.source) || !getNode(this.model, edge.target)) issues.push({ level: 'error', elementId: edge.id, message: '连线引用了不存在的节点。' });
478
+ if ((edge.type || 'sequenceFlow') === 'sequenceFlow' && edge.source === edge.target) issues.push({ level: 'error', elementId: edge.id, message: '顺序流不能连接到自身。' });
479
+ }
480
+ for (const node of this.model.nodes) {
481
+ const def = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
482
+ if (node.type === 'serviceTask' && !node.properties?.implementation) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: 服务任务尚未配置实现。` });
483
+ if (node.type === 'userTask' && !node.properties?.assignee && !node.properties?.candidateUsers && !node.properties?.candidateGroups) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: 用户任务尚未配置处理人。` });
484
+ if (def.kind === 'gateway') {
485
+ const outgoing = this.model.edges.filter((e) => e.source === node.id && (e.type || 'sequenceFlow') === 'sequenceFlow');
486
+ if (node.type === 'parallelGateway') {
487
+ for (const message of validateParallelGateway(this.model, node)) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: ${message}` });
488
+ } else if (outgoing.length < 2) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: 网关通常需要至少两个出口。` });
489
+ if (['exclusiveGateway', 'inclusiveGateway'].includes(node.type)) {
490
+ const unnamed = outgoing.filter((edge) => !edge.name);
491
+ if (outgoing.length >= 2 && unnamed.length) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: 建议为每条分支线路设置名称,便于审批展示和运行追踪。` });
492
+ }
493
+ }
494
+ if (def.kind === 'boundary' && !node.properties?.attachedToRef) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: 边界事件尚未选择附着活动。` });
495
+ if (def.eventDefinition === 'timer' && !node.properties?.timerValue) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: 定时事件尚未配置定时表达式。` });
496
+ }
497
+ return issues;
498
+ }
499
+
500
+ _startDrag(node, event) {
501
+ if (event.button !== 0) return;
502
+ event.preventDefault();
503
+ this.select({ kind: 'node', id: node.id });
504
+ const snapshot = cloneModel(this.model);
505
+ const origin = this.renderer.screenToWorld(event.clientX, event.clientY);
506
+ this.dragState = { id: node.id, origin, nodeX: node.x, nodeY: node.y, moved: false, snapshot };
507
+
508
+ const move = (moveEvent) => {
509
+ if (!this.dragState) return;
510
+ const point = this.renderer.screenToWorld(moveEvent.clientX, moveEvent.clientY);
511
+ const dx = point.x - this.dragState.origin.x;
512
+ const dy = point.y - this.dragState.origin.y;
513
+ if (Math.abs(dx) + Math.abs(dy) > 2) this.dragState.moved = true;
514
+
515
+ let rawX = this.dragState.nodeX + dx;
516
+ let rawY = this.dragState.nodeY + dy;
517
+
518
+ // Grid snapping is now opt-in. The default interaction is continuous and
519
+ // magnetically snaps centers/edges to nearby BPMN elements. Hold Alt to
520
+ // temporarily disable every snap for pixel-perfect positioning.
521
+ if (!moveEvent.altKey && this.model.settings?.snapToGrid) {
522
+ const grid = this.model.settings?.gridSize || 16;
523
+ rawX = Math.round(rawX / grid) * grid;
524
+ rawY = Math.round(rawY / grid) * grid;
525
+ }
526
+
527
+ const snapped = snapNodePosition(this.model, node, rawX, rawY, {
528
+ threshold: this.model.settings?.alignmentThreshold ?? 7,
529
+ disabled: moveEvent.altKey,
530
+ });
531
+ node.x = normalizeCanvasCoordinate(snapped.x);
532
+ node.y = normalizeCanvasCoordinate(snapped.y);
533
+ clearConnectedWaypoints(this.model, node.id);
534
+ this.renderer.setAlignmentGuides?.(snapped.guides || []);
535
+ this.renderer.render();
536
+ };
537
+
538
+ const up = () => {
539
+ window.removeEventListener('pointermove', move);
540
+ window.removeEventListener('pointerup', up);
541
+ const moved = this.dragState?.moved;
542
+ const snapshot = this.dragState?.snapshot;
543
+ this.dragState = null;
544
+ this.renderer.setAlignmentGuides?.([]);
545
+ if (moved) {
546
+ this.history.undoStack.push(snapshot);
547
+ if (this.history.undoStack.length > this.history.limit) this.history.undoStack.shift();
548
+ this.history.redoStack.length = 0;
549
+ this._emit('move-node');
550
+ } else {
551
+ this.renderer.render();
552
+ this.onStateChange(this.getState());
553
+ }
554
+ };
555
+
556
+ window.addEventListener('pointermove', move);
557
+ window.addEventListener('pointerup', up, { once: true });
558
+ }
559
+
560
+
561
+ _startEdgeBend(edge, index, points, event) {
562
+ if (event.button !== 0) return;
563
+ const snapshot = cloneModel(this.model);
564
+ edge.waypoints = points.map((point) => ({ ...point }));
565
+ const move = (moveEvent) => {
566
+ const point = this.renderer.screenToWorld(moveEvent.clientX, moveEvent.clientY);
567
+ if (!edge.waypoints?.[index]) return;
568
+ edge.waypoints[index] = { x: Math.round(point.x), y: Math.round(point.y) };
569
+ this.renderer.render();
570
+ };
571
+ const up = () => {
572
+ window.removeEventListener('pointermove', move);
573
+ window.removeEventListener('pointerup', up);
574
+ this.history.undoStack.push(snapshot);
575
+ if (this.history.undoStack.length > this.history.limit) this.history.undoStack.shift();
576
+ this.history.redoStack.length = 0;
577
+ this._emit('bend-edge');
578
+ this.select({ kind: 'edge', id: edge.id });
579
+ };
580
+ window.addEventListener('pointermove', move);
581
+ window.addEventListener('pointerup', up, { once: true });
582
+ }
583
+
584
+ _handleKey(event) {
585
+ const target = event.target;
586
+ if (target?.matches?.('input,textarea,select,[contenteditable="true"]')) return;
587
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
588
+ event.preventDefault();
589
+ if (event.shiftKey) this.redo(); else this.undo();
590
+ return;
591
+ }
592
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') {
593
+ event.preventDefault();
594
+ this.redo();
595
+ return;
596
+ }
597
+ if (event.key === 'Delete' || event.key === 'Backspace') {
598
+ event.preventDefault();
599
+ this.removeSelection();
600
+ }
601
+ if (event.key === 'Escape') {
602
+ this.connectingSource = null;
603
+ this.renderer.setConnectingSource(null);
604
+ this.clearSelection();
605
+ }
606
+ }
607
+ }
@@ -0,0 +1,19 @@
1
+ import type { BpmnEdge, BpmnNode, ProcessModel } from '../core/index.js'
2
+
3
+ export interface ActivitiEngineProfile {
4
+ id: 'activiti'
5
+ label: 'Activiti'
6
+ prefix: 'activiti'
7
+ namespace: 'http://activiti.org/bpmn'
8
+ parseProcess(element: Element): Record<string, unknown>
9
+ processAttributes(model: ProcessModel): Record<string, unknown>
10
+ parseNode(element: Element): Record<string, unknown>
11
+ nodeAttributes(node: BpmnNode): Record<string, unknown>
12
+ processExtensionElements(model: ProcessModel): string[]
13
+ nodeExtensionElements(node: BpmnNode): string[]
14
+ nodeChildElements(node: BpmnNode): string[]
15
+ parseEdge(element: Element): Record<string, unknown>
16
+ edgeExtensionElements(edge: BpmnEdge): string[]
17
+ }
18
+
19
+ export const activitiProfile: ActivitiEngineProfile