@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.
package/dist/canvas.js ADDED
@@ -0,0 +1,943 @@
1
+ import {
2
+ applyContainmentOperation,
3
+ clearConnectedWaypoints,
4
+ CONTAINMENT_LIMITS,
5
+ elementScopeId,
6
+ getNode,
7
+ isEmbeddedSubProcess,
8
+ NODE_DEFINITIONS,
9
+ resolveContainment,
10
+ snapNodePosition,
11
+ } from '@bpmn-nova/core';
12
+ import { DiagramRenderer } from '@bpmn-nova/renderer';
13
+ import { hydrateIcons } from '@bpmn-nova/icons';
14
+ import { canCreateSequenceFlow } from './controller.js';
15
+ import { createDefaultContextMenuRegistry } from './context-menu.js';
16
+ import { CREATE_INTENT_MIME } from './interactions.js';
17
+ import {
18
+ isMarqueeSelectableNode,
19
+ isSelectionLayoutNode,
20
+ selectedDiagramNodes,
21
+ selectedGroupableNodes,
22
+ } from './selection-layout.js';
23
+
24
+ function domNode(tag, className = '', text = '') {
25
+ const element = document.createElement(tag);
26
+ if (className) element.className = className;
27
+ if (text) element.textContent = text;
28
+ return element;
29
+ }
30
+
31
+ function toolbarIcon(id) {
32
+ const icon = domNode('span', 'nova-icon nova-icon-sm');
33
+ icon.dataset.icon = id;
34
+ return icon;
35
+ }
36
+
37
+ export class BpmnCanvas {
38
+ constructor({ container, studio, rendererOptions = {}, interactions = null, selectionToolbar = null, contextMenu = null, contextMenuRegistry = createDefaultContextMenuRegistry(), pointerMode = 'select', themeController = null, theme = null, onThemeChange = null } = {}) {
39
+ if (!container) throw new Error('BpmnCanvas requires a container.');
40
+ if (!studio) throw new Error('BpmnCanvas requires a studio controller.');
41
+ this.container = container;
42
+ this.studio = studio;
43
+ this.interactions = interactions;
44
+ this.selectionToolbarSlot = selectionToolbar;
45
+ this.contextMenuSlot = contextMenu;
46
+ this.contextMenuRegistry = contextMenuRegistry;
47
+ this.pointerMode = ['select', 'marquee', 'pan'].includes(pointerMode) ? pointerMode : 'select';
48
+ this._drag = null;
49
+ this._viewportByScope = new Map();
50
+ this._renderedScopeId = studio.activeScopeId;
51
+ const onViewportChange = rendererOptions.onViewportChange;
52
+ this.renderer = new DiagramRenderer(container, {
53
+ ...rendererOptions,
54
+ themeController,
55
+ theme,
56
+ onThemeChange,
57
+ getVisualModel: () => studio.model,
58
+ interactionMode: this.pointerMode,
59
+ model: studio.getActiveGraph(),
60
+ mode: 'design',
61
+ showEmptyState: studio.activeScopeId !== studio.model.id,
62
+ onCanvasClick: () => {
63
+ studio.cancelConnect();
64
+ studio.select(studio.activeScopeId === studio.model.id ? { kind: 'process', id: studio.model.id } : { kind: 'node', id: studio.activeScopeId });
65
+ },
66
+ onMarqueeSelect: (rect, options) => {
67
+ studio.cancelConnect();
68
+ studio.selectInRect(rect, options);
69
+ },
70
+ onNodeClick: (node, event) => this._handleNodeClick(node, event),
71
+ onNodeDoubleClick: (node) => this._enterSubProcess(node),
72
+ onEnterSubProcess: (node) => this._enterSubProcess(node),
73
+ onNodePointerDown: (node, event) => this._startNodeDrag(node, event),
74
+ onNodeResizePointerDown: (node, handle, event) => this._startNodeResize(node, handle, event),
75
+ onLaneDividerPointerDown: (lane, event) => this._startLaneDividerResize(lane, event),
76
+ onEdgeClick: (edge) => {
77
+ if (this.pointerMode === 'pan') return;
78
+ studio.cancelConnect();
79
+ studio.select({ kind: 'edge', id: edge.id });
80
+ },
81
+ onStartConnect: (node) => studio.startConnect(node.id),
82
+ canConnect: (source, target) => canCreateSequenceFlow(studio.model, source.id, target.id),
83
+ onQuickAdd: (source, type, preset) => studio.commands.quickAdd(source.id, type, preset),
84
+ onDeleteNode: (node) => studio.commands.remove({ kind: 'node', id: node.id }),
85
+ onUngroup: (node) => studio.commands.ungroup(node.id),
86
+ onEdgeNameChange: (edge, name) => studio.commands.updateEdge(edge.id, { name }),
87
+ onEdgeBendPointerDown: (edge, index, points, event) => this._startEdgeBend(edge, index, points, event),
88
+ onViewportChange: (viewport) => {
89
+ this.closeContextMenu();
90
+ onViewportChange?.(viewport);
91
+ this._positionSelectionToolbar();
92
+ },
93
+ });
94
+ this._buildPointerToolbar();
95
+ this._buildSelectionToolbar();
96
+ this._buildContextMenu();
97
+ this.renderer.setSelection(studio.selection);
98
+ this._offStudio = studio.subscribe((event) => this._onStudioEvent(event));
99
+ this._onKeyDown = (event) => this._handleKeyDown(event);
100
+ this._onKeyUp = (event) => {
101
+ if (event.code === 'Space') this.renderer.setSpacePressed(false);
102
+ };
103
+ this.container.tabIndex = this.container.tabIndex >= 0 ? this.container.tabIndex : 0;
104
+ this.container.addEventListener('keydown', this._onKeyDown);
105
+ this.container.addEventListener('keyup', this._onKeyUp);
106
+ this._onDragOver = (event) => this._dragOver(event);
107
+ this._onDrop = (event) => this._drop(event);
108
+ this.container.addEventListener('dragover', this._onDragOver);
109
+ this.container.addEventListener('drop', this._onDrop);
110
+ this._onContextMenu = (event) => this._handleContextMenu(event);
111
+ this.renderer.viewport.addEventListener('contextmenu', this._onContextMenu);
112
+ this._onToolbarOutside = (event) => {
113
+ if (!this._contextMenu?.contains(event.target)) this.closeContextMenu();
114
+ if (!this._selectionToolbar?.contains(event.target)) this._selectionToolbar?.querySelectorAll('details[open]').forEach((details) => { details.open = false; });
115
+ };
116
+ document.addEventListener('pointerdown', this._onToolbarOutside);
117
+ this._renderSelectionToolbar();
118
+ }
119
+
120
+ _buildPointerToolbar() {
121
+ const toolbar = domNode('div', 'nova-pointer-toolbar');
122
+ toolbar.setAttribute('role', 'toolbar');
123
+ toolbar.setAttribute('aria-label', '画布操作模式');
124
+ toolbar.addEventListener('pointerdown', (event) => event.stopPropagation());
125
+ this._pointerModeButtons = new Map();
126
+ const items = [
127
+ { mode: 'select', iconId: 'ui.pointer', label: '单选', title: '单选与移动节点' },
128
+ { mode: 'marquee', iconId: 'ui.marquee', label: '框选', title: '拖动空白区域框选节点' },
129
+ { mode: 'pan', iconId: 'ui.hand', label: '拖动', title: '拖动画布' },
130
+ ];
131
+ for (const item of items) {
132
+ const button = domNode('button', 'nova-pointer-mode');
133
+ button.type = 'button';
134
+ button.dataset.pointerMode = item.mode;
135
+ button.title = item.title;
136
+ button.setAttribute('aria-label', item.title);
137
+ button.append(toolbarIcon(item.iconId), domNode('span', '', item.label));
138
+ button.addEventListener('click', () => this.setPointerMode(item.mode));
139
+ toolbar.appendChild(button);
140
+ this._pointerModeButtons.set(item.mode, button);
141
+ }
142
+ this._pointerToolbar = toolbar;
143
+ this.container.appendChild(toolbar);
144
+ hydrateIcons(toolbar, this.renderer.iconRegistry);
145
+ this.setPointerMode(this.pointerMode);
146
+ }
147
+
148
+ setPointerMode(mode) {
149
+ if (!['select', 'marquee', 'pan'].includes(mode)) return false;
150
+ this.pointerMode = mode;
151
+ if (mode === 'pan') this.studio.cancelConnect();
152
+ this.renderer.setInteractionMode(mode);
153
+ this._pointerModeButtons?.forEach((button, value) => {
154
+ const active = value === mode;
155
+ button.classList.toggle('is-active', active);
156
+ button.setAttribute('aria-pressed', active ? 'true' : 'false');
157
+ });
158
+ return true;
159
+ }
160
+
161
+ getPointerMode() { return this.pointerMode; }
162
+
163
+ _buildContextMenu() {
164
+ this._contextMenu = domNode('div', 'nova-context-menu');
165
+ this._contextMenu.hidden = true;
166
+ this._contextMenu.setAttribute('role', 'menu');
167
+ this._contextMenu.setAttribute('aria-label', '画布快捷操作');
168
+ this._contextMenu.addEventListener('pointerdown', (event) => event.stopPropagation());
169
+ this._contextMenu.addEventListener('contextmenu', (event) => event.preventDefault());
170
+ this._contextMenu.addEventListener('keydown', (event) => this._handleContextMenuKeyDown(event));
171
+ this.container.appendChild(this._contextMenu);
172
+ }
173
+
174
+ _contextTargetFromSelection() {
175
+ const selection = this.studio.selection;
176
+ if (selection?.kind === 'multi') return { kind: 'multi', items: selection.items };
177
+ if (selection?.kind === 'node' && selection.id !== this.studio.activeScopeId) return { kind: 'node', id: selection.id };
178
+ if (selection?.kind === 'edge') return { kind: 'edge', id: selection.id };
179
+ return { kind: 'canvas', id: this.studio.activeScopeId };
180
+ }
181
+
182
+ _contextPositionForSelection() {
183
+ const host = this.container.getBoundingClientRect();
184
+ const viewport = this.renderer.viewport.getBoundingClientRect();
185
+ const bounds = this.studio.getSelectionBounds();
186
+ if (bounds) {
187
+ return {
188
+ x: viewport.left - host.left + (bounds.x + bounds.width / 2) * this.renderer.zoom + this.renderer.pan.x,
189
+ y: viewport.top - host.top + (bounds.y + bounds.height / 2) * this.renderer.zoom + this.renderer.pan.y,
190
+ };
191
+ }
192
+ return {
193
+ x: viewport.left - host.left + viewport.width / 2,
194
+ y: viewport.top - host.top + viewport.height / 2,
195
+ };
196
+ }
197
+
198
+ _handleContextMenu(event) {
199
+ if (event.shiftKey) {
200
+ this.closeContextMenu();
201
+ return;
202
+ }
203
+ if (event.target?.closest?.('input,textarea,select,[contenteditable="true"],.mb-quick-menu,.mb-edge-label-editor')) return;
204
+ event.preventDefault();
205
+ event.stopPropagation();
206
+ this._closeTransientCanvasMenus();
207
+ this.studio.cancelConnect();
208
+
209
+ const nodeHost = event.target?.closest?.('[data-node-id]');
210
+ const edgeHost = event.target?.closest?.('[data-edge-id]');
211
+ let target;
212
+ if (nodeHost?.dataset.nodeId) {
213
+ const nodeId = nodeHost.dataset.nodeId;
214
+ const selectedByMulti = this.studio.selection?.kind === 'multi'
215
+ && this.studio.selection.items?.some((item) => item.kind === 'node' && item.id === nodeId);
216
+ if (selectedByMulti) target = { kind: 'multi', items: this.studio.selection.items };
217
+ else {
218
+ target = { kind: 'node', id: nodeId };
219
+ this.studio.select(target);
220
+ }
221
+ } else if (edgeHost?.dataset.edgeId) {
222
+ target = { kind: 'edge', id: edgeHost.dataset.edgeId };
223
+ this.studio.select(target);
224
+ } else {
225
+ target = { kind: 'canvas', id: this.studio.activeScopeId };
226
+ this.studio.select(this._selectionFallback());
227
+ }
228
+ const host = this.container.getBoundingClientRect();
229
+ this._openContextMenu(target, { x: event.clientX - host.left, y: event.clientY - host.top }, false);
230
+ }
231
+
232
+ _openContextMenu(target, position, keyboard = false) {
233
+ this.closeContextMenu();
234
+ this._selectionToolbar?.querySelectorAll('details[open]').forEach((details) => { details.open = false; });
235
+ this.renderer.closeTransientOverlays?.();
236
+ const context = { studio: this.studio, canvas: this, target, selection: this.studio.selection, position };
237
+ const actions = this.contextMenuRegistry?.resolve?.(context) || [];
238
+ if (!actions.length) return false;
239
+ this._contextMenuState = { ...context, actions, keyboard };
240
+ this._contextMenuCleanup?.();
241
+ this._contextMenuCleanup = null;
242
+ this._contextMenu.replaceChildren();
243
+ this._contextMenu.hidden = false;
244
+ if (this.contextMenuSlot instanceof HTMLElement) this._contextMenu.appendChild(this.contextMenuSlot);
245
+ else if (typeof this.contextMenuSlot === 'function') {
246
+ this._contextMenuCleanup = this.contextMenuSlot({
247
+ container: this._contextMenu,
248
+ ...context,
249
+ actions,
250
+ execute: (id) => this.executeContextAction(id),
251
+ close: () => this.closeContextMenu({ restoreFocus: keyboard }),
252
+ });
253
+ } else this._renderDefaultContextMenu(actions);
254
+ hydrateIcons(this._contextMenu, this.renderer.iconRegistry);
255
+ this._positionContextMenu(position);
256
+ requestAnimationFrame(() => this._contextMenu.querySelector('button:not(:disabled)')?.focus());
257
+ return true;
258
+ }
259
+
260
+ _renderDefaultContextMenu(actions) {
261
+ const submenuGroups = {
262
+ align: { label: '对齐', iconId: 'ui.alignCenterHorizontal' },
263
+ distribute: { label: '等距分布', iconId: 'ui.distributeHorizontal' },
264
+ pointer: { label: '操作模式', iconId: 'ui.pointer' },
265
+ };
266
+ let previousGroup = null;
267
+ for (let index = 0; index < actions.length;) {
268
+ const currentGroup = actions[index].group || 'primary';
269
+ const grouped = [];
270
+ while (index < actions.length && (actions[index].group || 'primary') === currentGroup) grouped.push(actions[index++]);
271
+ if (previousGroup !== null) this._contextMenu.appendChild(domNode('div', 'nova-context-separator'));
272
+ if (submenuGroups[currentGroup]) this._contextMenu.appendChild(this._contextSubmenu(currentGroup, submenuGroups[currentGroup], grouped));
273
+ else for (const action of grouped) this._contextMenu.appendChild(this._contextActionButton(action));
274
+ previousGroup = currentGroup;
275
+ }
276
+ }
277
+
278
+ _contextActionButton(action, submenu = false) {
279
+ const button = domNode('button', `nova-context-item${action.danger ? ' is-danger' : ''}${action.id === `pointer.${this.pointerMode}` ? ' is-active' : ''}`);
280
+ button.type = 'button';
281
+ button.setAttribute('role', action.group === 'pointer' ? 'menuitemradio' : 'menuitem');
282
+ if (action.group === 'pointer') button.setAttribute('aria-checked', action.id === `pointer.${this.pointerMode}` ? 'true' : 'false');
283
+ button.dataset.contextAction = action.id;
284
+ button.disabled = Boolean(action.disabled);
285
+ button.append(toolbarIcon(action.iconId || 'ui.locate'), domNode('span', 'nova-context-label', action.label || action.id));
286
+ button.addEventListener('click', () => this.executeContextAction(action.id));
287
+ if (submenu) button.tabIndex = -1;
288
+ return button;
289
+ }
290
+
291
+ _contextSubmenu(group, meta, actions) {
292
+ const wrap = domNode('div', 'nova-context-submenu');
293
+ wrap.dataset.contextGroup = group;
294
+ const trigger = domNode('button', 'nova-context-item');
295
+ trigger.type = 'button';
296
+ trigger.setAttribute('role', 'menuitem');
297
+ trigger.setAttribute('aria-haspopup', 'menu');
298
+ trigger.setAttribute('aria-expanded', 'false');
299
+ trigger.append(toolbarIcon(meta.iconId), domNode('span', 'nova-context-label', meta.label), toolbarIcon('ui.chevron'));
300
+ const panel = domNode('div', 'nova-context-submenu-panel');
301
+ panel.setAttribute('role', 'menu');
302
+ panel.hidden = true;
303
+ for (const action of actions) panel.appendChild(this._contextActionButton(action, true));
304
+ trigger.addEventListener('click', () => panel.hidden ? this._openContextSubmenu(wrap) : this._closeContextSubmenu(wrap));
305
+ wrap.append(trigger, panel);
306
+ return wrap;
307
+ }
308
+
309
+ _openContextSubmenu(wrap) {
310
+ this._contextMenu.querySelectorAll('.nova-context-submenu').forEach((other) => {
311
+ if (other !== wrap) this._closeContextSubmenu(other);
312
+ });
313
+ const trigger = wrap.firstElementChild;
314
+ const panel = wrap.querySelector('.nova-context-submenu-panel');
315
+ if (!panel) return;
316
+ panel.hidden = false;
317
+ trigger?.setAttribute('aria-expanded', 'true');
318
+ requestAnimationFrame(() => {
319
+ const host = this.container.getBoundingClientRect();
320
+ const rect = panel.getBoundingClientRect();
321
+ const wrapRect = wrap.getBoundingClientRect();
322
+ wrap.classList.toggle('is-flipped', wrapRect.right + rect.width + 8 > host.right);
323
+ panel.style.maxHeight = `${Math.max(120, host.bottom - Math.max(host.top + 8, wrapRect.top) - 8)}px`;
324
+ });
325
+ }
326
+
327
+ _closeContextSubmenu(wrap) {
328
+ const trigger = wrap?.firstElementChild;
329
+ const panel = wrap?.querySelector?.('.nova-context-submenu-panel');
330
+ if (panel) panel.hidden = true;
331
+ trigger?.setAttribute?.('aria-expanded', 'false');
332
+ }
333
+
334
+ _contextMenuButtons(scope) {
335
+ return [...scope.children].map((child) => {
336
+ if (child.matches?.('button.nova-context-item')) return child;
337
+ if (child.matches?.('.nova-context-submenu')) return child.querySelector(':scope > button.nova-context-item');
338
+ return null;
339
+ }).filter((button) => button && !button.disabled);
340
+ }
341
+
342
+ _handleContextMenuKeyDown(event) {
343
+ const item = event.target?.closest?.('button.nova-context-item');
344
+ if (!item) return;
345
+ const panel = item.parentElement?.matches?.('.nova-context-submenu-panel') ? item.parentElement : this._contextMenu;
346
+ const buttons = this._contextMenuButtons(panel);
347
+ const index = buttons.indexOf(item);
348
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
349
+ event.preventDefault();
350
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
351
+ buttons[(index + direction + buttons.length) % buttons.length]?.focus();
352
+ } else if (event.key === 'Home' || event.key === 'End') {
353
+ event.preventDefault();
354
+ buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus();
355
+ } else if (event.key === 'ArrowRight' && item.parentElement?.matches?.('.nova-context-submenu')) {
356
+ event.preventDefault();
357
+ this._openContextSubmenu(item.parentElement);
358
+ item.parentElement.querySelector('.nova-context-submenu-panel button:not(:disabled)')?.focus();
359
+ } else if (event.key === 'ArrowLeft' && panel.matches?.('.nova-context-submenu-panel')) {
360
+ event.preventDefault();
361
+ const wrap = panel.parentElement;
362
+ this._closeContextSubmenu(wrap);
363
+ wrap.firstElementChild?.focus();
364
+ } else if (event.key === 'Escape') {
365
+ event.preventDefault();
366
+ this.closeContextMenu({ restoreFocus: true });
367
+ }
368
+ }
369
+
370
+ _positionContextMenu(position) {
371
+ requestAnimationFrame(() => {
372
+ if (this._contextMenu.hidden) return;
373
+ const width = this._contextMenu.offsetWidth || 226;
374
+ const height = this._contextMenu.offsetHeight || 260;
375
+ const maxLeft = Math.max(8, this.container.clientWidth - width - 8);
376
+ const maxTop = Math.max(8, this.container.clientHeight - height - 8);
377
+ this._contextMenu.style.left = `${Math.round(Math.max(8, Math.min(position.x, maxLeft)))}px`;
378
+ this._contextMenu.style.top = `${Math.round(Math.max(8, Math.min(position.y, maxTop)))}px`;
379
+ });
380
+ }
381
+
382
+ executeContextAction(id) {
383
+ const state = this._contextMenuState;
384
+ const action = state?.actions?.find((candidate) => candidate.id === id);
385
+ if (!action || action.disabled || typeof action.execute !== 'function') return false;
386
+ try { action.execute(state); }
387
+ finally { this.closeContextMenu(); }
388
+ return true;
389
+ }
390
+
391
+ closeContextMenu({ restoreFocus = false } = {}) {
392
+ if (!this._contextMenu || this._contextMenu.hidden) return false;
393
+ this._contextMenuCleanup?.();
394
+ this._contextMenuCleanup = null;
395
+ this._contextMenu.hidden = true;
396
+ this._contextMenu.replaceChildren();
397
+ this._contextMenuState = null;
398
+ if (restoreFocus) this.container.focus();
399
+ return true;
400
+ }
401
+
402
+ _closeTransientCanvasMenus() {
403
+ this.closeContextMenu();
404
+ this._selectionToolbar?.querySelectorAll('details[open]').forEach((details) => { details.open = false; });
405
+ this.renderer.closeTransientOverlays?.();
406
+ }
407
+
408
+ _onStudioEvent(event) {
409
+ if (['modelChanged', 'selectionChanged', 'scopeChanged'].includes(event.type)) this.closeContextMenu();
410
+ if (event.type === 'modelChanged') this.renderer.setModel(this.studio.getActiveGraph());
411
+ if (event.type === 'selectionChanged') this.renderer.setSelection(this.studio.selection);
412
+ if (event.type === 'connectingChanged') this.renderer.setConnectingSource(this.studio.connectingSource);
413
+ if (event.type === 'scopeChanged') {
414
+ this._viewportByScope.set(this._renderedScopeId, this.renderer.getViewportState());
415
+ this._renderedScopeId = this.studio.activeScopeId;
416
+ this.renderer.options.showEmptyState = this.studio.activeScopeId !== this.studio.model.id;
417
+ this.renderer.setModel(this.studio.getActiveGraph());
418
+ const viewport = this._viewportByScope.get(this._renderedScopeId);
419
+ if (viewport) this.renderer.setViewportState(viewport);
420
+ else this.fitView(72);
421
+ }
422
+ if (['modelChanged', 'selectionChanged', 'scopeChanged'].includes(event.type)) this._renderSelectionToolbar();
423
+ }
424
+
425
+ _buildSelectionToolbar() {
426
+ this._selectionToolbar = domNode('div', 'nova-selection-toolbar');
427
+ this._selectionToolbar.hidden = true;
428
+ this._selectionToolbar.setAttribute('role', 'toolbar');
429
+ this._selectionToolbar.setAttribute('aria-label', '选区布局工具栏');
430
+ this._selectionToolbar.addEventListener('pointerdown', (event) => event.stopPropagation());
431
+ this.container.appendChild(this._selectionToolbar);
432
+ }
433
+
434
+ _toolbarActions() {
435
+ return {
436
+ align: (alignment) => this.studio.arrangeSelection({ type: 'align', alignment }),
437
+ distribute: (axis) => this.studio.arrangeSelection({ type: 'distribute', axis }),
438
+ layout: () => this.studio.arrangeSelection({ type: 'layout' }),
439
+ reroute: () => this.studio.arrangeSelection({ type: 'reroute' }),
440
+ fit: () => this.fitSelection(),
441
+ group: () => this.studio.commands.groupSelection(),
442
+ };
443
+ }
444
+
445
+ _renderSelectionToolbar() {
446
+ if (!this._selectionToolbar) return;
447
+ const diagramNodes = selectedDiagramNodes(this.studio.model, this.studio.selection, this.studio.activeScopeId);
448
+ const nodes = diagramNodes.filter(isSelectionLayoutNode);
449
+ const groupableNodes = selectedGroupableNodes(this.studio.model, this.studio.selection, this.studio.activeScopeId);
450
+ const visible = this.studio.selection?.kind === 'multi' && diagramNodes.length >= 2;
451
+ this._selectionToolbar.hidden = !visible;
452
+ this._selectionToolbarCleanup?.();
453
+ this._selectionToolbarCleanup = null;
454
+ if (!visible) {
455
+ this._selectionToolbar.replaceChildren();
456
+ return;
457
+ }
458
+
459
+ const bounds = this.studio.getSelectionBounds();
460
+ const actions = this._toolbarActions();
461
+ this._selectionToolbar.replaceChildren();
462
+ if (this.selectionToolbarSlot) {
463
+ if (this.selectionToolbarSlot instanceof HTMLElement) this._selectionToolbar.appendChild(this.selectionToolbarSlot);
464
+ else if (typeof this.selectionToolbarSlot === 'function') {
465
+ this._selectionToolbarCleanup = this.selectionToolbarSlot({
466
+ container: this._selectionToolbar,
467
+ studio: this.studio,
468
+ canvas: this,
469
+ selection: this.studio.selection,
470
+ elements: diagramNodes,
471
+ bounds,
472
+ actions,
473
+ });
474
+ }
475
+ this._positionSelectionToolbar();
476
+ return;
477
+ }
478
+
479
+ const button = (id, iconId, title, action, { label = '', disabled = false, menuItem = false } = {}) => {
480
+ const item = domNode('button', `nova-selection-action${label ? menuItem ? ' has-menu-label' : ' has-label' : ''}`);
481
+ item.type = 'button';
482
+ item.dataset.selectionAction = id;
483
+ item.title = title;
484
+ item.setAttribute('aria-label', title);
485
+ item.disabled = disabled;
486
+ item.appendChild(toolbarIcon(iconId));
487
+ if (label) item.appendChild(domNode('span', '', label));
488
+ item.addEventListener('click', () => action());
489
+ return item;
490
+ };
491
+ const menu = (id, iconId, title, items, disabled = false) => {
492
+ const details = domNode('details', `nova-selection-menu${disabled ? ' is-disabled' : ''}`);
493
+ details.dataset.selectionMenu = id;
494
+ details.addEventListener('toggle', () => {
495
+ if (!details.open) return;
496
+ this.closeContextMenu();
497
+ this._selectionToolbar.querySelectorAll('details[open]').forEach((other) => {
498
+ if (other !== details) other.open = false;
499
+ });
500
+ });
501
+ const summary = domNode('summary', 'nova-selection-action');
502
+ summary.title = title;
503
+ summary.setAttribute('aria-label', title);
504
+ summary.append(toolbarIcon(iconId), toolbarIcon('ui.chevron'));
505
+ if (disabled) summary.addEventListener('click', (event) => event.preventDefault());
506
+ const popover = domNode('div', 'nova-selection-popover');
507
+ for (const item of items) popover.appendChild(button(item.id, item.iconId, item.title, () => {
508
+ item.action();
509
+ details.open = false;
510
+ }, { disabled: item.disabled, label: item.title, menuItem: true }));
511
+ details.append(summary, popover);
512
+ return details;
513
+ };
514
+ const separator = () => domNode('span', 'nova-selection-separator');
515
+ const alignmentItems = [
516
+ ['left', 'ui.alignLeft', '左对齐'], ['centerX', 'ui.alignCenterHorizontal', '水平居中'], ['right', 'ui.alignRight', '右对齐'],
517
+ ['top', 'ui.alignTop', '顶部对齐'], ['centerY', 'ui.alignCenterVertical', '垂直居中'], ['bottom', 'ui.alignBottom', '底部对齐'],
518
+ ].map(([value, iconId, title]) => ({ id: `align-${value}`, iconId, title, action: () => actions.align(value) }));
519
+ const distributeItems = [
520
+ { id: 'distribute-horizontal', iconId: 'ui.distributeHorizontal', title: '水平等距', action: () => actions.distribute('horizontal'), disabled: nodes.length < 3 },
521
+ { id: 'distribute-vertical', iconId: 'ui.distributeVertical', title: '垂直等距', action: () => actions.distribute('vertical'), disabled: nodes.length < 3 },
522
+ ];
523
+ this._selectionToolbar.append(
524
+ button('group', 'ui.layoutSelection', '创建分组', actions.group, { label: '创建分组', disabled: !groupableNodes.length || groupableNodes.length !== diagramNodes.length }),
525
+ separator(),
526
+ menu('align', 'ui.alignCenterHorizontal', '对齐选区', alignmentItems, nodes.length < 2),
527
+ menu('distribute', 'ui.distributeHorizontal', '等距分布', distributeItems, nodes.length < 3),
528
+ separator(),
529
+ button('layout', 'ui.layoutSelection', '美化选区', actions.layout, { label: '美化选区', disabled: nodes.length < 2 }),
530
+ button('reroute', 'ui.rerouteSelection', '优化选区连线', actions.reroute, { disabled: nodes.length < 1 }),
531
+ separator(),
532
+ button('fit', 'ui.fitSelection', '适应选区', actions.fit),
533
+ );
534
+ hydrateIcons(this._selectionToolbar, this.renderer.iconRegistry);
535
+ this._positionSelectionToolbar();
536
+ }
537
+
538
+ _positionSelectionToolbar() {
539
+ const toolbar = this._selectionToolbar;
540
+ const bounds = this.studio.getSelectionBounds();
541
+ if (!toolbar || toolbar.hidden || !bounds || !this.renderer) return;
542
+ const viewport = this.renderer.viewport.getBoundingClientRect();
543
+ const zoom = this.renderer.zoom;
544
+ const pan = this.renderer.pan;
545
+ const width = toolbar.offsetWidth || 252;
546
+ const height = toolbar.offsetHeight || 38;
547
+ const center = (bounds.x + bounds.width / 2) * zoom + pan.x;
548
+ let left = center - width / 2;
549
+ left = Math.max(8, Math.min(left, viewport.width - width - 8));
550
+ const selectionTop = bounds.y * zoom + pan.y;
551
+ const selectionBottom = (bounds.y + bounds.height) * zoom + pan.y;
552
+ let top = selectionTop - height - 10;
553
+ const below = top < 48;
554
+ if (below) top = selectionBottom + 10;
555
+ top = Math.max(8, Math.min(top, viewport.height - height - 8));
556
+ toolbar.classList.toggle('is-below', below);
557
+ toolbar.style.left = `${Math.round(left)}px`;
558
+ toolbar.style.top = `${Math.round(top)}px`;
559
+ }
560
+
561
+ _enterSubProcess(node) {
562
+ if (this.pointerMode === 'pan') return false;
563
+ if (!isEmbeddedSubProcess(node)) return false;
564
+ return this.studio.enterScope(node.id);
565
+ }
566
+
567
+ _selectionFallback() {
568
+ return this.studio.activeScopeId === this.studio.model.id
569
+ ? { kind: 'process', id: this.studio.model.id }
570
+ : { kind: 'node', id: this.studio.activeScopeId };
571
+ }
572
+
573
+ _handleNodeClick(node, event) {
574
+ if (this.pointerMode === 'pan') return;
575
+ if (this._suppressNodeClick === node.id) {
576
+ this._suppressNodeClick = null;
577
+ return;
578
+ }
579
+ if (this.studio.connectingSource && this.studio.connectingSource !== node.id) {
580
+ const edge = this.studio.commands.connect(this.studio.connectingSource, node.id);
581
+ if (edge) this.studio.cancelConnect();
582
+ return;
583
+ }
584
+ const mode = event?.ctrlKey || event?.metaKey ? 'toggle' : event?.shiftKey ? 'add' : 'replace';
585
+ if (mode !== 'replace' && isMarqueeSelectableNode(node)) {
586
+ const current = this.studio.selection?.kind === 'multi'
587
+ ? this.studio.selection.items.filter((item) => item.kind === 'node')
588
+ : this.studio.selection?.kind === 'node'
589
+ ? [{ kind: 'node', id: this.studio.selection.id }]
590
+ : [];
591
+ const index = current.findIndex((item) => item.id === node.id);
592
+ if (mode === 'toggle' && index >= 0) current.splice(index, 1);
593
+ else if (index < 0) current.push({ kind: 'node', id: node.id });
594
+ this.studio.select(!current.length ? this._selectionFallback() : current.length === 1 ? current[0] : { kind: 'multi', items: current });
595
+ return;
596
+ }
597
+ this.studio.select({ kind: 'node', id: node.id });
598
+ }
599
+
600
+ _captureNodeGeometry() {
601
+ return new Map(this.studio.model.nodes.map((candidate) => [candidate.id, {
602
+ x: candidate.x,
603
+ y: candidate.y,
604
+ width: candidate.width,
605
+ height: candidate.height,
606
+ }]));
607
+ }
608
+
609
+ _restoreNodeGeometry(geometry) {
610
+ for (const candidate of this.studio.model.nodes) {
611
+ const value = geometry.get(candidate.id);
612
+ if (value) Object.assign(candidate, value);
613
+ }
614
+ }
615
+
616
+ _clearChangedWaypoints(geometry) {
617
+ for (const candidate of this.studio.model.nodes) {
618
+ const before = geometry.get(candidate.id);
619
+ if (!before || before.x !== candidate.x || before.y !== candidate.y || before.width !== candidate.width || before.height !== candidate.height) {
620
+ clearConnectedWaypoints(this.studio.model, candidate.id);
621
+ }
622
+ }
623
+ }
624
+
625
+ _startNodeResize(node, handle, event) {
626
+ if (event.button !== 0 || this.pointerMode === 'pan') return;
627
+ const kind = NODE_DEFINITIONS[node.type]?.kind;
628
+ if (!['group', 'participant', 'lane'].includes(kind) || (kind === 'lane' && node.containerId)) return;
629
+ const snapshot = this.studio.beginGesture();
630
+ const geometry = this._captureNodeGeometry();
631
+ const origin = this.clientToWorld(event.clientX, event.clientY);
632
+ const start = { x: node.x, y: node.y, width: node.width, height: node.height };
633
+ const minimum = kind === 'group'
634
+ ? { width: CONTAINMENT_LIMITS.groupMinWidth, height: CONTAINMENT_LIMITS.groupMinHeight }
635
+ : kind === 'participant'
636
+ ? { width: CONTAINMENT_LIMITS.participantMinWidth, height: CONTAINMENT_LIMITS.participantMinHeight }
637
+ : { width: CONTAINMENT_LIMITS.laneMinWidth, height: CONTAINMENT_LIMITS.laneMinHeight };
638
+ let moved = false;
639
+ const move = (moveEvent) => {
640
+ const point = this.clientToWorld(moveEvent.clientX, moveEvent.clientY);
641
+ let pointerX = point.x;
642
+ let pointerY = point.y;
643
+ if (!moveEvent.altKey && this.studio.model.settings?.snapToGrid) {
644
+ const grid = this.studio.model.settings.gridSize || 16;
645
+ pointerX = Math.round(pointerX / grid) * grid;
646
+ pointerY = Math.round(pointerY / grid) * grid;
647
+ }
648
+ const dx = pointerX - origin.x;
649
+ const dy = pointerY - origin.y;
650
+ moved = moved || Math.hypot(dx, dy) > 1;
651
+ let left = start.x;
652
+ let top = start.y;
653
+ let right = start.x + start.width;
654
+ let bottom = start.y + start.height;
655
+ if (handle.includes('w')) left += dx;
656
+ if (handle.includes('e')) right += dx;
657
+ if (handle.includes('n')) top += dy;
658
+ if (handle.includes('s')) bottom += dy;
659
+ if (right - left < minimum.width) {
660
+ if (handle.includes('w')) left = right - minimum.width;
661
+ else right = left + minimum.width;
662
+ }
663
+ if (bottom - top < minimum.height) {
664
+ if (handle.includes('n')) top = bottom - minimum.height;
665
+ else bottom = top + minimum.height;
666
+ }
667
+ this._restoreNodeGeometry(geometry);
668
+ applyContainmentOperation(this.studio.model, {
669
+ type: 'resize-container',
670
+ nodeId: node.id,
671
+ bounds: { x: Math.round(left), y: Math.round(top), width: Math.round(right - left), height: Math.round(bottom - top) },
672
+ });
673
+ this.renderer.render();
674
+ this._positionSelectionToolbar();
675
+ };
676
+ const up = () => {
677
+ window.removeEventListener('pointermove', move);
678
+ window.removeEventListener('pointerup', up);
679
+ if (moved) {
680
+ this._clearChangedWaypoints(geometry);
681
+ this._suppressNodeClick = node.id;
682
+ this.studio.commitGesture(snapshot, 'resize-node');
683
+ } else this.renderer.render();
684
+ };
685
+ window.addEventListener('pointermove', move);
686
+ window.addEventListener('pointerup', up, { once: true });
687
+ }
688
+
689
+ _startLaneDividerResize(lane, event) {
690
+ if (event.button !== 0 || !lane.containerId || this.pointerMode === 'pan') return;
691
+ const snapshot = this.studio.beginGesture();
692
+ const geometry = this._captureNodeGeometry();
693
+ const origin = this.clientToWorld(event.clientX, event.clientY);
694
+ let moved = false;
695
+ const move = (moveEvent) => {
696
+ const point = this.clientToWorld(moveEvent.clientX, moveEvent.clientY);
697
+ let delta = point.y - origin.y;
698
+ if (!moveEvent.altKey && this.studio.model.settings?.snapToGrid) {
699
+ const grid = this.studio.model.settings.gridSize || 16;
700
+ delta = Math.round(delta / grid) * grid;
701
+ }
702
+ this._restoreNodeGeometry(geometry);
703
+ const result = applyContainmentOperation(this.studio.model, {
704
+ type: 'resize-divider', laneId: lane.id, delta: Math.round(delta),
705
+ });
706
+ moved = moved || result.changed;
707
+ this.renderer.render();
708
+ };
709
+ const up = () => {
710
+ window.removeEventListener('pointermove', move);
711
+ window.removeEventListener('pointerup', up);
712
+ if (moved) {
713
+ this._clearChangedWaypoints(geometry);
714
+ this._suppressNodeClick = lane.id;
715
+ this.studio.commitGesture(snapshot, 'resize-lane-divider');
716
+ } else this.renderer.render();
717
+ };
718
+ window.addEventListener('pointermove', move);
719
+ window.addEventListener('pointerup', up, { once: true });
720
+ }
721
+
722
+ _startNodeDrag(node, event) {
723
+ if (event.button !== 0 || this.studio.connectingSource || this.pointerMode === 'pan') return;
724
+ if (event.shiftKey || event.ctrlKey || event.metaKey) return;
725
+ event.preventDefault();
726
+ const selectedIds = new Set(
727
+ this.studio.selection?.kind === 'multi'
728
+ ? this.studio.selection.items.filter((item) => item.kind === 'node').map((item) => item.id)
729
+ : this.studio.selection?.kind === 'node'
730
+ ? [this.studio.selection.id]
731
+ : [],
732
+ );
733
+ if (!selectedIds.has(node.id)) {
734
+ selectedIds.clear();
735
+ selectedIds.add(node.id);
736
+ this.studio.select({ kind: 'node', id: node.id });
737
+ }
738
+ if (selectedIds.size > 1 && NODE_DEFINITIONS[node.type]?.kind === 'lane' && node.containerId) {
739
+ selectedIds.clear();
740
+ selectedIds.add(node.id);
741
+ this.studio.select({ kind: 'node', id: node.id });
742
+ }
743
+ const containment = resolveContainment(this.studio.model);
744
+ const movableIds = new Set(selectedIds);
745
+ for (const selectedId of selectedIds) {
746
+ const selected = getNode(this.studio.model, selectedId);
747
+ const selectedKind = NODE_DEFINITIONS[selected?.type]?.kind;
748
+ if (selectedKind === 'participant') {
749
+ for (const lane of containment.getParticipantLanes(selected.id)) {
750
+ movableIds.add(lane.id);
751
+ for (const ref of lane.properties?.flowNodeRefs || []) movableIds.add(ref);
752
+ }
753
+ } else if (selectedKind === 'lane' && (!selected.containerId || selectedIds.has(selected.containerId))) {
754
+ for (const ref of selected.properties?.flowNodeRefs || []) movableIds.add(ref);
755
+ }
756
+ }
757
+ for (const candidate of this.studio.model.nodes) {
758
+ if (movableIds.has(candidate.properties?.attachedToRef)) movableIds.add(candidate.id);
759
+ }
760
+ if (selectedIds.size === 1 && NODE_DEFINITIONS[node.type]?.kind === 'lane' && node.containerId) {
761
+ movableIds.clear();
762
+ movableIds.add(node.id);
763
+ }
764
+ const movable = this.studio.getActiveGraph().nodes.filter((candidate) => movableIds.has(candidate.id));
765
+ const positions = new Map(movable.map((candidate) => [candidate.id, { x: candidate.x, y: candidate.y }]));
766
+ const geometry = this._captureNodeGeometry();
767
+ const origin = this.clientToWorld(event.clientX, event.clientY);
768
+ const nodeKind = NODE_DEFINITIONS[node.type]?.kind;
769
+ const containerMove = selectedIds.size === 1 && (nodeKind === 'participant' || (nodeKind === 'lane' && !node.containerId));
770
+ this._drag = {
771
+ kind: 'node', id: node.id, origin, positions, geometry, movableIds, selectedIds,
772
+ x: node.x, y: node.y, moved: false, snapshot: this.studio.beginGesture(), containerMove,
773
+ };
774
+ const move = (moveEvent) => {
775
+ const current = getNode(this.studio.model, node.id);
776
+ if (!this._drag || !current) return;
777
+ const point = this.clientToWorld(moveEvent.clientX, moveEvent.clientY);
778
+ const dx = point.x - this._drag.origin.x;
779
+ const dy = point.y - this._drag.origin.y;
780
+ if (Math.hypot(dx, dy) > 2) this._drag.moved = true;
781
+ let x = this._drag.x + dx;
782
+ let y = this._drag.y + dy;
783
+ if (!moveEvent.altKey && this.studio.model.settings?.snapToGrid) {
784
+ const grid = this.studio.model.settings.gridSize || 16;
785
+ x = Math.round(x / grid) * grid;
786
+ y = Math.round(y / grid) * grid;
787
+ }
788
+ const activeGraph = this.studio.getActiveGraph();
789
+ const snapGraph = { ...activeGraph, nodes: activeGraph.nodes.filter((candidate) => !this._drag.movableIds.has(candidate.id)) };
790
+ const snapped = snapNodePosition(snapGraph, current, x, y, {
791
+ threshold: this.studio.model.settings?.alignmentThreshold ?? 7,
792
+ disabled: moveEvent.altKey,
793
+ });
794
+ const moveX = Math.round(snapped.x) - this._drag.x;
795
+ const moveY = Math.round(snapped.y) - this._drag.y;
796
+ if (this._drag.containerMove) {
797
+ this._restoreNodeGeometry(this._drag.geometry);
798
+ applyContainmentOperation(this.studio.model, { type: 'move-container', nodeId: node.id, dx: moveX, dy: moveY });
799
+ } else {
800
+ for (const candidate of movable) {
801
+ const start = this._drag.positions.get(candidate.id);
802
+ candidate.x = Math.round(start.x + moveX);
803
+ candidate.y = Math.round(start.y + moveY);
804
+ }
805
+ }
806
+ for (const candidate of movable) clearConnectedWaypoints(this.studio.model, candidate.id);
807
+ this.renderer.setAlignmentGuides(snapped.guides || []);
808
+ this.renderer.render();
809
+ this._positionSelectionToolbar();
810
+ };
811
+ const up = () => {
812
+ window.removeEventListener('pointermove', move);
813
+ window.removeEventListener('pointerup', up);
814
+ const drag = this._drag;
815
+ this._drag = null;
816
+ this.renderer.setAlignmentGuides([]);
817
+ if (drag?.moved) {
818
+ const dragged = getNode(this.studio.model, drag.id);
819
+ if (NODE_DEFINITIONS[dragged?.type]?.kind === 'lane') {
820
+ const drop = { x: dragged.x + dragged.width / 2, y: dragged.y + dragged.height / 2 };
821
+ const target = resolveContainment(this.studio.model).getParticipantAt(drop);
822
+ const finalPosition = { x: dragged.x, y: dragged.y };
823
+ this._restoreNodeGeometry(drag.geometry);
824
+ if (target) {
825
+ const siblings = resolveContainment(this.studio.model).getParticipantLanes(target.id).filter((candidate) => candidate.id !== dragged.id);
826
+ const index = siblings.filter((candidate) => drop.y > candidate.y + candidate.height / 2).length;
827
+ applyContainmentOperation(this.studio.model, { type: 'attach-lane', laneId: dragged.id, participantId: target.id, index });
828
+ } else if (dragged.containerId) {
829
+ applyContainmentOperation(this.studio.model, { type: 'detach-lane', laneId: dragged.id, ...finalPosition });
830
+ } else {
831
+ applyContainmentOperation(this.studio.model, { type: 'move-container', nodeId: dragged.id, dx: finalPosition.x - dragged.x, dy: finalPosition.y - dragged.y });
832
+ }
833
+ } else {
834
+ for (const selectedId of drag.selectedIds) applyContainmentOperation(this.studio.model, { type: 'assign-node', nodeId: selectedId });
835
+ applyContainmentOperation(this.studio.model, { type: 'reconcile' });
836
+ }
837
+ this._clearChangedWaypoints(drag.geometry);
838
+ this._suppressNodeClick = drag.id;
839
+ this.studio.commitGesture(drag.snapshot, drag.selectedIds.size > 1 ? 'move-selection' : 'move-node');
840
+ } else this.renderer.render();
841
+ };
842
+ window.addEventListener('pointermove', move);
843
+ window.addEventListener('pointerup', up, { once: true });
844
+ }
845
+
846
+ _startEdgeBend(edge, index, points, event) {
847
+ if (event.button !== 0) return;
848
+ const snapshot = this.studio.beginGesture();
849
+ edge.waypoints = points.map((point) => ({ ...point }));
850
+ const move = (moveEvent) => {
851
+ const point = this.clientToWorld(moveEvent.clientX, moveEvent.clientY);
852
+ if (edge.waypoints?.[index]) edge.waypoints[index] = { x: Math.round(point.x), y: Math.round(point.y) };
853
+ this.renderer.render();
854
+ };
855
+ const up = () => {
856
+ window.removeEventListener('pointermove', move);
857
+ window.removeEventListener('pointerup', up);
858
+ this.studio.commitGesture(snapshot, 'bend-edge');
859
+ this.studio.select({ kind: 'edge', id: edge.id });
860
+ };
861
+ window.addEventListener('pointermove', move);
862
+ window.addEventListener('pointerup', up, { once: true });
863
+ }
864
+
865
+ _handleKeyDown(event) {
866
+ if (event.target?.matches?.('input,textarea,select,[contenteditable="true"]')) return;
867
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) {
868
+ event.preventDefault();
869
+ this._closeTransientCanvasMenus();
870
+ this.studio.cancelConnect();
871
+ this._openContextMenu(this._contextTargetFromSelection(), this._contextPositionForSelection(), true);
872
+ }
873
+ else if (event.code === 'Space') { event.preventDefault(); this.renderer.setSpacePressed(true); }
874
+ else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { event.preventDefault(); event.shiftKey ? this.studio.redo() : this.studio.undo(); }
875
+ else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') { event.preventDefault(); this.studio.redo(); }
876
+ else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); this.studio.commands.remove(); }
877
+ else if (event.key === 'Enter' && this.studio.selection?.kind === 'node') {
878
+ const node = getNode(this.studio.model, this.studio.selection.id);
879
+ if (isEmbeddedSubProcess(node) && elementScopeId(this.studio.model, node) === this.studio.activeScopeId) { event.preventDefault(); this._enterSubProcess(node); }
880
+ }
881
+ else if (event.key === 'Escape') this.studio.cancelConnect();
882
+ }
883
+
884
+ _dragOver(event) {
885
+ if (!this.interactions || (!event.dataTransfer?.types?.includes(CREATE_INTENT_MIME) && !event.dataTransfer?.types?.includes('text/plain'))) return;
886
+ event.preventDefault();
887
+ event.dataTransfer.dropEffect = 'copy';
888
+ }
889
+
890
+ _drop(event) {
891
+ if (!this.interactions) return;
892
+ const raw = event.dataTransfer?.getData(CREATE_INTENT_MIME) || event.dataTransfer?.getData('text/plain');
893
+ if (!raw) return;
894
+ event.preventDefault();
895
+ this.interactions.drop(this.interactions.deserialize(raw), event, this);
896
+ }
897
+
898
+ clientToWorld(clientX, clientY) { return this.renderer.screenToWorld(clientX, clientY); }
899
+ getViewportCenterWorld() {
900
+ const rect = this.renderer.viewport.getBoundingClientRect();
901
+ return this.clientToWorld(rect.left + rect.width / 2, rect.top + rect.height / 2);
902
+ }
903
+ openQuickAdd(nodeId) { return this.renderer.openQuickMenu?.(nodeId) || false; }
904
+ startConnect(nodeId) { this.renderer.closeTransientOverlays?.(); return this.studio.startConnect(nodeId); }
905
+ editEdgeName(edgeId) { return this.renderer.editEdgeName?.(edgeId) || false; }
906
+ fitView(padding, options) { this.renderer.fitView(padding, { minZoom: 0.7, ...(options || {}) }); }
907
+ fitSelection(padding = 72) {
908
+ const bounds = this.studio.getSelectionBounds();
909
+ if (!bounds) return false;
910
+ const rect = this.renderer.viewport.getBoundingClientRect();
911
+ const availableWidth = Math.max(1, rect.width - padding * 2);
912
+ const availableHeight = Math.max(1, rect.height - padding * 2);
913
+ const zoom = Math.max(0.5, Math.min(2, Math.min(availableWidth / Math.max(bounds.width, 1), availableHeight / Math.max(bounds.height, 1))));
914
+ this.renderer.setViewportState({
915
+ zoom,
916
+ pan: {
917
+ x: rect.width / 2 - (bounds.x + bounds.width / 2) * zoom,
918
+ y: rect.height / 2 - (bounds.y + bounds.height / 2) * zoom,
919
+ },
920
+ });
921
+ return true;
922
+ }
923
+ zoomBy(factor) { this.renderer.zoomBy(factor); }
924
+ actualSize(padding) { this.renderer.actualSize(padding); }
925
+ getViewportState() { return this.renderer.getViewportState(); }
926
+ setTheme(theme) { return this.renderer.setTheme(theme); }
927
+ setThemeMode(mode) { return this.renderer.setThemeMode(mode); }
928
+ getThemeState() { return this.renderer.getThemeState(); }
929
+ destroy() {
930
+ this._offStudio?.();
931
+ this.container.removeEventListener('keydown', this._onKeyDown);
932
+ this.container.removeEventListener('keyup', this._onKeyUp);
933
+ this.container.removeEventListener('dragover', this._onDragOver);
934
+ this.container.removeEventListener('drop', this._onDrop);
935
+ this.renderer.viewport.removeEventListener('contextmenu', this._onContextMenu);
936
+ document.removeEventListener('pointerdown', this._onToolbarOutside);
937
+ this._selectionToolbarCleanup?.();
938
+ this._contextMenuCleanup?.();
939
+ this.renderer.destroy();
940
+ }
941
+ }
942
+
943
+ export function createBpmnCanvas(options) { return new BpmnCanvas(options); }