@bpmn-nova/studio 0.3.0-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.
@@ -0,0 +1,677 @@
1
+ import {
2
+ applyContainmentOperation,
3
+ CONTAINMENT_LIMITS,
4
+ HistoryStack,
5
+ LAYOUT_DENSITIES,
6
+ NODE_DEFINITIONS,
7
+ autoLayout,
8
+ beautify,
9
+ clearConnectedWaypoints,
10
+ cloneModel,
11
+ createEdge,
12
+ createNode,
13
+ elementScopeId,
14
+ getEdge,
15
+ getDescendantScopeIds,
16
+ getNode,
17
+ getParentScopeId,
18
+ getScopeGraph,
19
+ getScopeIntegrityIssues,
20
+ getScopePath,
21
+ hasScopeChildren,
22
+ isEmbeddedSubProcess,
23
+ resolveContainment,
24
+ resolveSwimlaneLabelPlacement,
25
+ scopeExists,
26
+ validateParallelGateway,
27
+ } from '@bpmn-nova/core';
28
+ import { exportBpmn, importBpmn } from '@bpmn-nova/bpmn-model';
29
+ import {
30
+ applySelectionArrangement,
31
+ mergeSelectionRefs,
32
+ nodesInSelectionRect,
33
+ selectedDiagramNodes,
34
+ selectedGroupableNodes,
35
+ selectedLayoutNodes,
36
+ selectionBounds,
37
+ } from './selection-layout.js';
38
+
39
+ const SEQUENCE_FLOW_KINDS = new Set(['event', 'boundary', 'task', 'container', 'gateway']);
40
+
41
+ export function canCreateSequenceFlow(model, sourceId, targetId) {
42
+ if (!model || !sourceId || !targetId || sourceId === targetId) return false;
43
+ const source = getNode(model, sourceId);
44
+ const target = getNode(model, targetId);
45
+ if (!source || !target) return false;
46
+ if (elementScopeId(model, source) !== elementScopeId(model, target)) return false;
47
+ const sourceDef = NODE_DEFINITIONS[source.type] || NODE_DEFINITIONS.generic;
48
+ const targetDef = NODE_DEFINITIONS[target.type] || NODE_DEFINITIONS.generic;
49
+ if (!SEQUENCE_FLOW_KINDS.has(sourceDef.kind) || !SEQUENCE_FLOW_KINDS.has(targetDef.kind)) return false;
50
+ if (sourceDef.eventStage === 'end' || targetDef.eventStage === 'start' || targetDef.kind === 'boundary') return false;
51
+ return !model.edges.some((edge) => (edge.type || 'sequenceFlow') === 'sequenceFlow' && edge.source === sourceId && edge.target === targetId);
52
+ }
53
+
54
+ function mergeNodePreset(preset = {}) {
55
+ return {
56
+ ...preset,
57
+ properties: preset.properties ? cloneModel(preset.properties) : undefined,
58
+ };
59
+ }
60
+
61
+ export class BpmnStudioController {
62
+ constructor({ model, historyLimit = 80, extensions = [], propertiesProfile = 'business' } = {}) {
63
+ if (!model) throw new Error('BpmnStudioController requires a model.');
64
+ this._model = model;
65
+ this.history = new HistoryStack(historyLimit);
66
+ this.selection = { kind: 'process', id: model.id };
67
+ this.activeScopeId = model.id;
68
+ this.connectingSource = null;
69
+ this.propertiesProfile = propertiesProfile;
70
+ this._listeners = new Set();
71
+ this._extensions = new Map();
72
+ applyContainmentOperation(this._model, { type: 'reconcile' });
73
+ this.commands = Object.freeze({
74
+ createNode: (input) => this.createNode(input),
75
+ createTemplate: (template, point) => this.createTemplate(template, point),
76
+ connect: (source, target, overrides) => this.connect(source, target, overrides),
77
+ updateProcess: (patch) => this.updateProcess(patch),
78
+ updateNode: (id, patch) => this.updateNode(id, patch),
79
+ updateEdge: (id, patch) => this.updateEdge(id, patch),
80
+ updateEdges: (patches) => this.updateEdges(patches),
81
+ remove: (selection = this.selection) => this.remove(selection),
82
+ changeElementId: (kind, oldId, nextId) => this.changeElementId(kind, oldId, nextId),
83
+ changeNodeType: (id, type) => this.changeNodeType(id, type),
84
+ quickAdd: (sourceId, type, preset) => this.quickAdd(sourceId, type, preset),
85
+ autoLayout: (direction) => this.autoLayout(direction),
86
+ beautify: (options) => this.beautify(options),
87
+ rerouteEdges: (options) => this.rerouteEdges(options),
88
+ resetEdgeRoute: (id) => this.resetEdgeRoute(id),
89
+ setEngine: (engine) => this.setEngine(engine),
90
+ setPropertiesProfile: (profile) => this.setPropertiesProfile(profile),
91
+ select: (selection) => this.select(selection),
92
+ selectInRect: (rect, options) => this.selectInRect(rect, options),
93
+ getSelectionBounds: () => this.getSelectionBounds(),
94
+ arrangeSelection: (operation) => this.arrangeSelection(operation),
95
+ groupSelection: () => this.groupSelection(),
96
+ ungroup: (id) => this.ungroup(id),
97
+ attachLane: (laneId, participantId, index) => this.attachLane(laneId, participantId, index),
98
+ detachLane: (laneId, point) => this.detachLane(laneId, point),
99
+ enterScope: (id) => this.enterScope(id),
100
+ leaveScope: () => this.leaveScope(),
101
+ navigateToScope: (id) => this.navigateToScope(id),
102
+ getActiveGraph: () => this.getActiveGraph(),
103
+ undo: () => this.undo(),
104
+ redo: () => this.redo(),
105
+ });
106
+ for (const extension of extensions) this.registerExtension(extension);
107
+ }
108
+
109
+ get model() { return this._model; }
110
+
111
+ getState() {
112
+ return {
113
+ model: cloneModel(this._model),
114
+ selection: this.selection ? cloneModel(this.selection) : null,
115
+ selectedElement: cloneModel(this.getSelectedElement()),
116
+ connectingSource: this.connectingSource,
117
+ canUndo: this.history.canUndo,
118
+ canRedo: this.history.canRedo,
119
+ engine: this._model.engine,
120
+ propertiesProfile: this.propertiesProfile,
121
+ activeScopeId: this.activeScopeId,
122
+ scopePath: getScopePath(this._model, this.activeScopeId),
123
+ };
124
+ }
125
+
126
+ subscribe(listener) {
127
+ if (typeof listener !== 'function') return () => {};
128
+ this._listeners.add(listener);
129
+ return () => this._listeners.delete(listener);
130
+ }
131
+
132
+ _emit(type, detail = {}) {
133
+ const event = { type, ...detail, state: this.getState() };
134
+ for (const listener of this._listeners) listener(event);
135
+ }
136
+
137
+ _commit(reason, mutator) {
138
+ this.history.capture(this._model);
139
+ const result = mutator();
140
+ this._emit('modelChanged', { reason });
141
+ this._emit('historyChanged', { reason });
142
+ return result;
143
+ }
144
+
145
+ setModel(model, { resetHistory = true } = {}) {
146
+ const previousScopeId = this.activeScopeId;
147
+ this._model = model;
148
+ applyContainmentOperation(this._model, { type: 'reconcile' });
149
+ if (resetHistory) this.history = new HistoryStack(this.history.limit);
150
+ this.connectingSource = null;
151
+ this.activeScopeId = model.id;
152
+ if (previousScopeId !== this.activeScopeId) this._emit('scopeChanged', { activeScopeId: this.activeScopeId, scopePath: getScopePath(model, this.activeScopeId) });
153
+ this.select({ kind: 'process', id: model.id });
154
+ this._emit('modelChanged', { reason: 'set-model' });
155
+ }
156
+
157
+ select(selection) {
158
+ if (selection?.kind === 'process') selection = { kind: 'process', id: this._model.id };
159
+ if (selection?.kind === 'node' && !getNode(this._model, selection.id)) return false;
160
+ if (selection?.kind === 'edge' && !getEdge(this._model, selection.id)) return false;
161
+ this.selection = selection ? cloneModel(selection) : null;
162
+ this._emit('selectionChanged', { selection: this.selection, element: this.getSelectedElement() });
163
+ return true;
164
+ }
165
+
166
+ getSelectedElement() {
167
+ if (!this.selection) return null;
168
+ if (this.selection.kind === 'process') return this._model;
169
+ if (this.selection.kind === 'node') return getNode(this._model, this.selection.id);
170
+ if (this.selection.kind === 'edge') return getEdge(this._model, this.selection.id);
171
+ if (this.selection.kind === 'multi') return this.selection.items.map((ref) => ref.kind === 'node' ? getNode(this._model, ref.id) : getEdge(this._model, ref.id)).filter(Boolean);
172
+ return null;
173
+ }
174
+
175
+ selectInRect(rect, { mode = 'replace' } = {}) {
176
+ mode = ['replace', 'add', 'toggle'].includes(mode) ? mode : 'replace';
177
+ const hits = nodesInSelectionRect(this._model, this.activeScopeId, rect);
178
+ const current = selectedDiagramNodes(this._model, this.selection, this.activeScopeId);
179
+ const refs = mergeSelectionRefs(current, hits, mode);
180
+ let selection;
181
+ if (!refs.length) selection = this.activeScopeId === this._model.id
182
+ ? { kind: 'process', id: this._model.id }
183
+ : { kind: 'node', id: this.activeScopeId };
184
+ else if (refs.length === 1) selection = refs[0];
185
+ else selection = { kind: 'multi', items: refs };
186
+ this.select(selection);
187
+ return cloneModel(this.selection);
188
+ }
189
+
190
+ getSelectionBounds() {
191
+ return selectionBounds(selectedDiagramNodes(this._model, this.selection, this.activeScopeId));
192
+ }
193
+
194
+ groupSelection() {
195
+ const selected = selectedDiagramNodes(this._model, this.selection, this.activeScopeId);
196
+ const groupable = selectedGroupableNodes(this._model, this.selection, this.activeScopeId);
197
+ if (!groupable.length || groupable.length !== selected.length) return null;
198
+ const bounds = selectionBounds(groupable);
199
+ if (!bounds) return null;
200
+ const padding = CONTAINMENT_LIMITS.groupPadding;
201
+ let group;
202
+ this._commit('group-selection', () => {
203
+ group = createNode('group', bounds.x - padding, bounds.y - padding, {
204
+ scopeId: this.activeScopeId,
205
+ width: Math.max(CONTAINMENT_LIMITS.groupMinWidth, bounds.width + padding * 2),
206
+ height: Math.max(CONTAINMENT_LIMITS.groupMinHeight, bounds.height + padding * 2),
207
+ properties: { categoryValue: '分组' },
208
+ });
209
+ const firstIndex = Math.min(...groupable.map((node) => this._model.nodes.indexOf(node)));
210
+ this._model.nodes.splice(Math.max(0, firstIndex), 0, group);
211
+ });
212
+ this.select({ kind: 'node', id: group.id });
213
+ return group;
214
+ }
215
+
216
+ ungroup(id) {
217
+ const targetId = id || (this.selection?.kind === 'node' ? this.selection.id : '');
218
+ const group = getNode(this._model, targetId);
219
+ if (!group || NODE_DEFINITIONS[group.type]?.kind !== 'group') return false;
220
+ this._commit('ungroup', () => {
221
+ this._model.nodes = this._model.nodes.filter((node) => node.id !== targetId);
222
+ this._model.edges = this._model.edges.filter((edge) => edge.source !== targetId && edge.target !== targetId);
223
+ });
224
+ this.select(this.activeScopeId === this._model.id
225
+ ? { kind: 'process', id: this._model.id }
226
+ : { kind: 'node', id: this.activeScopeId });
227
+ return true;
228
+ }
229
+
230
+ attachLane(laneId, participantId, index) {
231
+ const lane = getNode(this._model, laneId);
232
+ const participant = getNode(this._model, participantId);
233
+ if (NODE_DEFINITIONS[lane?.type]?.kind !== 'lane'
234
+ || NODE_DEFINITIONS[participant?.type]?.kind !== 'participant'
235
+ || participant.properties?.processRef !== this._model.id) return false;
236
+ return this._commit('attach-lane', () => applyContainmentOperation(this._model, {
237
+ type: 'attach-lane', laneId, participantId, index,
238
+ }).changed);
239
+ }
240
+
241
+ detachLane(laneId, point = {}) {
242
+ const lane = getNode(this._model, laneId);
243
+ if (NODE_DEFINITIONS[lane?.type]?.kind !== 'lane' || !lane.containerId) return false;
244
+ return this._commit('detach-lane', () => applyContainmentOperation(this._model, {
245
+ type: 'detach-lane', laneId, x: point.x, y: point.y,
246
+ }).changed);
247
+ }
248
+
249
+ arrangeSelection(operation) {
250
+ const valid = operation?.type === 'align'
251
+ ? ['left', 'centerX', 'right', 'top', 'centerY', 'bottom'].includes(operation.alignment)
252
+ : operation?.type === 'distribute'
253
+ ? ['horizontal', 'vertical'].includes(operation.axis)
254
+ : ['layout', 'reroute'].includes(operation?.type);
255
+ if (!valid) return false;
256
+ const nodes = selectedLayoutNodes(this._model, this.selection, this.activeScopeId);
257
+ if (nodes.length < (operation?.type === 'reroute' ? 1 : 2)) return false;
258
+ if (operation?.type === 'distribute' && nodes.length < 3) return false;
259
+ return this._commit(`selection-${operation?.type || 'arrange'}`, () => (
260
+ applySelectionArrangement(this._model, this.selection, this.activeScopeId, operation)
261
+ ));
262
+ }
263
+
264
+ getActiveGraph() { return getScopeGraph(this._model, this.activeScopeId); }
265
+
266
+ _nearestAvailableScope(model, previousPath) {
267
+ for (let index = previousPath.length - 1; index >= 0; index -= 1) {
268
+ if (scopeExists(model, previousPath[index].id)) return previousPath[index].id;
269
+ }
270
+ return model.id;
271
+ }
272
+
273
+ _setActiveScope(scopeId, nextSelection = null) {
274
+ if (!scopeExists(this._model, scopeId) || scopeId === this.activeScopeId) return false;
275
+ this.cancelConnect();
276
+ this.activeScopeId = scopeId;
277
+ const selection = nextSelection || (scopeId === this._model.id ? { kind: 'process', id: this._model.id } : { kind: 'node', id: scopeId });
278
+ this.selection = selection;
279
+ this._emit('scopeChanged', { activeScopeId: scopeId, scopePath: getScopePath(this._model, scopeId) });
280
+ this._emit('selectionChanged', { selection, element: this.getSelectedElement() });
281
+ return true;
282
+ }
283
+
284
+ enterScope(subProcessId) {
285
+ const node = getNode(this._model, subProcessId);
286
+ if (!node || !isEmbeddedSubProcess(node) || elementScopeId(this._model, node) !== this.activeScopeId) return false;
287
+ return this._setActiveScope(node.id);
288
+ }
289
+
290
+ leaveScope() {
291
+ const exitedScopeId = this.activeScopeId;
292
+ const parentScopeId = getParentScopeId(this._model, this.activeScopeId);
293
+ return parentScopeId ? this._setActiveScope(parentScopeId, { kind: 'node', id: exitedScopeId }) : false;
294
+ }
295
+
296
+ navigateToScope(scopeId) {
297
+ const currentPath = getScopePath(this._model, this.activeScopeId);
298
+ const targetIndex = currentPath.findIndex((item) => item.id === scopeId);
299
+ const exited = targetIndex >= 0 && targetIndex < currentPath.length - 1 ? currentPath[targetIndex + 1] : null;
300
+ return this._setActiveScope(scopeId, exited ? { kind: 'node', id: exited.id } : null);
301
+ }
302
+
303
+ createNode({ nodeType, point, preset = {} } = {}) {
304
+ const def = NODE_DEFINITIONS[nodeType];
305
+ if (!def) throw new Error(`Unknown BPMN node type: ${nodeType}`);
306
+ const position = point || { x: 100, y: 100 };
307
+ let node;
308
+ this._commit('create-node', () => {
309
+ const mergedPreset = mergeNodePreset(preset);
310
+ if (nodeType === 'participant') {
311
+ mergedPreset.properties = { ...(mergedPreset.properties || {}), processRef: mergedPreset.properties?.processRef || this._model.id };
312
+ }
313
+ node = createNode(nodeType, position.x, position.y, { ...mergedPreset, scopeId: this.activeScopeId });
314
+ this._model.nodes.push(node);
315
+ if (nodeType === 'lane') {
316
+ const participant = resolveContainment(this._model).getParticipantAt({
317
+ x: node.x + node.width / 2,
318
+ y: node.y + node.height / 2,
319
+ });
320
+ if (participant) applyContainmentOperation(this._model, { type: 'attach-lane', laneId: node.id, participantId: participant.id });
321
+ }
322
+ });
323
+ this.select({ kind: 'node', id: node.id });
324
+ return node;
325
+ }
326
+
327
+ createTemplate(template = {}, point = { x: 100, y: 100 }) {
328
+ const nodeSpecs = template.nodes || [];
329
+ if (!nodeSpecs.length) throw new Error('A template must contain at least one node.');
330
+ const keys = new Set();
331
+ for (const spec of nodeSpecs) {
332
+ if (!spec.key || keys.has(spec.key)) throw new Error('Template node keys must be unique.');
333
+ if (!NODE_DEFINITIONS[spec.nodeType]) throw new Error(`Unknown BPMN node type: ${spec.nodeType}`);
334
+ keys.add(spec.key);
335
+ }
336
+ for (const edge of template.edges || []) if (!keys.has(edge.source) || !keys.has(edge.target)) throw new Error('Template edge references an unknown node key.');
337
+ const result = { nodes: [], edges: [] };
338
+ const byKey = new Map();
339
+ this._commit('create-template', () => {
340
+ for (const spec of nodeSpecs) {
341
+ const offset = spec.offset || { x: 0, y: 0 };
342
+ const node = createNode(spec.nodeType, Math.round(point.x + (offset.x || 0)), Math.round(point.y + (offset.y || 0)), { ...mergeNodePreset(spec.preset), scopeId: this.activeScopeId });
343
+ byKey.set(spec.key, node);
344
+ result.nodes.push(node);
345
+ this._model.nodes.push(node);
346
+ }
347
+ for (const spec of template.edges || []) {
348
+ const edge = createEdge(byKey.get(spec.source).id, byKey.get(spec.target).id, { ...(spec.preset || {}), scopeId: this.activeScopeId });
349
+ result.edges.push(edge);
350
+ this._model.edges.push(edge);
351
+ }
352
+ });
353
+ this.select({ kind: 'multi', items: result.nodes.map((node) => ({ kind: 'node', id: node.id })) });
354
+ return result;
355
+ }
356
+
357
+ connect(source, target, overrides = {}) {
358
+ if (!canCreateSequenceFlow(this._model, source, target)) return null;
359
+ let edge;
360
+ this._commit('connect', () => {
361
+ edge = createEdge(source, target, { ...overrides, scopeId: elementScopeId(this._model, getNode(this._model, source)) });
362
+ this._model.edges.push(edge);
363
+ });
364
+ this.select({ kind: 'edge', id: edge.id });
365
+ return edge;
366
+ }
367
+
368
+ startConnect(sourceId) {
369
+ const source = getNode(this._model, sourceId);
370
+ const definition = source && (NODE_DEFINITIONS[source.type] || NODE_DEFINITIONS.generic);
371
+ if (!source || elementScopeId(this._model, source) !== this.activeScopeId || !SEQUENCE_FLOW_KINDS.has(definition.kind) || definition.eventStage === 'end') return false;
372
+ this.connectingSource = this.connectingSource === sourceId ? null : sourceId;
373
+ if (this.connectingSource) this.select({ kind: 'node', id: sourceId });
374
+ this._emit('connectingChanged', { sourceId: this.connectingSource });
375
+ return true;
376
+ }
377
+
378
+ cancelConnect() {
379
+ if (!this.connectingSource) return;
380
+ this.connectingSource = null;
381
+ this._emit('connectingChanged', { sourceId: null });
382
+ }
383
+
384
+ beginGesture() { return cloneModel(this._model); }
385
+
386
+ commitGesture(snapshot, reason = 'gesture') {
387
+ if (!snapshot) return false;
388
+ this.history.undoStack.push(snapshot);
389
+ if (this.history.undoStack.length > this.history.limit) this.history.undoStack.shift();
390
+ this.history.redoStack.length = 0;
391
+ this._emit('modelChanged', { reason });
392
+ this._emit('historyChanged', { reason });
393
+ return true;
394
+ }
395
+
396
+ updateProcess(patch = {}) {
397
+ this._commit('update-process', () => {
398
+ if (patch.properties) this._model.properties = { ...(this._model.properties || {}), ...patch.properties };
399
+ if (patch.settings) this._model.settings = { ...(this._model.settings || {}), ...patch.settings };
400
+ if (patch.resources) this._model.resources = { ...(this._model.resources || {}), ...patch.resources };
401
+ const rest = { ...patch };
402
+ delete rest.properties; delete rest.settings; delete rest.resources;
403
+ Object.assign(this._model, rest);
404
+ });
405
+ }
406
+
407
+ updateNode(id, patch = {}) {
408
+ const node = getNode(this._model, id);
409
+ if (!node) return false;
410
+ const previousLabelPlacement = resolveSwimlaneLabelPlacement(this._model, node);
411
+ this._commit('update-node', () => {
412
+ if (patch.properties) node.properties = { ...node.properties, ...patch.properties };
413
+ const rest = { ...patch }; delete rest.properties;
414
+ Object.assign(node, rest);
415
+ clearConnectedWaypoints(this._model, id);
416
+ const nextLabelPlacement = resolveSwimlaneLabelPlacement(this._model, node);
417
+ if (previousLabelPlacement !== nextLabelPlacement) {
418
+ const result = applyContainmentOperation(this._model, {
419
+ type: 'update-label-placement',
420
+ nodeId: id,
421
+ previousPlacement: previousLabelPlacement,
422
+ });
423
+ if (result.changed) for (const edge of this._model.edges) edge.waypoints = null;
424
+ } else {
425
+ applyContainmentOperation(this._model, { type: 'reconcile' });
426
+ }
427
+ });
428
+ return true;
429
+ }
430
+
431
+ updateEdge(id, patch = {}) {
432
+ const edge = getEdge(this._model, id);
433
+ if (!edge) return false;
434
+ this._commit('update-edge', () => {
435
+ if (patch.properties) edge.properties = { ...(edge.properties || {}), ...patch.properties };
436
+ const rest = { ...patch }; delete rest.properties;
437
+ Object.assign(edge, rest);
438
+ });
439
+ return true;
440
+ }
441
+
442
+ updateEdges(patches = []) {
443
+ const valid = patches.filter((item) => item?.id && getEdge(this._model, item.id));
444
+ if (!valid.length) return false;
445
+ this._commit('update-edges', () => {
446
+ for (const { id, patch = {} } of valid) {
447
+ const edge = getEdge(this._model, id);
448
+ if (patch.properties) edge.properties = { ...(edge.properties || {}), ...patch.properties };
449
+ const rest = { ...patch }; delete rest.properties;
450
+ Object.assign(edge, rest);
451
+ }
452
+ });
453
+ return true;
454
+ }
455
+
456
+ changeElementId(kind, oldId, nextId) {
457
+ const value = String(nextId || '').trim();
458
+ if (!value || value === oldId) return false;
459
+ if (!['process', 'node', 'edge'].includes(kind)) return false;
460
+ if (kind === 'process' && this._model.id !== oldId) return false;
461
+ if (kind === 'node' && !getNode(this._model, oldId)) return false;
462
+ if (kind === 'edge' && !getEdge(this._model, oldId)) return false;
463
+ const conflict = this._model.id === value || this._model.nodes.some((node) => node.id === value) || this._model.edges.some((edge) => edge.id === value);
464
+ if (conflict) return false;
465
+ this._commit('change-element-id', () => {
466
+ if (kind === 'process') {
467
+ this._model.id = value;
468
+ for (const node of this._model.nodes) if (!node.scopeId || node.scopeId === oldId) node.scopeId = value;
469
+ for (const edge of this._model.edges) if (!edge.scopeId || edge.scopeId === oldId) edge.scopeId = value;
470
+ if (this.activeScopeId === oldId) this.activeScopeId = value;
471
+ for (const node of this._model.nodes) if (node.type === 'participant' && node.properties?.processRef === oldId) node.properties.processRef = value;
472
+ } else if (kind === 'node') {
473
+ const node = getNode(this._model, oldId);
474
+ if (!node) return;
475
+ node.id = value;
476
+ for (const item of this._model.nodes) if (item.scopeId === oldId) item.scopeId = value;
477
+ for (const item of this._model.edges) if (item.scopeId === oldId) item.scopeId = value;
478
+ if (this.activeScopeId === oldId) this.activeScopeId = value;
479
+ for (const edge of this._model.edges) {
480
+ if (edge.source === oldId) edge.source = value;
481
+ if (edge.target === oldId) edge.target = value;
482
+ }
483
+ for (const item of this._model.nodes) {
484
+ if (item.containerId === oldId) item.containerId = value;
485
+ if (item.properties?.attachedToRef === oldId) item.properties.attachedToRef = value;
486
+ if (Array.isArray(item.properties?.flowNodeRefs)) item.properties.flowNodeRefs = item.properties.flowNodeRefs.map((ref) => ref === oldId ? value : ref);
487
+ }
488
+ } else if (kind === 'edge') {
489
+ const edge = getEdge(this._model, oldId);
490
+ if (!edge) return;
491
+ edge.id = value;
492
+ for (const node of this._model.nodes) if (node.defaultFlowId === oldId) node.defaultFlowId = value;
493
+ }
494
+ });
495
+ if (kind !== 'edge') this._emit('scopeChanged', { activeScopeId: this.activeScopeId, scopePath: getScopePath(this._model, this.activeScopeId) });
496
+ this.select({ kind, id: value });
497
+ return true;
498
+ }
499
+
500
+ changeNodeType(id, type) {
501
+ const node = getNode(this._model, id);
502
+ const def = NODE_DEFINITIONS[type];
503
+ if (!node || !def || node.type === type) return false;
504
+ if (isEmbeddedSubProcess(node) && !isEmbeddedSubProcess(type) && hasScopeChildren(this._model, id)) return false;
505
+ this._commit('change-node-type', () => {
506
+ const next = createNode(type, node.x, node.y, { id: node.id, name: node.name });
507
+ Object.assign(node, { type, bpmnType: def.bpmnType, width: def.width, height: def.height, originalLocalName: def.localName });
508
+ node.isExpanded = isEmbeddedSubProcess(type) ? false : undefined;
509
+ node.properties = { ...next.properties, ...node.properties };
510
+ clearConnectedWaypoints(this._model, id);
511
+ applyContainmentOperation(this._model, { type: 'reconcile' });
512
+ });
513
+ return true;
514
+ }
515
+
516
+ quickAdd(sourceId, type = 'userTask', preset = {}) {
517
+ const source = getNode(this._model, sourceId);
518
+ const def = NODE_DEFINITIONS[type];
519
+ if (!source || elementScopeId(this._model, source) !== this.activeScopeId || !def) return null;
520
+ const direction = this._model.settings?.direction || 'horizontal';
521
+ const spacing = LAYOUT_DENSITIES[this._model.settings?.layoutDensity || 'balanced'] || LAYOUT_DENSITIES.balanced;
522
+ const outgoingCount = this._model.edges.filter((edge) => edge.source === sourceId).length;
523
+ const x = direction === 'vertical'
524
+ ? source.x + source.width / 2 - def.width / 2
525
+ : source.x + source.width + spacing.rankGap;
526
+ const y = direction === 'vertical'
527
+ ? source.y + source.height + spacing.rankGap
528
+ : source.y + source.height / 2 - def.height / 2 + (outgoingCount ? outgoingCount * (def.height + spacing.nodeGap) : 0);
529
+ let node;
530
+ this._commit('quick-add', () => {
531
+ node = createNode(type, Math.round(x), Math.round(y), { ...mergeNodePreset(preset), scopeId: elementScopeId(this._model, source) });
532
+ this._model.nodes.push(node);
533
+ this._model.edges.push(createEdge(sourceId, node.id, { name: NODE_DEFINITIONS[source.type]?.kind === 'gateway' ? `分支 ${outgoingCount + 1}` : '', scopeId: elementScopeId(this._model, source) }));
534
+ });
535
+ this.select({ kind: 'node', id: node.id });
536
+ return node;
537
+ }
538
+
539
+ remove(selection = this.selection) {
540
+ if (!selection || selection.kind === 'process') return false;
541
+ const refs = selection.kind === 'multi' ? selection.items : [selection];
542
+ const previousScopeId = this.activeScopeId;
543
+ const requestedNodeIds = refs.filter((ref) => ref.kind === 'node').map((ref) => ref.id);
544
+ let fallbackScopeId = this.activeScopeId;
545
+ for (const id of requestedNodeIds) {
546
+ if (id === fallbackScopeId || getDescendantScopeIds(this._model, id).has(fallbackScopeId)) fallbackScopeId = elementScopeId(this._model, getNode(this._model, id));
547
+ }
548
+ this._commit('remove', () => {
549
+ const nodeIds = new Set(requestedNodeIds);
550
+ const removedScopes = new Set();
551
+ for (const id of requestedNodeIds) {
552
+ const node = getNode(this._model, id);
553
+ if (!isEmbeddedSubProcess(node)) continue;
554
+ const descendantScopes = getDescendantScopeIds(this._model, id);
555
+ for (const scopeId of descendantScopes) removedScopes.add(scopeId);
556
+ for (const candidate of this._model.nodes) if (descendantScopes.has(elementScopeId(this._model, candidate))) nodeIds.add(candidate.id);
557
+ }
558
+ const edgeIds = new Set(refs.filter((ref) => ref.kind === 'edge').map((ref) => ref.id));
559
+ for (const nodeId of nodeIds) applyContainmentOperation(this._model, { type: 'prepare-remove', nodeId });
560
+ this._model.nodes = this._model.nodes.filter((node) => !nodeIds.has(node.id));
561
+ this._model.edges = this._model.edges.filter((edge) => !edgeIds.has(edge.id) && !removedScopes.has(elementScopeId(this._model, edge)) && !nodeIds.has(edge.source) && !nodeIds.has(edge.target));
562
+ applyContainmentOperation(this._model, { type: 'reconcile' });
563
+ });
564
+ if (!scopeExists(this._model, this.activeScopeId)) this.activeScopeId = scopeExists(this._model, fallbackScopeId) ? fallbackScopeId : this._model.id;
565
+ this.select(this.activeScopeId === this._model.id ? { kind: 'process', id: this._model.id } : { kind: 'node', id: this.activeScopeId });
566
+ if (previousScopeId !== this.activeScopeId) this._emit('scopeChanged', { activeScopeId: this.activeScopeId, scopePath: getScopePath(this._model, this.activeScopeId) });
567
+ return true;
568
+ }
569
+
570
+ autoLayout(direction = this._model.settings?.direction || 'horizontal') { this._commit('auto-layout', () => autoLayout(this.getActiveGraph(), { direction })); }
571
+ beautify(options = {}) {
572
+ this._commit('beautify', () => {
573
+ const graph = this.getActiveGraph();
574
+ beautify(graph, options);
575
+ this._model.settings = graph.settings;
576
+ });
577
+ }
578
+ rerouteEdges(options = {}) {
579
+ this._commit('reroute-edges', () => {
580
+ const edgeStyle = options.edgeStyle || this._model.settings?.edgeStyle || 'rounded';
581
+ const cornerRadius = options.cornerRadius ?? this._model.settings?.cornerRadius ?? 16;
582
+ this._model.settings = { ...(this._model.settings || {}), edgeStyle, cornerRadius };
583
+ for (const edge of this.getActiveGraph().edges) Object.assign(edge, { waypoints: null, routeStyle: edgeStyle, cornerRadius });
584
+ });
585
+ }
586
+ resetEdgeRoute(id) { return this.updateEdge(id, { waypoints: null }); }
587
+
588
+ setEngine(engine) {
589
+ if (!['flowable', 'activiti'].includes(engine) || engine === this._model.engine) return false;
590
+ this._commit('set-engine', () => { this._model.engine = engine; });
591
+ return true;
592
+ }
593
+ setPropertiesProfile(profile) {
594
+ if (!['business', 'developer'].includes(profile) || profile === this.propertiesProfile) return false;
595
+ this.propertiesProfile = profile;
596
+ this._emit('propertiesProfileChanged', { profile });
597
+ return true;
598
+ }
599
+ exportXml(engine) { return exportBpmn(this._model, engine); }
600
+ importXml(xml, engine) { const model = importBpmn(xml, engine); this.setModel(model); return model; }
601
+
602
+ validate() {
603
+ const issues = [];
604
+ const starts = this._model.nodes.filter((node) => NODE_DEFINITIONS[node.type]?.eventStage === 'start');
605
+ const ends = this._model.nodes.filter((node) => NODE_DEFINITIONS[node.type]?.eventStage === 'end');
606
+ if (!starts.length) issues.push({ level: 'error', message: '流程至少需要一个开始事件。' });
607
+ if (!ends.length) issues.push({ level: 'warning', message: '建议至少配置一个结束事件。' });
608
+ const scopeIssues = getScopeIntegrityIssues(this._model);
609
+ const invalidScopeEdges = new Set(scopeIssues.filter((issue) => issue.kind === 'edge').map((issue) => issue.id));
610
+ for (const issue of scopeIssues.filter((item) => item.kind === 'node')) {
611
+ issues.push({ level: 'error', elementId: issue.id, message: '节点引用了不存在或无效的子流程作用域。' });
612
+ }
613
+ for (const edge of this._model.edges) {
614
+ if (!getNode(this._model, edge.source) || !getNode(this._model, edge.target)) issues.push({ level: 'error', elementId: edge.id, message: '连线引用了不存在的节点。' });
615
+ else if (invalidScopeEdges.has(edge.id)) issues.push({ level: 'error', elementId: edge.id, message: '连线不能跨越流程或子流程作用域。' });
616
+ if ((edge.type || 'sequenceFlow') === 'sequenceFlow' && edge.source === edge.target) issues.push({ level: 'error', elementId: edge.id, message: '顺序流不能连接到自身。' });
617
+ }
618
+ for (const node of this._model.nodes) {
619
+ const definition = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
620
+ if (node.type === 'serviceTask' && !node.properties?.implementation) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: 服务任务尚未配置实现。` });
621
+ if (node.type === 'userTask' && !node.properties?.assignee && !node.properties?.candidateUsers && !node.properties?.candidateGroups) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: 用户任务尚未配置处理人。` });
622
+ if (definition.kind === 'boundary' && !node.properties?.attachedToRef) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: 边界事件尚未选择附着活动。` });
623
+ for (const message of validateParallelGateway(this._model, node)) issues.push({ level: 'warning', elementId: node.id, message: `${node.name}: ${message}` });
624
+ }
625
+ return issues;
626
+ }
627
+
628
+ undo() {
629
+ const previousScopeId = this.activeScopeId;
630
+ const previousPath = getScopePath(this._model, previousScopeId);
631
+ const model = this.history.undo(this._model);
632
+ if (!model) return false;
633
+ this._model = model;
634
+ applyContainmentOperation(this._model, { type: 'reconcile' });
635
+ this.activeScopeId = this._nearestAvailableScope(model, previousPath);
636
+ this.selection = this.activeScopeId === model.id ? { kind: 'process', id: model.id } : { kind: 'node', id: this.activeScopeId };
637
+ if (previousScopeId !== this.activeScopeId) this._emit('scopeChanged', { activeScopeId: this.activeScopeId, scopePath: getScopePath(model, this.activeScopeId) });
638
+ this._emit('modelChanged', { reason: 'undo' });
639
+ this._emit('selectionChanged', { selection: this.selection, element: this.getSelectedElement() });
640
+ this._emit('historyChanged', { reason: 'undo' });
641
+ return true;
642
+ }
643
+
644
+ redo() {
645
+ const previousScopeId = this.activeScopeId;
646
+ const previousPath = getScopePath(this._model, previousScopeId);
647
+ const model = this.history.redo(this._model);
648
+ if (!model) return false;
649
+ this._model = model;
650
+ applyContainmentOperation(this._model, { type: 'reconcile' });
651
+ this.activeScopeId = this._nearestAvailableScope(model, previousPath);
652
+ this.selection = this.activeScopeId === model.id ? { kind: 'process', id: model.id } : { kind: 'node', id: this.activeScopeId };
653
+ if (previousScopeId !== this.activeScopeId) this._emit('scopeChanged', { activeScopeId: this.activeScopeId, scopePath: getScopePath(model, this.activeScopeId) });
654
+ this._emit('modelChanged', { reason: 'redo' });
655
+ this._emit('selectionChanged', { selection: this.selection, element: this.getSelectedElement() });
656
+ this._emit('historyChanged', { reason: 'redo' });
657
+ return true;
658
+ }
659
+
660
+ registerExtension(extension) {
661
+ if (!extension?.id || typeof extension.setup !== 'function') throw new Error('Studio extension requires id and setup().');
662
+ if (this._extensions.has(extension.id)) throw new Error(`Studio extension already registered: ${extension.id}`);
663
+ const cleanup = extension.setup({ studio: this }) || (() => {});
664
+ this._extensions.set(extension.id, cleanup);
665
+ return () => { this._extensions.get(extension.id)?.(); this._extensions.delete(extension.id); };
666
+ }
667
+
668
+ destroy() {
669
+ for (const cleanup of this._extensions.values()) cleanup?.();
670
+ this._extensions.clear();
671
+ this._listeners.clear();
672
+ }
673
+ }
674
+
675
+ export function createStudioController(options) {
676
+ return new BpmnStudioController(options);
677
+ }