@bpmnkit/editor 0.0.8

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/editor.js ADDED
@@ -0,0 +1,1977 @@
1
+ import { KeyboardHandler, ViewportController, computeDiagramBounds, createDefs, createGrid, injectStyles, render, } from "@bpmnkit/canvas";
2
+ import { Bpmn, applyAutoLayout } from "@bpmnkit/core";
3
+ import { CommandStack } from "./command-stack.js";
4
+ import { injectEditorStyles } from "./css.js";
5
+ import { closestPort, computeWaypoints, computeWaypointsAvoiding, computeWaypointsWithPorts, diagramToScreen, labelBoundsForPosition, portFromWaypoint, screenToDiagram, } from "./geometry.js";
6
+ import { LabelEditor } from "./label-editor.js";
7
+ import { changeElementType as changeElementTypeFn, copyElements, createAnnotation, createAnnotationWithLink, createBoundaryEvent, createConnection, createEmptyDefinitions, createShape, deleteElements, insertEdgeWaypoint, insertShapeOnEdge, moveEdgeWaypoint, moveShapes, pasteElements, removeCollinearWaypoints, resizeShape, updateEdgeEndpoint, updateLabel, updateLabelPosition, updateShapeColor, } from "./modeling.js";
8
+ import { OverlayRenderer } from "./overlay.js";
9
+ import { canConnect } from "./rules.js";
10
+ import { EditorStateMachine } from "./state-machine.js";
11
+ import { RESIZABLE_TYPES } from "./types.js";
12
+ const NS = "http://www.w3.org/2000/svg";
13
+ let _instanceCounter = 0;
14
+ function defaultBounds(type, cx, cy) {
15
+ switch (type) {
16
+ case "startEvent":
17
+ case "messageStartEvent":
18
+ case "timerStartEvent":
19
+ case "conditionalStartEvent":
20
+ case "signalStartEvent":
21
+ case "endEvent":
22
+ case "messageEndEvent":
23
+ case "escalationEndEvent":
24
+ case "errorEndEvent":
25
+ case "compensationEndEvent":
26
+ case "signalEndEvent":
27
+ case "terminateEndEvent":
28
+ case "intermediateThrowEvent":
29
+ case "intermediateCatchEvent":
30
+ case "messageCatchEvent":
31
+ case "messageThrowEvent":
32
+ case "timerCatchEvent":
33
+ case "escalationThrowEvent":
34
+ case "conditionalCatchEvent":
35
+ case "linkCatchEvent":
36
+ case "linkThrowEvent":
37
+ case "compensationThrowEvent":
38
+ case "signalCatchEvent":
39
+ case "signalThrowEvent":
40
+ return { x: cx - 18, y: cy - 18, width: 36, height: 36 };
41
+ case "exclusiveGateway":
42
+ case "parallelGateway":
43
+ case "inclusiveGateway":
44
+ case "eventBasedGateway":
45
+ case "complexGateway":
46
+ return { x: cx - 25, y: cy - 25, width: 50, height: 50 };
47
+ case "subProcess":
48
+ case "adHocSubProcess":
49
+ case "transaction":
50
+ return { x: cx - 100, y: cy - 60, width: 200, height: 120 };
51
+ case "textAnnotation":
52
+ return { x: cx - 50, y: cy - 25, width: 100, height: 50 };
53
+ default:
54
+ return { x: cx - 50, y: cy - 40, width: 100, height: 80 };
55
+ }
56
+ }
57
+ function resolveEventPaletteType(bpmnType, defType) {
58
+ if (bpmnType === "startEvent") {
59
+ if (defType === "message")
60
+ return "messageStartEvent";
61
+ if (defType === "timer")
62
+ return "timerStartEvent";
63
+ if (defType === "conditional")
64
+ return "conditionalStartEvent";
65
+ if (defType === "signal")
66
+ return "signalStartEvent";
67
+ }
68
+ if (bpmnType === "endEvent") {
69
+ if (defType === "message")
70
+ return "messageEndEvent";
71
+ if (defType === "escalation")
72
+ return "escalationEndEvent";
73
+ if (defType === "error")
74
+ return "errorEndEvent";
75
+ if (defType === "compensate")
76
+ return "compensationEndEvent";
77
+ if (defType === "signal")
78
+ return "signalEndEvent";
79
+ if (defType === "terminate")
80
+ return "terminateEndEvent";
81
+ }
82
+ if (bpmnType === "intermediateCatchEvent") {
83
+ if (defType === "message")
84
+ return "messageCatchEvent";
85
+ if (defType === "timer")
86
+ return "timerCatchEvent";
87
+ if (defType === "conditional")
88
+ return "conditionalCatchEvent";
89
+ if (defType === "link")
90
+ return "linkCatchEvent";
91
+ if (defType === "signal")
92
+ return "signalCatchEvent";
93
+ }
94
+ if (bpmnType === "intermediateThrowEvent") {
95
+ if (defType === "message")
96
+ return "messageThrowEvent";
97
+ if (defType === "escalation")
98
+ return "escalationThrowEvent";
99
+ if (defType === "link")
100
+ return "linkThrowEvent";
101
+ if (defType === "compensate")
102
+ return "compensationThrowEvent";
103
+ if (defType === "signal")
104
+ return "signalThrowEvent";
105
+ }
106
+ return bpmnType;
107
+ }
108
+ const INTERMEDIATE_EVENT_TYPES = new Set([
109
+ "intermediateThrowEvent",
110
+ "intermediateCatchEvent",
111
+ "messageCatchEvent",
112
+ "messageThrowEvent",
113
+ "timerCatchEvent",
114
+ "escalationThrowEvent",
115
+ "conditionalCatchEvent",
116
+ "linkCatchEvent",
117
+ "linkThrowEvent",
118
+ "compensationThrowEvent",
119
+ "signalCatchEvent",
120
+ "signalThrowEvent",
121
+ ]);
122
+ const ACTIVITY_TYPES = new Set([
123
+ "task",
124
+ "serviceTask",
125
+ "userTask",
126
+ "scriptTask",
127
+ "sendTask",
128
+ "receiveTask",
129
+ "businessRuleTask",
130
+ "manualTask",
131
+ "callActivity",
132
+ "subProcess",
133
+ "adHocSubProcess",
134
+ "eventSubProcess",
135
+ "transaction",
136
+ ]);
137
+ function isIntermediateEventType(type) {
138
+ return INTERMEDIATE_EVENT_TYPES.has(type);
139
+ }
140
+ function isActivityType(type) {
141
+ return ACTIVITY_TYPES.has(type);
142
+ }
143
+ function snapToBoundary(center, hostBounds, r) {
144
+ // Clamp center to host boundary and return event bounds
145
+ const left = hostBounds.x;
146
+ const right = hostBounds.x + hostBounds.width;
147
+ const top = hostBounds.y;
148
+ const bottom = hostBounds.y + hostBounds.height;
149
+ // Find the nearest point on the rect border
150
+ const clampedX = Math.max(left, Math.min(right, center.x));
151
+ const clampedY = Math.max(top, Math.min(bottom, center.y));
152
+ // Determine which edge is closest
153
+ const dLeft = Math.abs(center.x - left);
154
+ const dRight = Math.abs(center.x - right);
155
+ const dTop = Math.abs(center.y - top);
156
+ const dBottom = Math.abs(center.y - bottom);
157
+ const minD = Math.min(dLeft, dRight, dTop, dBottom);
158
+ let snapX = clampedX;
159
+ let snapY = clampedY;
160
+ if (minD === dLeft)
161
+ snapX = left;
162
+ else if (minD === dRight)
163
+ snapX = right;
164
+ else if (minD === dTop)
165
+ snapY = top;
166
+ else
167
+ snapY = bottom;
168
+ // Clamp to host boundary so center is on the edge
169
+ snapX = Math.max(left, Math.min(right, snapX));
170
+ snapY = Math.max(top, Math.min(bottom, snapY));
171
+ return { x: snapX - r, y: snapY - r, width: r * 2, height: r * 2 };
172
+ }
173
+ function intermediateEventDefType(type) {
174
+ switch (type) {
175
+ case "messageCatchEvent":
176
+ case "messageThrowEvent":
177
+ return "message";
178
+ case "timerCatchEvent":
179
+ return "timer";
180
+ case "escalationThrowEvent":
181
+ return "escalation";
182
+ case "conditionalCatchEvent":
183
+ return "conditional";
184
+ case "linkCatchEvent":
185
+ case "linkThrowEvent":
186
+ return "link";
187
+ case "compensationThrowEvent":
188
+ return "compensate";
189
+ case "signalCatchEvent":
190
+ case "signalThrowEvent":
191
+ return "signal";
192
+ default:
193
+ return null;
194
+ }
195
+ }
196
+ /**
197
+ * BpmnEditor — a full BPMN 2.0 diagram editor with create, move, resize,
198
+ * connect, delete, label-edit, undo/redo, and copy/paste.
199
+ */
200
+ export class BpmnEditor {
201
+ // ── DOM ────────────────────────────────────────────────────────────
202
+ _id;
203
+ _host;
204
+ _svg;
205
+ _viewportG;
206
+ _containersG;
207
+ _edgesG;
208
+ _shapesG;
209
+ _labelsG;
210
+ _overlayG;
211
+ _gridPattern = null;
212
+ _markerId = "";
213
+ // ── Sub-systems ────────────────────────────────────────────────────
214
+ _viewport;
215
+ _keyboard;
216
+ _overlay;
217
+ _commandStack;
218
+ _stateMachine;
219
+ _labelEditor;
220
+ _plugins = [];
221
+ // ── State ──────────────────────────────────────────────────────────
222
+ _shapes = [];
223
+ _edges = [];
224
+ _defs = null;
225
+ _selectedIds = [];
226
+ _theme;
227
+ _fit;
228
+ _clipboard = null;
229
+ _snapDelta = null;
230
+ _selectedEdgeId = null;
231
+ _edgeDropTarget = null;
232
+ _ghostSnapCenter = null;
233
+ _createEdgeDropTarget = null;
234
+ _readOnly = false;
235
+ _isDragging = false;
236
+ _boundaryHostId = null;
237
+ _warningBanner = null;
238
+ // ── Events ─────────────────────────────────────────────────────────
239
+ _listeners = new Map();
240
+ // ── Resize observer ────────────────────────────────────────────────
241
+ _ro;
242
+ constructor(options) {
243
+ injectStyles();
244
+ injectEditorStyles();
245
+ this._id = String(_instanceCounter++);
246
+ // Resolve initial theme — localStorage overrides the options.theme when persistTheme is on
247
+ let initialTheme = options.theme ?? "auto";
248
+ if (options.persistTheme) {
249
+ try {
250
+ const stored = localStorage.getItem("bpmnkit-theme");
251
+ if (stored === "dark" || stored === "light" || stored === "auto") {
252
+ initialTheme = stored;
253
+ }
254
+ }
255
+ catch {
256
+ // localStorage unavailable — fall back to options.theme
257
+ }
258
+ }
259
+ this._theme = initialTheme;
260
+ this._fit = options.fit ?? "contain";
261
+ // ── DOM ──────────────────────────────────────────────────────
262
+ const container = options.container;
263
+ container.innerHTML = "";
264
+ this._host = document.createElement("div");
265
+ this._host.className = "bpmnkit-canvas-host";
266
+ this._host.setAttribute("role", "application");
267
+ this._host.setAttribute("aria-label", "BPMN Editor");
268
+ this._host.setAttribute("tabindex", "0");
269
+ this._applyTheme(this._theme);
270
+ container.appendChild(this._host);
271
+ this._svg = document.createElementNS(NS, "svg");
272
+ this._svg.setAttribute("aria-hidden", "true");
273
+ this._host.appendChild(this._svg);
274
+ this._markerId = createDefs(this._svg, this._id);
275
+ if (options.grid !== false) {
276
+ this._gridPattern = createGrid(this._svg, this._id);
277
+ }
278
+ this._viewportG = document.createElementNS(NS, "g");
279
+ this._svg.appendChild(this._viewportG);
280
+ this._containersG = document.createElementNS(NS, "g");
281
+ this._edgesG = document.createElementNS(NS, "g");
282
+ this._shapesG = document.createElementNS(NS, "g");
283
+ this._labelsG = document.createElementNS(NS, "g");
284
+ this._overlayG = document.createElementNS(NS, "g");
285
+ this._viewportG.appendChild(this._containersG);
286
+ this._viewportG.appendChild(this._edgesG);
287
+ this._viewportG.appendChild(this._shapesG);
288
+ this._viewportG.appendChild(this._labelsG);
289
+ this._viewportG.appendChild(this._overlayG);
290
+ // ── Viewport controller ──────────────────────────────────────
291
+ this._viewport = new ViewportController(this._host, this._svg, this._viewportG, this._gridPattern, (state) => this._emit("viewport:change", state));
292
+ // ── Overlay ──────────────────────────────────────────────────
293
+ this._overlay = new OverlayRenderer(this._overlayG, this._markerId);
294
+ // ── Command stack ────────────────────────────────────────────
295
+ this._commandStack = new CommandStack();
296
+ // ── State machine callbacks ──────────────────────────────────
297
+ const callbacks = {
298
+ getShapes: () => [...this._shapes],
299
+ getSelectedIds: () => [...this._selectedIds],
300
+ getViewport: () => this._viewport.state,
301
+ viewportDidPan: () => this._viewport.didPan,
302
+ isResizable: (id) => this._isResizable(id),
303
+ lockViewport: (lock) => this._viewport.lock(lock),
304
+ setSelection: (ids) => this._setSelection(ids),
305
+ previewTranslate: (dx, dy) => this._previewTranslate(dx, dy),
306
+ commitTranslate: (dx, dy) => this._commitTranslate(dx, dy),
307
+ cancelTranslate: () => this._cancelTranslate(),
308
+ previewResize: (bounds) => this._overlay.setResizePreview(bounds),
309
+ commitResize: (id, bounds) => {
310
+ this._overlay.setResizePreview(null);
311
+ this._executeCommand((d) => resizeShape(d, id, bounds));
312
+ },
313
+ previewConnect: (ghostEnd) => {
314
+ const src = this._connectSourceBounds();
315
+ if (src) {
316
+ const wps = computeWaypoints(src, {
317
+ x: ghostEnd.x - 1,
318
+ y: ghostEnd.y - 1,
319
+ width: 2,
320
+ height: 2,
321
+ });
322
+ this._overlay.setGhostConnection(wps);
323
+ }
324
+ },
325
+ cancelConnect: () => this._overlay.setGhostConnection(null),
326
+ commitConnect: (srcId, tgtId) => {
327
+ this._overlay.setGhostConnection(null);
328
+ this._doConnect(srcId, tgtId);
329
+ },
330
+ previewRubberBand: (origin, current) => this._overlay.setRubberBand(origin, current),
331
+ cancelRubberBand: () => this._overlay.setRubberBand(null),
332
+ commitCreate: (type, diagPoint) => this._doCreate(type, diagPoint),
333
+ startLabelEdit: (id) => this._startLabelEdit(id),
334
+ setHovered: (id) => this._overlay.setHovered(id, this._shapes),
335
+ executeDelete: (ids) => {
336
+ this._executeCommand((d) => deleteElements(d, ids));
337
+ this._setSelection([]);
338
+ },
339
+ executeCopy: () => this._doCopy(),
340
+ executePaste: () => this._doPaste(),
341
+ setTool: (tool) => this.setTool(tool),
342
+ getSelectedEdgeId: () => this._selectedEdgeId,
343
+ setEdgeSelected: (edgeId) => this._setEdgeSelected(edgeId),
344
+ previewEndpointMove: (edgeId, isStart, diagPoint) => this._previewEndpointMove(edgeId, isStart, diagPoint),
345
+ commitEndpointMove: (edgeId, isStart, diagPoint) => this._commitEndpointMove(edgeId, isStart, diagPoint),
346
+ cancelEndpointMove: () => {
347
+ this._overlay.setEndpointDragGhost(null);
348
+ if (this._selectedEdgeId) {
349
+ const edge = this._edges.find((e) => e.id === this._selectedEdgeId);
350
+ this._overlay.setEdgeEndpoints(edge?.edge.waypoints ?? null, this._selectedEdgeId);
351
+ }
352
+ },
353
+ previewWaypointInsert: (edgeId, segIdx, pt) => {
354
+ if (!this._defs)
355
+ return;
356
+ const snap = this._snapWaypoint(pt);
357
+ const preview = insertEdgeWaypoint(this._defs, edgeId, segIdx, snap.pt);
358
+ const edge = preview.diagrams[0]?.plane.edges.find((e) => e.bpmnElement === edgeId);
359
+ this._overlay.setEndpointDragGhost(edge?.waypoints ?? null);
360
+ this._overlay.setAlignmentGuides(snap.guides);
361
+ },
362
+ commitWaypointInsert: (edgeId, segIdx, pt) => {
363
+ this._overlay.setEndpointDragGhost(null);
364
+ this._overlay.setAlignmentGuides([]);
365
+ const snap = this._snapWaypoint(pt);
366
+ this._executeCommand((d) => removeCollinearWaypoints(insertEdgeWaypoint(d, edgeId, segIdx, snap.pt), edgeId));
367
+ },
368
+ cancelWaypointInsert: () => {
369
+ this._overlay.setEndpointDragGhost(null);
370
+ this._overlay.setAlignmentGuides([]);
371
+ },
372
+ previewWaypointMove: (edgeId, wpIdx, pt) => {
373
+ if (!this._defs)
374
+ return;
375
+ const snap = this._snapWaypoint(pt);
376
+ const preview = moveEdgeWaypoint(this._defs, edgeId, wpIdx, snap.pt);
377
+ const edge = preview.diagrams[0]?.plane.edges.find((e) => e.bpmnElement === edgeId);
378
+ this._overlay.setEndpointDragGhost(edge?.waypoints ?? null);
379
+ this._overlay.setAlignmentGuides(snap.guides);
380
+ },
381
+ commitWaypointMove: (edgeId, wpIdx, pt) => {
382
+ this._overlay.setEndpointDragGhost(null);
383
+ this._overlay.setAlignmentGuides([]);
384
+ const snap = this._snapWaypoint(pt);
385
+ this._executeCommand((d) => removeCollinearWaypoints(moveEdgeWaypoint(d, edgeId, wpIdx, snap.pt), edgeId));
386
+ },
387
+ cancelWaypointMove: () => {
388
+ this._overlay.setEndpointDragGhost(null);
389
+ this._overlay.setAlignmentGuides([]);
390
+ },
391
+ showEdgeHoverDot: (pt) => {
392
+ this._overlay.setEdgeHoverDot(pt);
393
+ },
394
+ hideEdgeHoverDot: () => {
395
+ this._overlay.setEdgeHoverDot(null);
396
+ },
397
+ showEdgeWaypointBalls: (edgeId) => {
398
+ const edge = this._edges.find((e) => e.id === edgeId);
399
+ if (edge)
400
+ this._overlay.setEdgeWaypointBalls(edge.edge.waypoints, edgeId);
401
+ },
402
+ hideEdgeWaypointBalls: () => {
403
+ this._overlay.setEdgeWaypointBalls(null, null);
404
+ },
405
+ previewSpace: (origin, current, axis) => this._previewSpace(origin, current, axis),
406
+ commitSpace: (origin, current, axis) => this._commitSpace(origin, current, axis),
407
+ cancelSpace: () => this._cancelSpace(),
408
+ };
409
+ this._stateMachine = new EditorStateMachine(callbacks);
410
+ // ── Label editor ─────────────────────────────────────────────
411
+ this._labelEditor = new LabelEditor(this._host, (id, text) => {
412
+ this._executeCommand((d) => updateLabel(d, id, text));
413
+ this._stateMachine.setMode({ mode: "select", sub: { name: "idle", hoveredId: null } });
414
+ }, () => {
415
+ this._stateMachine.setMode({ mode: "select", sub: { name: "idle", hoveredId: null } });
416
+ });
417
+ // ── Keyboard ─────────────────────────────────────────────────
418
+ this._keyboard = new KeyboardHandler(this._host, this._viewport, () => this.fitView(), (id) => {
419
+ const shape = this._shapes.find((s) => s.id === id);
420
+ if (shape) {
421
+ this._emit("element:click", id, new PointerEvent("click"));
422
+ }
423
+ }, (id) => this._emit("element:focus", id), () => this._emit("element:blur"));
424
+ this._host.addEventListener("keydown", this._onKeyDown);
425
+ // ── Pointer events ────────────────────────────────────────────
426
+ this._svg.addEventListener("pointerdown", this._onPointerDown);
427
+ this._svg.addEventListener("pointermove", this._onPointerMove);
428
+ this._svg.addEventListener("pointerup", this._onPointerUp);
429
+ this._svg.addEventListener("dblclick", this._onDblClick);
430
+ // ── Plugins ───────────────────────────────────────────────────
431
+ if (options.plugins) {
432
+ for (const plugin of options.plugins) {
433
+ this._installPlugin(plugin);
434
+ }
435
+ }
436
+ // ── Initial diagram ───────────────────────────────────────────
437
+ if (options.xml) {
438
+ this.load(options.xml);
439
+ }
440
+ else {
441
+ this.loadDefinitions(createEmptyDefinitions());
442
+ }
443
+ this._ro = new ResizeObserver(() => {
444
+ if (this._defs)
445
+ this.fitView();
446
+ });
447
+ this._ro.observe(this._host);
448
+ // Persist theme changes to localStorage when requested
449
+ if (options.persistTheme) {
450
+ new MutationObserver(() => {
451
+ try {
452
+ localStorage.setItem("bpmnkit-theme", this._host.getAttribute("data-theme") ?? "light");
453
+ }
454
+ catch {
455
+ // ignore
456
+ }
457
+ }).observe(this._host, { attributes: true, attributeFilter: ["data-theme"] });
458
+ }
459
+ }
460
+ // ── Public API ─────────────────────────────────────────────────────
461
+ load(xml) {
462
+ const defs = Bpmn.parse(xml);
463
+ this.loadDefinitions(defs);
464
+ }
465
+ /**
466
+ * Apply auto-layout to the current diagram.
467
+ * Replaces all DI positions with freshly computed layout.
468
+ * The operation is undoable.
469
+ */
470
+ autoLayout() {
471
+ this._executeCommand(applyAutoLayout);
472
+ this.fitView();
473
+ }
474
+ loadDefinitions(defs) {
475
+ this._commandStack.clear();
476
+ this._commandStack.push(defs);
477
+ this._selectedIds = [];
478
+ this._renderDefs(defs);
479
+ if (this._fit !== "none") {
480
+ requestAnimationFrame(() => this.fitView());
481
+ }
482
+ this._setDuplicateIdWarning(this._getDuplicateIds(defs));
483
+ }
484
+ exportXml() {
485
+ return Bpmn.export(this._defs ?? createEmptyDefinitions());
486
+ }
487
+ /**
488
+ * Enables or disables read-only mode. In read-only mode the viewport
489
+ * (pan + zoom) still works but all editing actions are blocked.
490
+ */
491
+ setReadOnly(enabled) {
492
+ this._readOnly = enabled;
493
+ if (enabled) {
494
+ this._setSelection([]);
495
+ this._overlay.setGhostCreate(null);
496
+ this._overlay.setAlignmentGuides([]);
497
+ this._overlay.setDistanceGuides([]);
498
+ this._setCreateEdgeDropHighlight(null);
499
+ this._ghostSnapCenter = null;
500
+ this._stateMachine.setMode({ mode: "pan" });
501
+ }
502
+ else {
503
+ this.setTool("select");
504
+ }
505
+ }
506
+ setTool(tool) {
507
+ if (this._readOnly)
508
+ return;
509
+ this._overlay.setGhostCreate(null);
510
+ this._boundaryHostId = null;
511
+ this._overlay.setBoundaryHostHighlight(null);
512
+ this._overlay.setAlignmentGuides([]);
513
+ this._setCreateEdgeDropHighlight(null);
514
+ this._ghostSnapCenter = null;
515
+ if (tool === "select") {
516
+ this._stateMachine.setMode({ mode: "select", sub: { name: "idle", hoveredId: null } });
517
+ }
518
+ else if (tool === "pan") {
519
+ this._stateMachine.setMode({ mode: "pan" });
520
+ }
521
+ else if (tool === "space") {
522
+ this._stateMachine.setMode({ mode: "space", sub: { name: "idle" } });
523
+ }
524
+ else {
525
+ const elementType = tool.slice(7);
526
+ this._stateMachine.setMode({ mode: "create", elementType });
527
+ }
528
+ if (tool.startsWith("create:")) {
529
+ this._host.focus();
530
+ }
531
+ this._emit("editor:tool", tool);
532
+ }
533
+ setSelection(ids) {
534
+ this._setSelection(ids);
535
+ }
536
+ deleteSelected() {
537
+ if (this._selectedIds.length === 0)
538
+ return;
539
+ const ids = [...this._selectedIds];
540
+ this._executeCommand((d) => deleteElements(d, ids));
541
+ this._setSelection([]);
542
+ }
543
+ undo() {
544
+ const prev = this._commandStack.undo();
545
+ if (prev) {
546
+ this._renderDefs(prev);
547
+ this._emit("diagram:change", prev);
548
+ }
549
+ }
550
+ redo() {
551
+ const next = this._commandStack.redo();
552
+ if (next) {
553
+ this._renderDefs(next);
554
+ this._emit("diagram:change", next);
555
+ }
556
+ }
557
+ canUndo() {
558
+ return this._commandStack.canUndo();
559
+ }
560
+ canRedo() {
561
+ return this._commandStack.canRedo();
562
+ }
563
+ getDefinitions() {
564
+ return this._defs;
565
+ }
566
+ applyChange(fn) {
567
+ this._executeCommand(fn);
568
+ }
569
+ fitView(padding = 40) {
570
+ if (!this._defs)
571
+ return;
572
+ const bounds = computeDiagramBounds(this._defs);
573
+ if (!bounds)
574
+ return;
575
+ const svgW = this._svg.clientWidth;
576
+ const svgH = this._svg.clientHeight;
577
+ if (svgW === 0 || svgH === 0)
578
+ return;
579
+ const dW = bounds.maxX - bounds.minX;
580
+ const dH = bounds.maxY - bounds.minY;
581
+ if (dW === 0 || dH === 0)
582
+ return;
583
+ const scaleX = (svgW - padding * 2) / dW;
584
+ const scaleY = (svgH - padding * 2) / dH;
585
+ let scale = Math.min(scaleX, scaleY);
586
+ if (this._fit === "center")
587
+ scale = 1;
588
+ const tx = (svgW - dW * scale) / 2 - bounds.minX * scale;
589
+ const ty = (svgH - dH * scale) / 2 - bounds.minY * scale;
590
+ this._viewport.set({ tx, ty, scale });
591
+ }
592
+ /** The host element that receives the `data-theme` attribute. */
593
+ get container() {
594
+ return this._host;
595
+ }
596
+ getTheme() {
597
+ return this._host.getAttribute("data-theme") === "dark" ? "dark" : "light";
598
+ }
599
+ setTheme(theme) {
600
+ this._theme = theme;
601
+ this._applyTheme(theme);
602
+ }
603
+ zoomIn() {
604
+ const { width, height } = this._svg.getBoundingClientRect();
605
+ this._viewport.zoomAt(width / 2, height / 2, 1.25);
606
+ }
607
+ zoomOut() {
608
+ const { width, height } = this._svg.getBoundingClientRect();
609
+ this._viewport.zoomAt(width / 2, height / 2, 0.8);
610
+ }
611
+ setZoom(scale) {
612
+ const { width, height } = this._svg.getBoundingClientRect();
613
+ const vp = this._viewport.state;
614
+ const cx = (width / 2 - vp.tx) / vp.scale;
615
+ const cy = (height / 2 - vp.ty) / vp.scale;
616
+ this._viewport.set({ tx: width / 2 - cx * scale, ty: height / 2 - cy * scale, scale });
617
+ }
618
+ selectAll() {
619
+ this._setSelection(this._shapes.map((s) => s.id));
620
+ }
621
+ paste() {
622
+ this._doPaste();
623
+ }
624
+ /** Pans the viewport to center on the element with the given id (preserves zoom). */
625
+ scrollToElement(id) {
626
+ const shape = this._shapes.find((s) => s.id === id);
627
+ if (!shape)
628
+ return;
629
+ const { x, y, width, height } = shape.shape.bounds;
630
+ const cx = x + width / 2;
631
+ const cy = y + height / 2;
632
+ const svgW = this._svg.clientWidth;
633
+ const svgH = this._svg.clientHeight;
634
+ const { scale } = this._viewport.state;
635
+ this._viewport.set({ tx: svgW / 2 - cx * scale, ty: svgH / 2 - cy * scale, scale });
636
+ }
637
+ on(event, handler) {
638
+ let set = this._listeners.get(event);
639
+ if (!set) {
640
+ set = new Set();
641
+ this._listeners.set(event, set);
642
+ }
643
+ set.add(handler);
644
+ return () => {
645
+ const s = this._listeners.get(event);
646
+ s?.delete(handler);
647
+ };
648
+ }
649
+ destroy() {
650
+ this._ro.disconnect();
651
+ this._viewport.destroy();
652
+ this._keyboard.destroy();
653
+ this._labelEditor.destroy();
654
+ this._svg.removeEventListener("pointerdown", this._onPointerDown);
655
+ this._svg.removeEventListener("pointermove", this._onPointerMove);
656
+ this._svg.removeEventListener("pointerup", this._onPointerUp);
657
+ this._svg.removeEventListener("dblclick", this._onDblClick);
658
+ this._host.removeEventListener("keydown", this._onKeyDown);
659
+ for (const plugin of this._plugins)
660
+ plugin.uninstall?.();
661
+ this._plugins.length = 0;
662
+ this._listeners.clear();
663
+ this._host.remove();
664
+ }
665
+ // ── Private helpers ────────────────────────────────────────────────
666
+ _getDuplicateIds(defs) {
667
+ const seen = new Map();
668
+ const track = (id) => seen.set(id, (seen.get(id) ?? 0) + 1);
669
+ const walkElements = (elements, flows) => {
670
+ for (const el of elements) {
671
+ track(el.id);
672
+ const nested = el;
673
+ if (nested.flowElements && nested.sequenceFlows) {
674
+ walkElements(nested.flowElements, nested.sequenceFlows);
675
+ }
676
+ }
677
+ for (const sf of flows)
678
+ track(sf.id);
679
+ };
680
+ track(defs.id);
681
+ for (const process of defs.processes) {
682
+ track(process.id);
683
+ walkElements(process.flowElements, process.sequenceFlows);
684
+ for (const ann of process.textAnnotations)
685
+ track(ann.id);
686
+ for (const assoc of process.associations)
687
+ track(assoc.id);
688
+ }
689
+ for (const diagram of defs.diagrams) {
690
+ track(diagram.id);
691
+ track(diagram.plane.id);
692
+ for (const shape of diagram.plane.shapes)
693
+ track(shape.id);
694
+ for (const edge of diagram.plane.edges)
695
+ track(edge.id);
696
+ }
697
+ return [...seen.entries()].filter(([, count]) => count > 1).map(([id]) => id);
698
+ }
699
+ _setDuplicateIdWarning(duplicateIds) {
700
+ if (duplicateIds.length === 0) {
701
+ this._warningBanner?.remove();
702
+ this._warningBanner = null;
703
+ return;
704
+ }
705
+ if (!this._warningBanner) {
706
+ this._warningBanner = document.createElement("div");
707
+ this._warningBanner.className = "bpmnkit-editor-warning-banner";
708
+ this._host.appendChild(this._warningBanner);
709
+ }
710
+ this._warningBanner.textContent = `⚠ Duplicate element IDs: ${duplicateIds.join(", ")}. Editing may produce unexpected results.`;
711
+ }
712
+ _renderDefs(defs) {
713
+ this._containersG.innerHTML = "";
714
+ this._edgesG.innerHTML = "";
715
+ this._shapesG.innerHTML = "";
716
+ this._labelsG.innerHTML = "";
717
+ const result = render(defs, this._containersG, this._edgesG, this._shapesG, this._labelsG, this._markerId, this._id);
718
+ this._shapes = result.shapes;
719
+ this._edges = result.edges;
720
+ this._defs = defs;
721
+ // Add transparent hit-area polylines for edge clicking
722
+ for (const edge of this._edges) {
723
+ const waypoints = edge.edge.waypoints;
724
+ if (waypoints.length < 2)
725
+ continue;
726
+ const points = waypoints.map((wp) => `${wp.x},${wp.y}`).join(" ");
727
+ const hitArea = document.createElementNS(NS, "polyline");
728
+ hitArea.setAttribute("class", "bpmnkit-edge-hitarea");
729
+ hitArea.setAttribute("data-bpmnkit-edge-hit", edge.id);
730
+ hitArea.setAttribute("points", points);
731
+ edge.element.appendChild(hitArea);
732
+ }
733
+ this._keyboard.setShapes(this._shapes);
734
+ this._overlay.setSelection(this._selectedIds, this._shapes, this._getResizableIds());
735
+ // Restore edge selection if the edge still exists after re-render
736
+ if (this._selectedEdgeId) {
737
+ const edge = this._edges.find((e) => e.id === this._selectedEdgeId);
738
+ if (edge) {
739
+ this._overlay.setEdgeEndpoints(edge.edge.waypoints, this._selectedEdgeId);
740
+ }
741
+ else {
742
+ this._selectedEdgeId = null;
743
+ this._overlay.setEdgeEndpoints(null, "");
744
+ }
745
+ }
746
+ this._emit("diagram:load", defs);
747
+ }
748
+ _executeCommand(fn) {
749
+ if (this._readOnly || !this._defs)
750
+ return;
751
+ const newDefs = fn(this._defs);
752
+ this._commandStack.push(newDefs);
753
+ this._renderDefs(newDefs);
754
+ this._emit("diagram:change", newDefs);
755
+ }
756
+ _setSelection(ids) {
757
+ this._selectedIds = ids;
758
+ // Clear edge selection whenever shape selection changes
759
+ if (this._selectedEdgeId) {
760
+ this._selectedEdgeId = null;
761
+ this._overlay.setEdgeEndpoints(null, "");
762
+ }
763
+ this._overlay.setSelection(ids, this._shapes, this._getResizableIds());
764
+ this._emit("editor:select", ids);
765
+ }
766
+ _previewTranslate(dx, dy) {
767
+ if (!this._isDragging) {
768
+ this._isDragging = true;
769
+ this._overlay.setDragging(true);
770
+ this._emit("editor:drag", true);
771
+ }
772
+ const alignSnap = this._computeSnap(dx, dy);
773
+ const spacingResult = this._computeSpacingSnap(dx, dy);
774
+ const alignAdjX = Math.abs(alignSnap.dx - dx);
775
+ const alignAdjY = Math.abs(alignSnap.dy - dy);
776
+ const spacingAdjX = Math.abs(spacingResult.dx - dx);
777
+ const spacingAdjY = Math.abs(spacingResult.dy - dy);
778
+ // Per axis: prefer spacing snap when it fires and is closer than alignment snap
779
+ const useSpacingX = spacingAdjX > 0 && (!alignAdjX || spacingAdjX < alignAdjX);
780
+ const useSpacingY = spacingAdjY > 0 && (!alignAdjY || spacingAdjY < alignAdjY);
781
+ const finalDx = useSpacingX ? spacingResult.dx : alignSnap.dx;
782
+ const finalDy = useSpacingY ? spacingResult.dy : alignSnap.dy;
783
+ this._snapDelta = { dx: finalDx, dy: finalDy };
784
+ for (const id of this._selectedIds) {
785
+ const shape = this._shapes.find((s) => s.id === id);
786
+ if (!shape)
787
+ continue;
788
+ const { x, y } = shape.shape.bounds;
789
+ shape.element.setAttribute("transform", `translate(${x + finalDx} ${y + finalDy})`);
790
+ }
791
+ this._overlay.setAlignmentGuides(this._computeAlignGuides(finalDx, finalDy));
792
+ this._overlay.setDistanceGuides(spacingResult.guides.filter((g) => {
793
+ const isH = g.y1 === g.y2;
794
+ return isH ? useSpacingX : useSpacingY;
795
+ }));
796
+ this._setEdgeDropHighlight(this._findEdgeDropTarget(finalDx, finalDy));
797
+ }
798
+ _cancelTranslate() {
799
+ this._snapDelta = null;
800
+ this._overlay.setAlignmentGuides([]);
801
+ this._overlay.setDistanceGuides([]);
802
+ this._setEdgeDropHighlight(null);
803
+ for (const id of this._selectedIds) {
804
+ const shape = this._shapes.find((s) => s.id === id);
805
+ if (!shape)
806
+ continue;
807
+ const { x, y } = shape.shape.bounds;
808
+ shape.element.setAttribute("transform", `translate(${x} ${y})`);
809
+ }
810
+ if (this._isDragging) {
811
+ this._isDragging = false;
812
+ this._overlay.setDragging(false);
813
+ this._overlay.setSelection(this._selectedIds, this._shapes, this._getResizableIds());
814
+ this._emit("editor:drag", false);
815
+ }
816
+ }
817
+ _commitTranslate(dx, dy) {
818
+ const snap = this._snapDelta ?? { dx, dy };
819
+ this._snapDelta = null;
820
+ this._overlay.setAlignmentGuides([]);
821
+ this._overlay.setDistanceGuides([]);
822
+ const edgeDropId = this._edgeDropTarget;
823
+ this._setEdgeDropHighlight(null);
824
+ const moves = this._selectedIds.map((id) => ({ id, dx: snap.dx, dy: snap.dy }));
825
+ const shapeId = this._selectedIds.length === 1 ? this._selectedIds[0] : undefined;
826
+ if (edgeDropId && shapeId) {
827
+ this._executeCommand((d) => insertShapeOnEdge(moveShapes(d, moves), edgeDropId, shapeId));
828
+ }
829
+ else {
830
+ this._executeCommand((d) => moveShapes(d, moves));
831
+ }
832
+ if (this._isDragging) {
833
+ this._isDragging = false;
834
+ this._overlay.setDragging(false);
835
+ this._emit("editor:drag", false);
836
+ }
837
+ }
838
+ _previewSpace(origin, current, axis) {
839
+ // Reset all shapes to their original positions first
840
+ for (const shape of this._shapes) {
841
+ const { x, y } = shape.shape.bounds;
842
+ shape.element.setAttribute("transform", `translate(${x} ${y})`);
843
+ }
844
+ if (!axis)
845
+ return;
846
+ const dx = current.x - origin.x;
847
+ const dy = current.y - origin.y;
848
+ for (const shape of this._shapes) {
849
+ const b = shape.shape.bounds;
850
+ const cx = b.x + b.width / 2;
851
+ const cy = b.y + b.height / 2;
852
+ let moveDx = 0;
853
+ let moveDy = 0;
854
+ if (axis === "h") {
855
+ if (dx > 0 && cx > origin.x)
856
+ moveDx = dx;
857
+ else if (dx < 0 && cx < origin.x)
858
+ moveDx = dx;
859
+ }
860
+ else {
861
+ if (dy > 0 && cy > origin.y)
862
+ moveDy = dy;
863
+ else if (dy < 0 && cy < origin.y)
864
+ moveDy = dy;
865
+ }
866
+ if (moveDx !== 0 || moveDy !== 0) {
867
+ shape.element.setAttribute("transform", `translate(${b.x + moveDx} ${b.y + moveDy})`);
868
+ }
869
+ }
870
+ const splitValue = axis === "h" ? origin.x : origin.y;
871
+ this._overlay.setSpacePreview(axis, splitValue);
872
+ }
873
+ _commitSpace(origin, current, axis) {
874
+ // Reset visual preview
875
+ for (const shape of this._shapes) {
876
+ const { x, y } = shape.shape.bounds;
877
+ shape.element.setAttribute("transform", `translate(${x} ${y})`);
878
+ }
879
+ this._overlay.setSpacePreview(null);
880
+ if (!axis || !this._defs)
881
+ return;
882
+ const dx = current.x - origin.x;
883
+ const dy = current.y - origin.y;
884
+ if (dx === 0 && dy === 0)
885
+ return;
886
+ const moves = [];
887
+ for (const shape of this._shapes) {
888
+ const b = shape.shape.bounds;
889
+ const cx = b.x + b.width / 2;
890
+ const cy = b.y + b.height / 2;
891
+ if (axis === "h") {
892
+ if (dx > 0 && cx > origin.x)
893
+ moves.push({ id: shape.id, dx, dy: 0 });
894
+ else if (dx < 0 && cx < origin.x)
895
+ moves.push({ id: shape.id, dx, dy: 0 });
896
+ }
897
+ else {
898
+ if (dy > 0 && cy > origin.y)
899
+ moves.push({ id: shape.id, dx: 0, dy });
900
+ else if (dy < 0 && cy < origin.y)
901
+ moves.push({ id: shape.id, dx: 0, dy });
902
+ }
903
+ }
904
+ if (moves.length > 0) {
905
+ this._executeCommand((d) => moveShapes(d, moves));
906
+ }
907
+ }
908
+ _cancelSpace() {
909
+ for (const shape of this._shapes) {
910
+ const { x, y } = shape.shape.bounds;
911
+ shape.element.setAttribute("transform", `translate(${x} ${y})`);
912
+ }
913
+ this._overlay.setSpacePreview(null);
914
+ }
915
+ _doCreate(type, diagPoint) {
916
+ // Read edge drop target BEFORE clearing it — _setCreateEdgeDropHighlight(null) zeroes it out.
917
+ const pendingEdgeDrop = this._createEdgeDropTarget;
918
+ this._overlay.setGhostCreate(null);
919
+ this._overlay.setAlignmentGuides([]);
920
+ const actualCenter = this._ghostSnapCenter ?? diagPoint;
921
+ this._ghostSnapCenter = null;
922
+ this._setCreateEdgeDropHighlight(null);
923
+ if (!this._defs)
924
+ return;
925
+ const bounds = defaultBounds(type, actualCenter.x, actualCenter.y);
926
+ if (type === "textAnnotation") {
927
+ const result = createAnnotation(this._defs, bounds);
928
+ this._selectedIds = [result.id];
929
+ this._commandStack.push(result.defs);
930
+ this._renderDefs(result.defs);
931
+ this._emit("diagram:change", result.defs);
932
+ this._emit("editor:select", [result.id]);
933
+ this._startLabelEdit(result.id);
934
+ return;
935
+ }
936
+ // If hovering over an activity, create a boundary event
937
+ const boundaryHostId = this._boundaryHostId;
938
+ this._setBoundaryHost(null);
939
+ if (boundaryHostId && isIntermediateEventType(type) && this._defs) {
940
+ const hostShape = this._shapes.find((s) => s.id === boundaryHostId);
941
+ if (hostShape) {
942
+ const hostBounds = hostShape.shape.bounds;
943
+ // Snap the event center to the nearest point on the host boundary
944
+ const eventBounds = snapToBoundary(actualCenter, hostBounds, 18);
945
+ // Map palette type to event definition type
946
+ const eventDefType = intermediateEventDefType(type);
947
+ const result = createBoundaryEvent(this._defs, boundaryHostId, eventDefType, eventBounds);
948
+ this._selectedIds = [result.id];
949
+ this._commandStack.push(result.defs);
950
+ this._renderDefs(result.defs);
951
+ this._emit("diagram:change", result.defs);
952
+ this._emit("editor:select", [result.id]);
953
+ return;
954
+ }
955
+ }
956
+ const result = createShape(this._defs, type, bounds);
957
+ this._selectedIds = [result.id];
958
+ const finalDefs = pendingEdgeDrop
959
+ ? insertShapeOnEdge(result.defs, pendingEdgeDrop, result.id)
960
+ : result.defs;
961
+ this._commandStack.push(finalDefs);
962
+ this._renderDefs(finalDefs);
963
+ this._emit("diagram:change", finalDefs);
964
+ this._emit("editor:select", [result.id]);
965
+ }
966
+ _doConnect(srcId, tgtId) {
967
+ const srcShape = this._shapes.find((s) => s.id === srcId);
968
+ const tgtShape = this._shapes.find((s) => s.id === tgtId);
969
+ if (!srcShape || !tgtShape)
970
+ return;
971
+ const srcType = srcShape.flowElement?.type;
972
+ const tgtType = tgtShape.flowElement?.type;
973
+ if (srcType && tgtType && !canConnect(srcType, tgtType))
974
+ return;
975
+ const obstacles = this._shapes
976
+ .filter((s) => s.id !== srcId && s.id !== tgtId)
977
+ .map((s) => s.shape.bounds);
978
+ const waypoints = computeWaypointsAvoiding(srcShape.shape.bounds, tgtShape.shape.bounds, obstacles);
979
+ this._executeCommand((d) => createConnection(d, srcId, tgtId, waypoints).defs);
980
+ }
981
+ _doCopy() {
982
+ if (!this._defs || this._selectedIds.length === 0)
983
+ return;
984
+ this._clipboard = copyElements(this._defs, this._selectedIds);
985
+ }
986
+ _doPaste() {
987
+ if (!this._clipboard)
988
+ return;
989
+ const base = this._defs ?? createEmptyDefinitions();
990
+ const result = pasteElements(base, this._clipboard, 20, 20);
991
+ const newIds = [...result.newIds.values()];
992
+ this._selectedIds = newIds;
993
+ this._commandStack.push(result.defs);
994
+ this._renderDefs(result.defs);
995
+ this._emit("diagram:change", result.defs);
996
+ this._emit("editor:select", newIds);
997
+ }
998
+ _startLabelEdit(id) {
999
+ if (this._readOnly || !id)
1000
+ return;
1001
+ const shape = this._shapes.find((s) => s.id === id);
1002
+ if (!shape)
1003
+ return;
1004
+ const defs = this._defs;
1005
+ if (!defs)
1006
+ return;
1007
+ const process = defs.processes[0];
1008
+ const currentText = process?.flowElements.find((el) => el.id === id)?.name ??
1009
+ process?.sequenceFlows.find((sf) => sf.id === id)?.name ??
1010
+ process?.textAnnotations.find((ta) => ta.id === id)?.text ??
1011
+ "";
1012
+ this._labelEditor.start(id, currentText, shape.shape.bounds, this._viewport.state, this._svg.getBoundingClientRect());
1013
+ }
1014
+ _connectSourceBounds() {
1015
+ const mode = this._stateMachine.mode;
1016
+ if (mode.mode !== "select")
1017
+ return null;
1018
+ const sub = mode.sub;
1019
+ const sourceId = sub.name === "connecting" ? sub.sourceId : sub.name === "pointing-port" ? sub.sourceId : null;
1020
+ if (!sourceId)
1021
+ return null;
1022
+ const shape = this._shapes.find((s) => s.id === sourceId);
1023
+ return shape ? shape.shape.bounds : null;
1024
+ }
1025
+ // ── New public helpers ─────────────────────────────────────────────
1026
+ /** Returns screen-space bounds of a shape (for positioning overlays). */
1027
+ getShapeBounds(id) {
1028
+ const shape = this._shapes.find((s) => s.id === id);
1029
+ if (!shape)
1030
+ return null;
1031
+ const b = shape.shape.bounds;
1032
+ const vp = this._viewport.state;
1033
+ const svgRect = this._svg.getBoundingClientRect();
1034
+ const { x, y } = diagramToScreen(b.x, b.y, vp, svgRect);
1035
+ return { x, y, width: b.width * vp.scale, height: b.height * vp.scale };
1036
+ }
1037
+ /** Returns the BPMN element type for a given id, or null if not found. */
1038
+ getElementType(id) {
1039
+ const shape = this._shapes.find((s) => s.id === id);
1040
+ if (!shape) {
1041
+ if (this._edges.some((e) => e.id === id))
1042
+ return "sequenceFlow";
1043
+ return null;
1044
+ }
1045
+ if (shape.annotation !== undefined)
1046
+ return "textAnnotation";
1047
+ const bpmnType = shape.flowElement?.type ?? null;
1048
+ if (!bpmnType)
1049
+ return null;
1050
+ // For events, resolve to specific palette type based on event definition
1051
+ const el = shape.flowElement;
1052
+ if (el &&
1053
+ (el.type === "startEvent" ||
1054
+ el.type === "endEvent" ||
1055
+ el.type === "intermediateCatchEvent" ||
1056
+ el.type === "intermediateThrowEvent")) {
1057
+ const def = el.eventDefinitions[0];
1058
+ if (def) {
1059
+ return resolveEventPaletteType(el.type, def.type);
1060
+ }
1061
+ }
1062
+ return bpmnType;
1063
+ }
1064
+ /**
1065
+ * Creates a new element of the given type connected to the source shape,
1066
+ * using smart placement (right → bottom → top, avoids overlaps).
1067
+ * Returns the new element's id.
1068
+ */
1069
+ addConnectedElement(sourceId, type) {
1070
+ if (!this._defs)
1071
+ return null;
1072
+ const srcShape = this._shapes.find((s) => s.id === sourceId);
1073
+ if (!srcShape)
1074
+ return null;
1075
+ const srcBounds = srcShape.shape.bounds;
1076
+ let w = 100;
1077
+ let h = 80;
1078
+ if (type === "startEvent" || type === "endEvent") {
1079
+ w = 36;
1080
+ h = 36;
1081
+ }
1082
+ else if (type === "exclusiveGateway" ||
1083
+ type === "parallelGateway" ||
1084
+ type === "inclusiveGateway" ||
1085
+ type === "eventBasedGateway") {
1086
+ w = 50;
1087
+ h = 50;
1088
+ }
1089
+ const newBounds = this._smartPlaceBounds(srcBounds, sourceId, w, h);
1090
+ const obstacles = this._shapes.filter((s) => s.id !== sourceId).map((s) => s.shape.bounds);
1091
+ const r1 = createShape(this._defs, type, newBounds);
1092
+ const waypoints = computeWaypointsAvoiding(srcBounds, newBounds, obstacles);
1093
+ const r2 = createConnection(r1.defs, sourceId, r1.id, waypoints);
1094
+ this._selectedIds = [r1.id];
1095
+ this._commandStack.push(r2.defs);
1096
+ this._renderDefs(r2.defs);
1097
+ this._emit("diagram:change", r2.defs);
1098
+ this._emit("editor:select", [r1.id]);
1099
+ return r1.id;
1100
+ }
1101
+ _smartPlaceBounds(srcBounds, sourceId, w, h) {
1102
+ const GAP = 60;
1103
+ const srcCx = srcBounds.x + srcBounds.width / 2;
1104
+ const srcCy = srcBounds.y + srcBounds.height / 2;
1105
+ // Find directions already occupied by outgoing connections
1106
+ const takenDirs = new Set();
1107
+ const process = this._defs?.processes[0];
1108
+ if (process) {
1109
+ for (const flow of process.sequenceFlows) {
1110
+ if (flow.sourceRef !== sourceId)
1111
+ continue;
1112
+ const tgt = this._shapes.find((s) => s.id === flow.targetRef);
1113
+ if (!tgt)
1114
+ continue;
1115
+ const tCx = tgt.shape.bounds.x + tgt.shape.bounds.width / 2;
1116
+ const tCy = tgt.shape.bounds.y + tgt.shape.bounds.height / 2;
1117
+ const ddx = tCx - srcCx;
1118
+ const ddy = tCy - srcCy;
1119
+ const dir = Math.abs(ddx) >= Math.abs(ddy)
1120
+ ? ddx >= 0
1121
+ ? "right"
1122
+ : "left"
1123
+ : ddy >= 0
1124
+ ? "bottom"
1125
+ : "top";
1126
+ takenDirs.add(dir);
1127
+ }
1128
+ }
1129
+ // Try primary candidates: right → bottom → top
1130
+ const candidates = [
1131
+ {
1132
+ dir: "right",
1133
+ bounds: { x: srcBounds.x + srcBounds.width + GAP, y: srcCy - h / 2, width: w, height: h },
1134
+ },
1135
+ {
1136
+ dir: "bottom",
1137
+ bounds: {
1138
+ x: srcCx - w / 2,
1139
+ y: srcBounds.y + srcBounds.height + GAP,
1140
+ width: w,
1141
+ height: h,
1142
+ },
1143
+ },
1144
+ {
1145
+ dir: "top",
1146
+ bounds: { x: srcCx - w / 2, y: srcBounds.y - GAP - h, width: w, height: h },
1147
+ },
1148
+ ];
1149
+ for (const { dir, bounds } of candidates) {
1150
+ if (!takenDirs.has(dir) && !this._overlapsAny(bounds))
1151
+ return bounds;
1152
+ }
1153
+ // All primary positions blocked — increase gap for bottom/top
1154
+ for (let extra = GAP * 2; extra <= GAP * 6; extra += GAP) {
1155
+ const bot = {
1156
+ x: srcCx - w / 2,
1157
+ y: srcBounds.y + srcBounds.height + extra,
1158
+ width: w,
1159
+ height: h,
1160
+ };
1161
+ if (!this._overlapsAny(bot))
1162
+ return bot;
1163
+ const top = {
1164
+ x: srcCx - w / 2,
1165
+ y: srcBounds.y - extra - h,
1166
+ width: w,
1167
+ height: h,
1168
+ };
1169
+ if (!this._overlapsAny(top))
1170
+ return top;
1171
+ }
1172
+ // Absolute fallback
1173
+ return { x: srcBounds.x + srcBounds.width + GAP * 5, y: srcCy - h / 2, width: w, height: h };
1174
+ }
1175
+ _overlapsAny(bounds) {
1176
+ const MARGIN = 10;
1177
+ for (const shape of this._shapes) {
1178
+ const b = shape.shape.bounds;
1179
+ if (bounds.x < b.x + b.width + MARGIN &&
1180
+ bounds.x + bounds.width + MARGIN > b.x &&
1181
+ bounds.y < b.y + b.height + MARGIN &&
1182
+ bounds.y + bounds.height + MARGIN > b.y) {
1183
+ return true;
1184
+ }
1185
+ }
1186
+ return false;
1187
+ }
1188
+ /**
1189
+ * Sets the external label position for an event or gateway shape.
1190
+ */
1191
+ setLabelPosition(shapeId, position) {
1192
+ const shape = this._shapes.find((s) => s.id === shapeId);
1193
+ if (!shape)
1194
+ return;
1195
+ const labelBounds = labelBoundsForPosition(shape.shape.bounds, position);
1196
+ this._executeCommand((d) => updateLabelPosition(d, shapeId, labelBounds));
1197
+ }
1198
+ /** Starts inline label editing for the element with the given id. */
1199
+ editLabel(id) {
1200
+ this._startLabelEdit(id);
1201
+ }
1202
+ /** Copies then pastes the current selection with a small offset. */
1203
+ duplicate() {
1204
+ this._doCopy();
1205
+ this._doPaste();
1206
+ }
1207
+ /**
1208
+ * Enters connection-drawing mode with the given shape as source.
1209
+ * The user then moves the mouse and clicks a target shape to complete the connection.
1210
+ */
1211
+ startConnectionFrom(sourceId) {
1212
+ const shape = this._shapes.find((s) => s.id === sourceId);
1213
+ if (!shape)
1214
+ return;
1215
+ this._viewport.lock(true);
1216
+ this._stateMachine.setMode({
1217
+ mode: "select",
1218
+ sub: { name: "connecting", sourceId, ghostEnd: { x: 0, y: 0 } },
1219
+ });
1220
+ }
1221
+ /** Creates a text annotation linked to the given source shape via an association. */
1222
+ createAnnotationFor(sourceId) {
1223
+ if (!this._defs)
1224
+ return;
1225
+ const srcShape = this._shapes.find((s) => s.id === sourceId);
1226
+ if (!srcShape)
1227
+ return;
1228
+ const srcBounds = srcShape.shape.bounds;
1229
+ // Place annotation above-right of source
1230
+ const annW = 100;
1231
+ const annH = 50;
1232
+ const annBounds = {
1233
+ x: srcBounds.x + srcBounds.width + 30,
1234
+ y: srcBounds.y - annH - 10,
1235
+ width: annW,
1236
+ height: annH,
1237
+ };
1238
+ const result = createAnnotationWithLink(this._defs, annBounds, sourceId, srcBounds);
1239
+ this._selectedIds = [result.annotationId];
1240
+ this._commandStack.push(result.defs);
1241
+ this._renderDefs(result.defs);
1242
+ this._emit("diagram:change", result.defs);
1243
+ this._emit("editor:select", [result.annotationId]);
1244
+ this._startLabelEdit(result.annotationId);
1245
+ }
1246
+ /** Updates the color of a shape in the diagram. Pass `{}` to clear colors. */
1247
+ updateColor(id, color) {
1248
+ this._executeCommand((d) => updateShapeColor(d, id, color));
1249
+ }
1250
+ // ── Private helpers ────────────────────────────────────────────────
1251
+ _setEdgeSelected(edgeId) {
1252
+ // Clear shape selection when edge is selected
1253
+ if (edgeId && this._selectedIds.length > 0) {
1254
+ this._selectedIds = [];
1255
+ this._overlay.setSelection([], this._shapes);
1256
+ }
1257
+ this._selectedEdgeId = edgeId;
1258
+ if (edgeId) {
1259
+ const edge = this._edges.find((e) => e.id === edgeId);
1260
+ this._overlay.setEdgeEndpoints(edge?.edge.waypoints ?? null, edgeId);
1261
+ this._emit("editor:select", [edgeId]);
1262
+ }
1263
+ else {
1264
+ this._overlay.setEdgeEndpoints(null, "");
1265
+ this._emit("editor:select", []);
1266
+ }
1267
+ }
1268
+ /** Changes a flow element's type (e.g. exclusiveGateway → parallelGateway). */
1269
+ changeElementType(id, newType) {
1270
+ this._executeCommand((d) => changeElementTypeFn(d, id, newType));
1271
+ }
1272
+ _findEdgeDropTarget(dx, dy) {
1273
+ if (this._selectedIds.length !== 1)
1274
+ return null;
1275
+ const id = this._selectedIds[0];
1276
+ if (!id || !this._defs)
1277
+ return null;
1278
+ const shape = this._shapes.find((s) => s.id === id);
1279
+ if (!shape)
1280
+ return null;
1281
+ const b = shape.shape.bounds;
1282
+ const cx = b.x + dx + b.width / 2;
1283
+ const cy = b.y + dy + b.height / 2;
1284
+ const process = this._defs.processes[0];
1285
+ if (!process)
1286
+ return null;
1287
+ const TOLERANCE = 20;
1288
+ for (const edge of this._edges) {
1289
+ const flow = process.sequenceFlows.find((sf) => sf.id === edge.id);
1290
+ if (!flow)
1291
+ continue;
1292
+ // Skip edges that are already connected to the shape being moved
1293
+ if (flow.sourceRef === id || flow.targetRef === id)
1294
+ continue;
1295
+ const wps = edge.edge.waypoints;
1296
+ for (let i = 0; i < wps.length - 1; i++) {
1297
+ const a = wps[i];
1298
+ const b2 = wps[i + 1];
1299
+ if (!a || !b2)
1300
+ continue;
1301
+ const minX = Math.min(a.x, b2.x) - TOLERANCE;
1302
+ const maxX = Math.max(a.x, b2.x) + TOLERANCE;
1303
+ const minY = Math.min(a.y, b2.y) - TOLERANCE;
1304
+ const maxY = Math.max(a.y, b2.y) + TOLERANCE;
1305
+ if (cx >= minX && cx <= maxX && cy >= minY && cy <= maxY) {
1306
+ return edge.id;
1307
+ }
1308
+ }
1309
+ }
1310
+ return null;
1311
+ }
1312
+ _setEdgeDropHighlight(edgeId) {
1313
+ if (this._edgeDropTarget) {
1314
+ const prev = this._edges.find((e) => e.id === this._edgeDropTarget);
1315
+ prev?.element.classList.remove("bpmnkit-edge-split-highlight");
1316
+ }
1317
+ this._edgeDropTarget = edgeId;
1318
+ if (edgeId) {
1319
+ const edge = this._edges.find((e) => e.id === edgeId);
1320
+ edge?.element.classList.add("bpmnkit-edge-split-highlight");
1321
+ }
1322
+ }
1323
+ _previewEndpointMove(edgeId, isStart, diagPoint) {
1324
+ if (!this._defs)
1325
+ return;
1326
+ const edge = this._edges.find((e) => e.id === edgeId);
1327
+ if (!edge)
1328
+ return;
1329
+ const flow = this._defs.processes[0]?.sequenceFlows.find((sf) => sf.id === edgeId);
1330
+ if (!flow)
1331
+ return;
1332
+ const plane = this._defs.diagrams[0]?.plane;
1333
+ if (!plane)
1334
+ return;
1335
+ const srcDi = plane.shapes.find((s) => s.bpmnElement === flow.sourceRef);
1336
+ const tgtDi = plane.shapes.find((s) => s.bpmnElement === flow.targetRef);
1337
+ if (!srcDi || !tgtDi)
1338
+ return;
1339
+ const waypoints = edge.edge.waypoints;
1340
+ let srcPort;
1341
+ let tgtPort;
1342
+ if (isStart) {
1343
+ srcPort = closestPort(diagPoint, srcDi.bounds);
1344
+ const lastWp = waypoints[waypoints.length - 1];
1345
+ tgtPort = lastWp ? portFromWaypoint(lastWp, tgtDi.bounds) : "left";
1346
+ }
1347
+ else {
1348
+ const firstWp = waypoints[0];
1349
+ srcPort = firstWp ? portFromWaypoint(firstWp, srcDi.bounds) : "right";
1350
+ tgtPort = closestPort(diagPoint, tgtDi.bounds);
1351
+ }
1352
+ const newWaypoints = computeWaypointsWithPorts(srcDi.bounds, srcPort, tgtDi.bounds, tgtPort);
1353
+ this._overlay.setEndpointDragGhost(newWaypoints);
1354
+ }
1355
+ _commitEndpointMove(edgeId, isStart, diagPoint) {
1356
+ if (!this._defs)
1357
+ return;
1358
+ this._overlay.setEndpointDragGhost(null);
1359
+ const edge = this._edges.find((e) => e.id === edgeId);
1360
+ if (!edge)
1361
+ return;
1362
+ const flow = this._defs.processes[0]?.sequenceFlows.find((sf) => sf.id === edgeId);
1363
+ if (!flow)
1364
+ return;
1365
+ const plane = this._defs.diagrams[0]?.plane;
1366
+ if (!plane)
1367
+ return;
1368
+ const srcDi = plane.shapes.find((s) => s.bpmnElement === flow.sourceRef);
1369
+ const tgtDi = plane.shapes.find((s) => s.bpmnElement === flow.targetRef);
1370
+ if (!srcDi || !tgtDi)
1371
+ return;
1372
+ const newPort = isStart
1373
+ ? closestPort(diagPoint, srcDi.bounds)
1374
+ : closestPort(diagPoint, tgtDi.bounds);
1375
+ this._executeCommand((d) => updateEdgeEndpoint(d, edgeId, isStart, newPort));
1376
+ }
1377
+ _isResizable(id) {
1378
+ const shape = this._shapes.find((s) => s.id === id);
1379
+ if (!shape)
1380
+ return false;
1381
+ if (shape.annotation !== undefined)
1382
+ return true;
1383
+ return shape.flowElement !== undefined && RESIZABLE_TYPES.has(shape.flowElement.type);
1384
+ }
1385
+ _getResizableIds() {
1386
+ const ids = new Set();
1387
+ for (const shape of this._shapes) {
1388
+ if (shape.annotation !== undefined ||
1389
+ (shape.flowElement && RESIZABLE_TYPES.has(shape.flowElement.type))) {
1390
+ ids.add(shape.id);
1391
+ }
1392
+ }
1393
+ return ids;
1394
+ }
1395
+ // ── Snap / alignment guides ───────────────────────────────────────
1396
+ _computeSnap(dx, dy) {
1397
+ const selectedSet = new Set(this._selectedIds);
1398
+ const movingShapes = this._shapes.filter((s) => selectedSet.has(s.id));
1399
+ const staticShapes = this._shapes.filter((s) => !selectedSet.has(s.id));
1400
+ if (movingShapes.length === 0)
1401
+ return { dx, dy };
1402
+ const scale = this._viewport.state.scale;
1403
+ const threshold = 8 / scale;
1404
+ const movingXVals = [];
1405
+ const movingYVals = [];
1406
+ for (const s of movingShapes) {
1407
+ const b = s.shape.bounds;
1408
+ movingXVals.push(b.x + dx, b.x + dx + b.width / 2, b.x + dx + b.width);
1409
+ movingYVals.push(b.y + dy, b.y + dy + b.height / 2, b.y + dy + b.height);
1410
+ }
1411
+ const staticXVals = [];
1412
+ const staticYVals = [];
1413
+ for (const s of staticShapes) {
1414
+ const b = s.shape.bounds;
1415
+ staticXVals.push(b.x, b.x + b.width / 2, b.x + b.width);
1416
+ staticYVals.push(b.y, b.y + b.height / 2, b.y + b.height);
1417
+ }
1418
+ // Include original positions of moving shapes as virtual snap targets
1419
+ for (const s of movingShapes) {
1420
+ const b = s.shape.bounds;
1421
+ staticXVals.push(b.x, b.x + b.width / 2, b.x + b.width);
1422
+ staticYVals.push(b.y, b.y + b.height / 2, b.y + b.height);
1423
+ }
1424
+ let bestDx = dx;
1425
+ let bestDy = dy;
1426
+ let minDistX = threshold;
1427
+ let minDistY = threshold;
1428
+ for (const mx of movingXVals) {
1429
+ for (const sx of staticXVals) {
1430
+ const dist = Math.abs(mx - sx);
1431
+ if (dist < minDistX) {
1432
+ minDistX = dist;
1433
+ bestDx = dx + (sx - mx);
1434
+ }
1435
+ }
1436
+ }
1437
+ for (const my of movingYVals) {
1438
+ for (const sy of staticYVals) {
1439
+ const dist = Math.abs(my - sy);
1440
+ if (dist < minDistY) {
1441
+ minDistY = dist;
1442
+ bestDy = dy + (sy - my);
1443
+ }
1444
+ }
1445
+ }
1446
+ return { dx: bestDx, dy: bestDy };
1447
+ }
1448
+ _computeAlignGuides(dx, dy) {
1449
+ const selectedSet = new Set(this._selectedIds);
1450
+ const movingShapes = this._shapes.filter((s) => selectedSet.has(s.id));
1451
+ const staticShapes = this._shapes.filter((s) => !selectedSet.has(s.id));
1452
+ if (movingShapes.length === 0)
1453
+ return [];
1454
+ const guides = [];
1455
+ const EXT = 2000;
1456
+ // Include original positions of moving shapes as virtual reference points
1457
+ const allStaticRef = [...staticShapes, ...movingShapes];
1458
+ for (const ms of movingShapes) {
1459
+ const mb = ms.shape.bounds;
1460
+ const mxVals = [mb.x + dx, mb.x + dx + mb.width / 2, mb.x + dx + mb.width];
1461
+ const myVals = [mb.y + dy, mb.y + dy + mb.height / 2, mb.y + dy + mb.height];
1462
+ for (const ss of allStaticRef) {
1463
+ const sb = ss.shape.bounds;
1464
+ const sxVals = [sb.x, sb.x + sb.width / 2, sb.x + sb.width];
1465
+ const syVals = [sb.y, sb.y + sb.height / 2, sb.y + sb.height];
1466
+ for (const mx of mxVals) {
1467
+ for (const sx of sxVals) {
1468
+ if (Math.abs(mx - sx) < 1) {
1469
+ guides.push({ x1: mx, y1: -EXT, x2: mx, y2: EXT });
1470
+ }
1471
+ }
1472
+ }
1473
+ for (const my of myVals) {
1474
+ for (const sy of syVals) {
1475
+ if (Math.abs(my - sy) < 1) {
1476
+ guides.push({ x1: -EXT, y1: my, x2: EXT, y2: my });
1477
+ }
1478
+ }
1479
+ }
1480
+ }
1481
+ }
1482
+ return guides;
1483
+ }
1484
+ // ── Spacing snap (equal-distance guides) ──────────────────────────
1485
+ _computeSpacingSnap(dx, dy) {
1486
+ const selectedSet = new Set(this._selectedIds);
1487
+ const movingShapes = this._shapes.filter((s) => selectedSet.has(s.id));
1488
+ const staticShapes = this._shapes.filter((s) => !selectedSet.has(s.id));
1489
+ if (movingShapes.length !== 1 || staticShapes.length < 2) {
1490
+ return { dx, dy, guides: [] };
1491
+ }
1492
+ const movingShape = movingShapes[0];
1493
+ if (!movingShape)
1494
+ return { dx, dy, guides: [] };
1495
+ const moving = movingShape.shape.bounds;
1496
+ const scale = this._viewport.state.scale;
1497
+ const threshold = 8 / scale;
1498
+ let bestDx = dx;
1499
+ let bestDy = dy;
1500
+ let minDistX = threshold;
1501
+ let minDistY = threshold;
1502
+ const hGuides = [];
1503
+ const vGuides = [];
1504
+ const mCy = moving.y + dy + moving.height / 2;
1505
+ const mCx = moving.x + dx + moving.width / 2;
1506
+ // Horizontal spacing: for each pair (A, B) where B is to the right of A
1507
+ for (const A of staticShapes) {
1508
+ const aRight = A.shape.bounds.x + A.shape.bounds.width;
1509
+ for (const B of staticShapes) {
1510
+ if (A.id === B.id)
1511
+ continue;
1512
+ const bLeft = B.shape.bounds.x;
1513
+ if (bLeft <= aRight)
1514
+ continue;
1515
+ const gap = bLeft - aRight;
1516
+ // Candidate: moving is to the right of B by the same gap
1517
+ const bRight = B.shape.bounds.x + B.shape.bounds.width;
1518
+ const candLeft = bRight + gap;
1519
+ const distX = Math.abs(moving.x + dx - candLeft);
1520
+ if (distX < minDistX) {
1521
+ minDistX = distX;
1522
+ bestDx = dx + (candLeft - (moving.x + dx));
1523
+ hGuides.length = 0;
1524
+ hGuides.push({ x1: aRight, y1: mCy, x2: bLeft, y2: mCy }, { x1: bRight, y1: mCy, x2: candLeft, y2: mCy });
1525
+ }
1526
+ // Candidate: moving is to the left of A by the same gap
1527
+ const aLeft = A.shape.bounds.x;
1528
+ const candRight = aLeft - gap;
1529
+ const movRight = moving.x + dx + moving.width;
1530
+ const distX2 = Math.abs(movRight - candRight);
1531
+ if (distX2 < minDistX) {
1532
+ minDistX = distX2;
1533
+ bestDx = dx + (candRight - movRight);
1534
+ hGuides.length = 0;
1535
+ hGuides.push({ x1: candRight, y1: mCy, x2: aLeft, y2: mCy }, { x1: aRight, y1: mCy, x2: bLeft, y2: mCy });
1536
+ }
1537
+ }
1538
+ }
1539
+ // Vertical spacing: for each pair (A, B) where B is below A
1540
+ for (const A of staticShapes) {
1541
+ const aBottom = A.shape.bounds.y + A.shape.bounds.height;
1542
+ for (const B of staticShapes) {
1543
+ if (A.id === B.id)
1544
+ continue;
1545
+ const bTop = B.shape.bounds.y;
1546
+ if (bTop <= aBottom)
1547
+ continue;
1548
+ const gap = bTop - aBottom;
1549
+ // Candidate: moving is below B by the same gap
1550
+ const bBottom = B.shape.bounds.y + B.shape.bounds.height;
1551
+ const candTop = bBottom + gap;
1552
+ const distY = Math.abs(moving.y + dy - candTop);
1553
+ if (distY < minDistY) {
1554
+ minDistY = distY;
1555
+ bestDy = dy + (candTop - (moving.y + dy));
1556
+ vGuides.length = 0;
1557
+ vGuides.push({ x1: mCx, y1: aBottom, x2: mCx, y2: bTop }, { x1: mCx, y1: bBottom, x2: mCx, y2: candTop });
1558
+ }
1559
+ // Candidate: moving is above A by the same gap
1560
+ const aTop = A.shape.bounds.y;
1561
+ const candBottom = aTop - gap;
1562
+ const movBottom = moving.y + dy + moving.height;
1563
+ const distY2 = Math.abs(movBottom - candBottom);
1564
+ if (distY2 < minDistY) {
1565
+ minDistY = distY2;
1566
+ bestDy = dy + (candBottom - movBottom);
1567
+ vGuides.length = 0;
1568
+ vGuides.push({ x1: mCx, y1: candBottom, x2: mCx, y2: aTop }, { x1: mCx, y1: aBottom, x2: mCx, y2: bTop });
1569
+ }
1570
+ }
1571
+ }
1572
+ return { dx: bestDx, dy: bestDy, guides: [...hGuides, ...vGuides] };
1573
+ }
1574
+ // ── Create-mode helpers ────────────────────────────────────────────
1575
+ _computeCreateSnap(bounds) {
1576
+ if (this._shapes.length === 0)
1577
+ return bounds;
1578
+ const scale = this._viewport.state.scale;
1579
+ const threshold = 8 / scale;
1580
+ const bxVals = [bounds.x, bounds.x + bounds.width / 2, bounds.x + bounds.width];
1581
+ const byVals = [bounds.y, bounds.y + bounds.height / 2, bounds.y + bounds.height];
1582
+ const sxVals = [];
1583
+ const syVals = [];
1584
+ for (const s of this._shapes) {
1585
+ const b = s.shape.bounds;
1586
+ sxVals.push(b.x, b.x + b.width / 2, b.x + b.width);
1587
+ syVals.push(b.y, b.y + b.height / 2, b.y + b.height);
1588
+ }
1589
+ let bestDx = 0;
1590
+ let bestDy = 0;
1591
+ let minDistX = threshold;
1592
+ let minDistY = threshold;
1593
+ for (const bx of bxVals) {
1594
+ for (const sx of sxVals) {
1595
+ const dist = Math.abs(bx - sx);
1596
+ if (dist < minDistX) {
1597
+ minDistX = dist;
1598
+ bestDx = sx - bx;
1599
+ }
1600
+ }
1601
+ }
1602
+ for (const by of byVals) {
1603
+ for (const sy of syVals) {
1604
+ const dist = Math.abs(by - sy);
1605
+ if (dist < minDistY) {
1606
+ minDistY = dist;
1607
+ bestDy = sy - by;
1608
+ }
1609
+ }
1610
+ }
1611
+ return { ...bounds, x: bounds.x + bestDx, y: bounds.y + bestDy };
1612
+ }
1613
+ _computeCreateGuides(bounds) {
1614
+ const guides = [];
1615
+ const EXT = 2000;
1616
+ const bxVals = [bounds.x, bounds.x + bounds.width / 2, bounds.x + bounds.width];
1617
+ const byVals = [bounds.y, bounds.y + bounds.height / 2, bounds.y + bounds.height];
1618
+ for (const s of this._shapes) {
1619
+ const sb = s.shape.bounds;
1620
+ const sxVals = [sb.x, sb.x + sb.width / 2, sb.x + sb.width];
1621
+ const syVals = [sb.y, sb.y + sb.height / 2, sb.y + sb.height];
1622
+ for (const bx of bxVals) {
1623
+ for (const sx of sxVals) {
1624
+ if (Math.abs(bx - sx) < 1)
1625
+ guides.push({ x1: bx, y1: -EXT, x2: bx, y2: EXT });
1626
+ }
1627
+ }
1628
+ for (const by of byVals) {
1629
+ for (const sy of syVals) {
1630
+ if (Math.abs(by - sy) < 1)
1631
+ guides.push({ x1: -EXT, y1: by, x2: EXT, y2: by });
1632
+ }
1633
+ }
1634
+ }
1635
+ return guides;
1636
+ }
1637
+ _findCreateEdgeDrop(bounds) {
1638
+ if (!this._defs)
1639
+ return null;
1640
+ const cx = bounds.x + bounds.width / 2;
1641
+ const cy = bounds.y + bounds.height / 2;
1642
+ const process = this._defs.processes[0];
1643
+ if (!process)
1644
+ return null;
1645
+ const TOLERANCE = 20;
1646
+ for (const edge of this._edges) {
1647
+ const flow = process.sequenceFlows.find((sf) => sf.id === edge.id);
1648
+ if (!flow)
1649
+ continue;
1650
+ const wps = edge.edge.waypoints;
1651
+ for (let i = 0; i < wps.length - 1; i++) {
1652
+ const a = wps[i];
1653
+ const b = wps[i + 1];
1654
+ if (!a || !b)
1655
+ continue;
1656
+ const minX = Math.min(a.x, b.x) - TOLERANCE;
1657
+ const maxX = Math.max(a.x, b.x) + TOLERANCE;
1658
+ const minY = Math.min(a.y, b.y) - TOLERANCE;
1659
+ const maxY = Math.max(a.y, b.y) + TOLERANCE;
1660
+ if (cx >= minX && cx <= maxX && cy >= minY && cy <= maxY)
1661
+ return edge.id;
1662
+ }
1663
+ }
1664
+ return null;
1665
+ }
1666
+ _setBoundaryHost(shapeId) {
1667
+ if (this._boundaryHostId === shapeId)
1668
+ return;
1669
+ this._boundaryHostId = shapeId;
1670
+ if (shapeId) {
1671
+ const shape = this._shapes.find((s) => s.id === shapeId);
1672
+ this._overlay.setBoundaryHostHighlight(shape ? shape.shape.bounds : null);
1673
+ }
1674
+ else {
1675
+ this._overlay.setBoundaryHostHighlight(null);
1676
+ }
1677
+ }
1678
+ _setCreateEdgeDropHighlight(edgeId) {
1679
+ if (this._createEdgeDropTarget === edgeId)
1680
+ return;
1681
+ if (this._createEdgeDropTarget) {
1682
+ const prev = this._edges.find((e) => e.id === this._createEdgeDropTarget);
1683
+ prev?.element.classList.remove("bpmnkit-edge-split-highlight");
1684
+ }
1685
+ this._createEdgeDropTarget = edgeId;
1686
+ if (edgeId) {
1687
+ const edge = this._edges.find((e) => e.id === edgeId);
1688
+ edge?.element.classList.add("bpmnkit-edge-split-highlight");
1689
+ }
1690
+ }
1691
+ // ── Pointer event handlers ─────────────────────────────────────────
1692
+ _onPointerDown = (e) => {
1693
+ if (e.button !== 0)
1694
+ return;
1695
+ const rect = this._svg.getBoundingClientRect();
1696
+ const diag = screenToDiagram(e.clientX, e.clientY, this._viewport.state, rect);
1697
+ const hit = this._hitTest(e.clientX, e.clientY);
1698
+ this._stateMachine.onPointerDown(e, diag, hit);
1699
+ };
1700
+ _onPointerMove = (e) => {
1701
+ const rect = this._svg.getBoundingClientRect();
1702
+ const diag = screenToDiagram(e.clientX, e.clientY, this._viewport.state, rect);
1703
+ const hit = this._hitTest(e.clientX, e.clientY);
1704
+ this._stateMachine.onPointerMove(e, diag, hit);
1705
+ const mode = this._stateMachine.mode;
1706
+ if (mode.mode === "create") {
1707
+ const rawBounds = defaultBounds(mode.elementType, diag.x, diag.y);
1708
+ const snapped = this._computeCreateSnap(rawBounds);
1709
+ const snappedCenter = {
1710
+ x: snapped.x + snapped.width / 2,
1711
+ y: snapped.y + snapped.height / 2,
1712
+ };
1713
+ this._ghostSnapCenter = snappedCenter;
1714
+ this._overlay.setGhostCreate(mode.elementType, snappedCenter);
1715
+ this._overlay.setAlignmentGuides(this._computeCreateGuides(snapped));
1716
+ this._setCreateEdgeDropHighlight(this._findCreateEdgeDrop(snapped));
1717
+ // Detect boundary event attachment target for intermediate event types
1718
+ if (isIntermediateEventType(mode.elementType) && hit.type === "shape") {
1719
+ const shape = this._shapes.find((s) => s.id === hit.id);
1720
+ if (shape?.flowElement && isActivityType(shape.flowElement.type)) {
1721
+ this._setBoundaryHost(hit.id);
1722
+ }
1723
+ else {
1724
+ this._setBoundaryHost(null);
1725
+ }
1726
+ }
1727
+ else {
1728
+ this._setBoundaryHost(null);
1729
+ }
1730
+ }
1731
+ };
1732
+ _onPointerUp = (e) => {
1733
+ if (e.button !== 0)
1734
+ return;
1735
+ const rect = this._svg.getBoundingClientRect();
1736
+ const diag = screenToDiagram(e.clientX, e.clientY, this._viewport.state, rect);
1737
+ const hit = this._hitTest(e.clientX, e.clientY);
1738
+ this._stateMachine.onPointerUp(e, diag, hit);
1739
+ };
1740
+ _onDblClick = (e) => {
1741
+ const rect = this._svg.getBoundingClientRect();
1742
+ const diag = screenToDiagram(e.clientX, e.clientY, this._viewport.state, rect);
1743
+ const hit = this._hitTest(e.clientX, e.clientY);
1744
+ this._stateMachine.onDblClick(e, diag, hit);
1745
+ };
1746
+ _onKeyDown = (e) => {
1747
+ // Don't intercept keys typed into form controls or editable elements
1748
+ const _kbTarget = e.target;
1749
+ if (_kbTarget.tagName === "INPUT" ||
1750
+ _kbTarget.tagName === "TEXTAREA" ||
1751
+ _kbTarget.isContentEditable)
1752
+ return;
1753
+ if (this._readOnly)
1754
+ return;
1755
+ this._stateMachine.onKeyDown(e);
1756
+ if (e.ctrlKey || e.metaKey) {
1757
+ switch (e.key) {
1758
+ case "z":
1759
+ if (e.shiftKey) {
1760
+ e.preventDefault();
1761
+ this.redo();
1762
+ }
1763
+ else {
1764
+ e.preventDefault();
1765
+ this.undo();
1766
+ }
1767
+ break;
1768
+ case "y":
1769
+ e.preventDefault();
1770
+ this.redo();
1771
+ break;
1772
+ case "a":
1773
+ e.preventDefault();
1774
+ this._setSelection(this._shapes.map((s) => s.id));
1775
+ break;
1776
+ case "c":
1777
+ e.preventDefault();
1778
+ this._doCopy();
1779
+ break;
1780
+ case "v":
1781
+ e.preventDefault();
1782
+ this._doPaste();
1783
+ break;
1784
+ }
1785
+ }
1786
+ };
1787
+ // ── Hit testing ───────────────────────────────────────────────────
1788
+ _hitTest(clientX, clientY) {
1789
+ const el = document.elementFromPoint(clientX, clientY);
1790
+ if (!el)
1791
+ return { type: "canvas" };
1792
+ const handleEl = el.closest("[data-bpmnkit-handle]");
1793
+ if (handleEl) {
1794
+ const shapeId = handleEl.getAttribute("data-bpmnkit-id");
1795
+ const handle = handleEl.getAttribute("data-bpmnkit-handle");
1796
+ if (shapeId && handle)
1797
+ return { type: "handle", shapeId, handle };
1798
+ }
1799
+ const portEl = el.closest("[data-bpmnkit-port]");
1800
+ if (portEl) {
1801
+ const shapeId = portEl.getAttribute("data-bpmnkit-id");
1802
+ const port = portEl.getAttribute("data-bpmnkit-port");
1803
+ if (shapeId && port)
1804
+ return { type: "port", shapeId, port };
1805
+ }
1806
+ const endpointEl = el.closest("[data-bpmnkit-endpoint]");
1807
+ if (endpointEl) {
1808
+ const edgeId = endpointEl.getAttribute("data-bpmnkit-id");
1809
+ const ep = endpointEl.getAttribute("data-bpmnkit-endpoint");
1810
+ if (edgeId && ep)
1811
+ return { type: "edge-endpoint", edgeId, isStart: ep === "start" };
1812
+ }
1813
+ const waypointEl = el.closest("[data-bpmnkit-waypoint]");
1814
+ if (waypointEl) {
1815
+ const id = waypointEl.getAttribute("data-bpmnkit-id");
1816
+ const wpIdxStr = waypointEl.getAttribute("data-bpmnkit-waypoint-idx");
1817
+ if (id && wpIdxStr !== null) {
1818
+ const wpIdx = Number(wpIdxStr);
1819
+ const rect = this._svg.getBoundingClientRect();
1820
+ const pt = screenToDiagram(clientX, clientY, this._viewport.state, rect);
1821
+ return { type: "edge-waypoint", id, wpIdx, pt };
1822
+ }
1823
+ }
1824
+ const edgeHitEl = el.closest("[data-bpmnkit-edge-hit]");
1825
+ if (edgeHitEl) {
1826
+ const rect = this._svg.getBoundingClientRect();
1827
+ const diag = screenToDiagram(clientX, clientY, this._viewport.state, rect);
1828
+ // Use geometry to find the nearest edge across all edges — DOM z-order
1829
+ // can cause elementFromPoint to return the wrong edge's hit area when
1830
+ // edges are close together.
1831
+ const seg = this._nearestEdgeSegment(diag);
1832
+ if (seg)
1833
+ return {
1834
+ type: "edge-segment",
1835
+ id: seg.edgeId,
1836
+ segIdx: seg.segIdx,
1837
+ isHoriz: seg.isHoriz,
1838
+ projPt: seg.projPt,
1839
+ };
1840
+ }
1841
+ const shapeEl = el.closest("[data-bpmnkit-id]");
1842
+ if (shapeEl && (this._shapesG.contains(shapeEl) || this._containersG.contains(shapeEl))) {
1843
+ const id = shapeEl.getAttribute("data-bpmnkit-id");
1844
+ if (id)
1845
+ return { type: "shape", id };
1846
+ }
1847
+ return { type: "canvas" };
1848
+ }
1849
+ /** Returns the nearest edge segment across all edges for the given diagram point. */
1850
+ _nearestEdgeSegment(diag) {
1851
+ let bestDist = Number.POSITIVE_INFINITY;
1852
+ let bestEdgeId = "";
1853
+ let bestIdx = 0;
1854
+ let bestProj = { x: 0, y: 0 };
1855
+ let bestHoriz = true;
1856
+ let found = false;
1857
+ for (const edge of this._edges) {
1858
+ const wps = edge.edge.waypoints;
1859
+ for (let i = 0; i < wps.length - 1; i++) {
1860
+ const a = wps[i];
1861
+ const b = wps[i + 1];
1862
+ if (!a || !b)
1863
+ continue;
1864
+ const dx = b.x - a.x;
1865
+ const dy = b.y - a.y;
1866
+ const lenSq = dx * dx + dy * dy;
1867
+ let proj;
1868
+ if (lenSq < 0.001) {
1869
+ proj = { x: a.x, y: a.y };
1870
+ }
1871
+ else {
1872
+ const t = Math.max(0, Math.min(1, ((diag.x - a.x) * dx + (diag.y - a.y) * dy) / lenSq));
1873
+ proj = { x: a.x + t * dx, y: a.y + t * dy };
1874
+ }
1875
+ const dist = Math.hypot(diag.x - proj.x, diag.y - proj.y);
1876
+ if (dist < bestDist) {
1877
+ bestDist = dist;
1878
+ bestEdgeId = edge.id;
1879
+ bestIdx = i;
1880
+ bestProj = proj;
1881
+ bestHoriz = Math.abs(dy) <= Math.abs(dx);
1882
+ found = true;
1883
+ }
1884
+ }
1885
+ }
1886
+ if (!found)
1887
+ return null;
1888
+ return { edgeId: bestEdgeId, segIdx: bestIdx, isHoriz: bestHoriz, projPt: bestProj };
1889
+ }
1890
+ /** Snaps a diagram point to nearby shape/waypoint positions and returns guide lines. */
1891
+ _snapWaypoint(pt) {
1892
+ const scale = this._viewport.state.scale;
1893
+ const threshold = 8 / scale;
1894
+ const EXT = 10000;
1895
+ const xTargets = [];
1896
+ const yTargets = [];
1897
+ for (const shape of this._shapes) {
1898
+ const b = shape.shape.bounds;
1899
+ xTargets.push(b.x, b.x + b.width / 2, b.x + b.width);
1900
+ yTargets.push(b.y, b.y + b.height / 2, b.y + b.height);
1901
+ }
1902
+ for (const edge of this._edges) {
1903
+ for (const wp of edge.edge.waypoints) {
1904
+ xTargets.push(wp.x);
1905
+ yTargets.push(wp.y);
1906
+ }
1907
+ }
1908
+ let snapX = pt.x;
1909
+ let snapY = pt.y;
1910
+ let minDx = threshold;
1911
+ let minDy = threshold;
1912
+ for (const tx of xTargets) {
1913
+ if (Math.abs(pt.x - tx) < minDx) {
1914
+ minDx = Math.abs(pt.x - tx);
1915
+ snapX = tx;
1916
+ }
1917
+ }
1918
+ for (const ty of yTargets) {
1919
+ if (Math.abs(pt.y - ty) < minDy) {
1920
+ minDy = Math.abs(pt.y - ty);
1921
+ snapY = ty;
1922
+ }
1923
+ }
1924
+ const guides = [];
1925
+ if (minDx < threshold)
1926
+ guides.push({ x1: snapX, y1: -EXT, x2: snapX, y2: EXT });
1927
+ if (minDy < threshold)
1928
+ guides.push({ x1: -EXT, y1: snapY, x2: EXT, y2: snapY });
1929
+ return { pt: { x: snapX, y: snapY }, guides };
1930
+ }
1931
+ // ── Theme + controls ──────────────────────────────────────────────
1932
+ _applyTheme(theme) {
1933
+ const resolved = theme === "auto"
1934
+ ? window.matchMedia("(prefers-color-scheme: dark)").matches
1935
+ ? "dark"
1936
+ : "light"
1937
+ : theme;
1938
+ if (resolved === "dark") {
1939
+ this._host.setAttribute("data-theme", "dark");
1940
+ }
1941
+ else {
1942
+ this._host.removeAttribute("data-theme");
1943
+ }
1944
+ }
1945
+ _installPlugin(plugin) {
1946
+ this._plugins.push(plugin);
1947
+ const self = this;
1948
+ const api = {
1949
+ container: this._host,
1950
+ svg: this._svg,
1951
+ viewportEl: this._viewportG,
1952
+ getViewport: () => this._viewport.state,
1953
+ setViewport: (s) => this._viewport.set(s),
1954
+ getShapes: () => [...this._shapes],
1955
+ getEdges: () => [...this._edges],
1956
+ getTheme: () => this._theme,
1957
+ setTheme: (theme) => this.setTheme(theme),
1958
+ on(event, handler) {
1959
+ return self.on(event, handler);
1960
+ },
1961
+ emit(event, ...args) {
1962
+ self._emit(event, ...args);
1963
+ },
1964
+ };
1965
+ plugin.install(api);
1966
+ }
1967
+ _emit(event, ...args) {
1968
+ const handlers = this._listeners.get(event);
1969
+ if (!handlers)
1970
+ return;
1971
+ for (const h of handlers) {
1972
+ ;
1973
+ h(...args);
1974
+ }
1975
+ }
1976
+ }
1977
+ //# sourceMappingURL=editor.js.map