@bpmnkit/editor 0.0.30 → 0.0.32

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 CHANGED
@@ -1,16 +1,36 @@
1
- import { KeyboardHandler, ViewportController, computeDiagramBounds, createDefs, createGrid, injectStyles, render, } from "@bpmnkit/canvas";
1
+ import { KeyboardHandler, OverlayManager, ViewportController, computeDiagramBounds, createDefs, createGrid, injectStyles, render, } from "@bpmnkit/canvas";
2
2
  import { Bpmn, applyAutoLayout } from "@bpmnkit/core";
3
3
  import { CommandStack } from "./command-stack.js";
4
4
  import { injectEditorStyles } from "./css.js";
5
5
  import { closestPort, computeWaypoints, computeWaypointsAvoiding, computeWaypointsWithPorts, diagramToScreen, labelBoundsForPosition, portFromWaypoint, screenToDiagram, } from "./geometry.js";
6
+ import { defaultTranslate } from "./i18n.js";
6
7
  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 { changeElementType as changeElementTypeFn, copyElements, createAnnotation, createAnnotationWithLink, createBoundaryEvent, createConnection, createEmptyDefinitions, createShape, deleteElements, insertEdgeWaypoint, insertShapeOnEdge, moveEdgeSegment, moveEdgeWaypoint, moveShapes, pasteElements, removeCollinearWaypoints, resizeShape, updateEdgeEndpoint, updateLabel, updateLabelPosition, updateShapeColor, } from "./modeling.js";
8
9
  import { OverlayRenderer } from "./overlay.js";
9
- import { canConnect } from "./rules.js";
10
+ import { canAttach, canConnect, canMorph, canResize } from "./rules.js";
10
11
  import { EditorStateMachine } from "./state-machine.js";
11
- import { RESIZABLE_TYPES } from "./types.js";
12
12
  const NS = "http://www.w3.org/2000/svg";
13
13
  let _instanceCounter = 0;
14
+ /**
15
+ * Scores a search element against a lowercased query. Higher is better; 0 means
16
+ * no match. A word-prefix hit on the name outranks a name substring, which
17
+ * outranks an id or type match.
18
+ */
19
+ function scoreSearch(q, name, id, type) {
20
+ if (name) {
21
+ if (name === q)
22
+ return 120;
23
+ if (name.split(/\s+/).some((w) => w.startsWith(q)))
24
+ return 100;
25
+ if (name.includes(q))
26
+ return 60;
27
+ }
28
+ if (id.includes(q))
29
+ return 30;
30
+ if (type.includes(q))
31
+ return 10;
32
+ return 0;
33
+ }
14
34
  function defaultBounds(type, cx, cy) {
15
35
  switch (type) {
16
36
  case "startEvent":
@@ -119,27 +139,9 @@ const INTERMEDIATE_EVENT_TYPES = new Set([
119
139
  "signalCatchEvent",
120
140
  "signalThrowEvent",
121
141
  ]);
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
142
  function isIntermediateEventType(type) {
138
143
  return INTERMEDIATE_EVENT_TYPES.has(type);
139
144
  }
140
- function isActivityType(type) {
141
- return ACTIVITY_TYPES.has(type);
142
- }
143
145
  function snapToBoundary(center, hostBounds, r) {
144
146
  // Clamp center to host boundary and return event bounds
145
147
  const left = hostBounds.x;
@@ -214,6 +216,7 @@ export class BpmnEditor {
214
216
  _viewport;
215
217
  _keyboard;
216
218
  _overlay;
219
+ _htmlOverlays;
217
220
  _commandStack;
218
221
  _stateMachine;
219
222
  _labelEditor;
@@ -234,6 +237,9 @@ export class BpmnEditor {
234
237
  _readOnly = false;
235
238
  _isDragging = false;
236
239
  _boundaryHostId = null;
240
+ _t;
241
+ _liveRegion;
242
+ _lastTap = null;
237
243
  _warningBanner = null;
238
244
  // ── Events ─────────────────────────────────────────────────────────
239
245
  _listeners = new Map();
@@ -243,6 +249,7 @@ export class BpmnEditor {
243
249
  injectStyles();
244
250
  injectEditorStyles();
245
251
  this._id = String(_instanceCounter++);
252
+ this._t = options.translate ?? defaultTranslate;
246
253
  // Resolve initial theme — localStorage overrides the options.theme when persistTheme is on
247
254
  let initialTheme = options.theme ?? "neon";
248
255
  if (options.persistTheme) {
@@ -268,6 +275,13 @@ export class BpmnEditor {
268
275
  this._host.setAttribute("tabindex", "0");
269
276
  this._applyTheme(this._theme);
270
277
  container.appendChild(this._host);
278
+ // Visually-hidden live region: since the SVG is aria-hidden, this is how
279
+ // selection and edit results reach assistive technology.
280
+ this._liveRegion = document.createElement("div");
281
+ this._liveRegion.className = "bpmnkit-sr-only";
282
+ this._liveRegion.setAttribute("aria-live", "polite");
283
+ this._liveRegion.setAttribute("aria-atomic", "true");
284
+ this._host.appendChild(this._liveRegion);
271
285
  this._svg = document.createElementNS(NS, "svg");
272
286
  this._svg.setAttribute("aria-hidden", "true");
273
287
  this._host.appendChild(this._svg);
@@ -289,6 +303,13 @@ export class BpmnEditor {
289
303
  this._viewportG.appendChild(this._overlayG);
290
304
  // ── Viewport controller ──────────────────────────────────────
291
305
  this._viewport = new ViewportController(this._host, this._svg, this._viewportG, this._gridPattern, (state) => this._emit("viewport:change", state));
306
+ // ── HTML overlays (element-anchored) ─────────────────────────
307
+ this._htmlOverlays = new OverlayManager({
308
+ hostEl: this._host,
309
+ getScale: () => this._viewport.state.scale,
310
+ getBBox: (id) => this._absoluteBBox(id),
311
+ onViewportChange: (cb) => this.on("viewport:change", cb),
312
+ });
292
313
  // ── Overlay ──────────────────────────────────────────────────
293
314
  this._overlay = new OverlayRenderer(this._overlayG, this._markerId);
294
315
  // ── Command stack ────────────────────────────────────────────
@@ -308,9 +329,9 @@ export class BpmnEditor {
308
329
  previewResize: (bounds) => this._overlay.setResizePreview(bounds),
309
330
  commitResize: (id, bounds) => {
310
331
  this._overlay.setResizePreview(null);
311
- this._executeCommand((d) => resizeShape(d, id, bounds));
332
+ this._executeCommand((d) => resizeShape(d, id, bounds), "Resize");
312
333
  },
313
- previewConnect: (ghostEnd) => {
334
+ previewConnect: (ghostEnd, targetId) => {
314
335
  const src = this._connectSourceBounds();
315
336
  if (src) {
316
337
  const wps = computeWaypoints(src, {
@@ -319,7 +340,7 @@ export class BpmnEditor {
319
340
  width: 2,
320
341
  height: 2,
321
342
  });
322
- this._overlay.setGhostConnection(wps);
343
+ this._overlay.setGhostConnection(wps, this._isConnectTargetInvalid(targetId));
323
344
  }
324
345
  },
325
346
  cancelConnect: () => this._overlay.setGhostConnection(null),
@@ -333,7 +354,7 @@ export class BpmnEditor {
333
354
  startLabelEdit: (id) => this._startLabelEdit(id),
334
355
  setHovered: (id) => this._overlay.setHovered(id, this._shapes),
335
356
  executeDelete: (ids) => {
336
- this._executeCommand((d) => deleteElements(d, ids));
357
+ this._executeCommand((d) => deleteElements(d, ids), "Delete");
337
358
  this._setSelection([]);
338
359
  },
339
360
  executeCopy: () => this._doCopy(),
@@ -363,7 +384,7 @@ export class BpmnEditor {
363
384
  this._overlay.setEndpointDragGhost(null);
364
385
  this._overlay.setAlignmentGuides([]);
365
386
  const snap = this._snapWaypoint(pt);
366
- this._executeCommand((d) => removeCollinearWaypoints(insertEdgeWaypoint(d, edgeId, segIdx, snap.pt), edgeId));
387
+ this._executeCommand((d) => removeCollinearWaypoints(insertEdgeWaypoint(d, edgeId, segIdx, snap.pt), edgeId), "Add waypoint");
367
388
  },
368
389
  cancelWaypointInsert: () => {
369
390
  this._overlay.setEndpointDragGhost(null);
@@ -382,12 +403,26 @@ export class BpmnEditor {
382
403
  this._overlay.setEndpointDragGhost(null);
383
404
  this._overlay.setAlignmentGuides([]);
384
405
  const snap = this._snapWaypoint(pt);
385
- this._executeCommand((d) => removeCollinearWaypoints(moveEdgeWaypoint(d, edgeId, wpIdx, snap.pt), edgeId));
406
+ this._executeCommand((d) => removeCollinearWaypoints(moveEdgeWaypoint(d, edgeId, wpIdx, snap.pt), edgeId), "Move waypoint");
386
407
  },
387
408
  cancelWaypointMove: () => {
388
409
  this._overlay.setEndpointDragGhost(null);
389
410
  this._overlay.setAlignmentGuides([]);
390
411
  },
412
+ previewSegmentMove: (edgeId, segIdx, isHoriz, delta) => {
413
+ if (!this._defs)
414
+ return;
415
+ const preview = moveEdgeSegment(this._defs, edgeId, segIdx, isHoriz, delta);
416
+ const edge = preview.diagrams[0]?.plane.edges.find((e) => e.bpmnElement === edgeId);
417
+ this._overlay.setEndpointDragGhost(edge?.waypoints ?? null);
418
+ },
419
+ commitSegmentMove: (edgeId, segIdx, isHoriz, delta) => {
420
+ this._overlay.setEndpointDragGhost(null);
421
+ this._executeCommand((d) => removeCollinearWaypoints(moveEdgeSegment(d, edgeId, segIdx, isHoriz, delta), edgeId), "Move segment");
422
+ },
423
+ cancelSegmentMove: () => {
424
+ this._overlay.setEndpointDragGhost(null);
425
+ },
391
426
  showEdgeHoverDot: (pt) => {
392
427
  this._overlay.setEdgeHoverDot(pt);
393
428
  },
@@ -409,7 +444,7 @@ export class BpmnEditor {
409
444
  this._stateMachine = new EditorStateMachine(callbacks);
410
445
  // ── Label editor ─────────────────────────────────────────────
411
446
  this._labelEditor = new LabelEditor(this._host, (id, text) => {
412
- this._executeCommand((d) => updateLabel(d, id, text));
447
+ this._executeCommand((d) => updateLabel(d, id, text), "Rename", `label:${id}`);
413
448
  this._stateMachine.setMode({ mode: "select", sub: { name: "idle", hoveredId: null } });
414
449
  }, () => {
415
450
  this._stateMachine.setMode({ mode: "select", sub: { name: "idle", hoveredId: null } });
@@ -470,13 +505,14 @@ export class BpmnEditor {
470
505
  * The operation is undoable.
471
506
  */
472
507
  autoLayout() {
473
- this._executeCommand(applyAutoLayout);
508
+ this._executeCommand(applyAutoLayout, "Auto-layout");
474
509
  this.fitView();
475
510
  }
476
511
  loadDefinitions(defs) {
477
512
  this._commandStack.clear();
478
513
  this._commandStack.push(defs);
479
514
  this._selectedIds = [];
515
+ this._htmlOverlays.clear();
480
516
  this._renderDefs(defs);
481
517
  if (this._fit !== "none") {
482
518
  requestAnimationFrame(() => this.fitView());
@@ -542,9 +578,134 @@ export class BpmnEditor {
542
578
  if (this._selectedIds.length === 0)
543
579
  return;
544
580
  const ids = [...this._selectedIds];
545
- this._executeCommand((d) => deleteElements(d, ids));
581
+ this._executeCommand((d) => deleteElements(d, ids), "Delete");
546
582
  this._setSelection([]);
547
583
  }
584
+ /** Bounds of the currently-selected shapes (edges have none, and are skipped). */
585
+ _selectedShapeBounds() {
586
+ const out = [];
587
+ for (const id of this._selectedIds) {
588
+ const shape = this._shapes.find((s) => s.id === id);
589
+ if (shape)
590
+ out.push({ id, ...shape.shape.bounds });
591
+ }
592
+ return out;
593
+ }
594
+ /**
595
+ * Aligns the selected shapes along an edge or centre-line, as a single
596
+ * undoable command. No-op for fewer than two selected shapes.
597
+ */
598
+ alignSelected(edge) {
599
+ const bs = this._selectedShapeBounds();
600
+ if (bs.length < 2)
601
+ return;
602
+ const minLeft = Math.min(...bs.map((b) => b.x));
603
+ const maxRight = Math.max(...bs.map((b) => b.x + b.width));
604
+ const minTop = Math.min(...bs.map((b) => b.y));
605
+ const maxBottom = Math.max(...bs.map((b) => b.y + b.height));
606
+ const moves = [];
607
+ for (const b of bs) {
608
+ let dx = 0;
609
+ let dy = 0;
610
+ switch (edge) {
611
+ case "left":
612
+ dx = minLeft - b.x;
613
+ break;
614
+ case "right":
615
+ dx = maxRight - (b.x + b.width);
616
+ break;
617
+ case "center":
618
+ dx = (minLeft + maxRight) / 2 - (b.x + b.width / 2);
619
+ break;
620
+ case "top":
621
+ dy = minTop - b.y;
622
+ break;
623
+ case "bottom":
624
+ dy = maxBottom - (b.y + b.height);
625
+ break;
626
+ case "middle":
627
+ dy = (minTop + maxBottom) / 2 - (b.y + b.height / 2);
628
+ break;
629
+ }
630
+ if (dx !== 0 || dy !== 0)
631
+ moves.push({ id: b.id, dx, dy });
632
+ }
633
+ if (moves.length > 0)
634
+ this._executeCommand((d) => moveShapes(d, moves), "Align");
635
+ }
636
+ /**
637
+ * Distributes the selected shapes so the gaps between them are equal along
638
+ * the axis (the outermost two stay fixed), as a single undoable command.
639
+ * No-op for fewer than three selected shapes.
640
+ */
641
+ distributeSelected(axis) {
642
+ const bs = this._selectedShapeBounds();
643
+ if (bs.length < 3)
644
+ return;
645
+ const horiz = axis === "horizontal";
646
+ const start = (b) => (horiz ? b.x : b.y);
647
+ const size = (b) => (horiz ? b.width : b.height);
648
+ const sorted = [...bs].sort((a, b) => start(a) + size(a) / 2 - (start(b) + size(b) / 2));
649
+ const first = sorted[0];
650
+ const last = sorted[sorted.length - 1];
651
+ if (!first || !last)
652
+ return;
653
+ const span = start(last) + size(last) - start(first);
654
+ const sumSize = sorted.reduce((s, b) => s + size(b), 0);
655
+ const gap = (span - sumSize) / (sorted.length - 1);
656
+ const moves = [];
657
+ let cursor = start(first) + size(first) + gap;
658
+ for (let i = 1; i < sorted.length - 1; i++) {
659
+ const b = sorted[i];
660
+ if (!b)
661
+ continue;
662
+ const delta = cursor - start(b);
663
+ if (delta !== 0) {
664
+ moves.push(horiz ? { id: b.id, dx: delta, dy: 0 } : { id: b.id, dx: 0, dy: delta });
665
+ }
666
+ cursor += size(b) + gap;
667
+ }
668
+ if (moves.length > 0)
669
+ this._executeCommand((d) => moveShapes(d, moves), "Distribute");
670
+ }
671
+ /**
672
+ * Searches the model (recursively, including sub-process children) for
673
+ * elements matching `query`, ranked: a word-prefix match on the name beats a
674
+ * name substring, which beats an id or type match. Returns `[]` for a blank
675
+ * query.
676
+ */
677
+ find(query) {
678
+ const q = query.trim().toLowerCase();
679
+ if (!q || !this._defs)
680
+ return [];
681
+ const candidates = [];
682
+ const walk = (els) => {
683
+ for (const el of els) {
684
+ candidates.push({ id: el.id, name: el.name ?? "", type: el.type });
685
+ if ("flowElements" in el)
686
+ walk(el.flowElements);
687
+ }
688
+ };
689
+ for (const proc of this._defs.processes) {
690
+ walk(proc.flowElements);
691
+ for (const ta of proc.textAnnotations) {
692
+ candidates.push({ id: ta.id, name: ta.text ?? "", type: "textAnnotation" });
693
+ }
694
+ }
695
+ for (const collab of this._defs.collaborations) {
696
+ for (const p of collab.participants) {
697
+ candidates.push({ id: p.id, name: p.name ?? "", type: "participant" });
698
+ }
699
+ }
700
+ const scored = [];
701
+ for (const c of candidates) {
702
+ const score = scoreSearch(q, c.name.toLowerCase(), c.id.toLowerCase(), c.type.toLowerCase());
703
+ if (score > 0)
704
+ scored.push({ ...c, score });
705
+ }
706
+ scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
707
+ return scored.map((c) => ({ id: c.id, label: c.name || c.id, type: c.type }));
708
+ }
548
709
  undo() {
549
710
  const prev = this._commandStack.undo();
550
711
  if (prev) {
@@ -608,6 +769,17 @@ export class BpmnEditor {
608
769
  this._theme = theme;
609
770
  this._applyTheme(theme);
610
771
  }
772
+ /** HTML overlays anchored to diagram elements (badges, tooltips, panels). */
773
+ get overlays() {
774
+ return this._htmlOverlays;
775
+ }
776
+ /**
777
+ * The resolved translation hook (from `options.translate`, or identity).
778
+ * Used by the HUD to localize its strings; also available to plugins.
779
+ */
780
+ get translate() {
781
+ return this._t;
782
+ }
611
783
  zoomIn() {
612
784
  const { width, height } = this._svg.getBoundingClientRect();
613
785
  this._viewport.zoomAt(width / 2, height / 2, 1.25);
@@ -629,6 +801,10 @@ export class BpmnEditor {
629
801
  paste() {
630
802
  this._doPaste();
631
803
  }
804
+ /** Copies the current selection to the clipboard and deletes it as one undo step. */
805
+ cut() {
806
+ this._doCut();
807
+ }
632
808
  /** Pans the viewport to center on the element with the given id (preserves zoom). */
633
809
  scrollToElement(id) {
634
810
  const shape = this._shapes.find((s) => s.id === id);
@@ -658,6 +834,7 @@ export class BpmnEditor {
658
834
  this._ro.disconnect();
659
835
  this._viewport.destroy();
660
836
  this._keyboard.destroy();
837
+ this._htmlOverlays.destroy();
661
838
  this._labelEditor.destroy();
662
839
  this._svg.removeEventListener("pointerdown", this._onPointerDown);
663
840
  this._svg.removeEventListener("pointermove", this._onPointerMove);
@@ -752,16 +929,26 @@ export class BpmnEditor {
752
929
  this._overlay.setEdgeEndpoints(null, "");
753
930
  }
754
931
  }
755
- this._emit("diagram:load", defs);
932
+ this._emit("diagram:load", defs, { missingShapes: [], missingEdges: [] });
756
933
  }
757
- _executeCommand(fn) {
934
+ _executeCommand(fn, label = "", coalesceKey) {
758
935
  if (this._readOnly || !this._defs)
759
936
  return;
760
937
  const newDefs = fn(this._defs);
761
- this._commandStack.push(newDefs);
938
+ this._commandStack.push(newDefs, label, coalesceKey);
762
939
  this._renderDefs(newDefs);
940
+ if (label)
941
+ this._announce(this._t(label));
763
942
  this._emit("diagram:change", newDefs);
764
943
  }
944
+ /** Label of the change `undo()` would revert (for HUD tooltips), or null. */
945
+ getUndoLabel() {
946
+ return this._commandStack.undoLabel();
947
+ }
948
+ /** Label of the change `redo()` would re-apply (for HUD tooltips), or null. */
949
+ getRedoLabel() {
950
+ return this._commandStack.redoLabel();
951
+ }
765
952
  _setSelection(ids) {
766
953
  this._selectedIds = ids;
767
954
  // Clear edge selection whenever shape selection changes
@@ -770,8 +957,34 @@ export class BpmnEditor {
770
957
  this._overlay.setEdgeEndpoints(null, "");
771
958
  }
772
959
  this._overlay.setSelection(ids, this._shapes, this._getResizableIds());
960
+ this._announceSelection(ids);
773
961
  this._emit("editor:select", ids);
774
962
  }
963
+ /** Human-readable name for an element (its label, else its type). */
964
+ _displayName(id) {
965
+ const el = this._shapes.find((s) => s.id === id)?.flowElement;
966
+ return el?.name ?? el?.type ?? id;
967
+ }
968
+ /** Pushes `message` to the aria-live region for assistive technology. */
969
+ _announce(message) {
970
+ // Re-assigning identical text does not re-trigger some screen readers, so
971
+ // clear first when the message is unchanged.
972
+ if (this._liveRegion.textContent === message)
973
+ this._liveRegion.textContent = "";
974
+ this._liveRegion.textContent = message;
975
+ }
976
+ _announceSelection(ids) {
977
+ // A cleared selection is left unannounced — it is usually the tail of an
978
+ // edit (delete, cut) whose own result is the meaningful announcement.
979
+ if (ids.length === 0)
980
+ return;
981
+ if (ids.length === 1 && ids[0]) {
982
+ this._announce(this._t("{name} selected", { name: this._displayName(ids[0]) }));
983
+ }
984
+ else {
985
+ this._announce(this._t("{count} elements selected", { count: ids.length }));
986
+ }
987
+ }
775
988
  _previewTranslate(dx, dy) {
776
989
  if (!this._isDragging) {
777
990
  this._isDragging = true;
@@ -833,10 +1046,10 @@ export class BpmnEditor {
833
1046
  const moves = this._selectedIds.map((id) => ({ id, dx: snap.dx, dy: snap.dy }));
834
1047
  const shapeId = this._selectedIds.length === 1 ? this._selectedIds[0] : undefined;
835
1048
  if (edgeDropId && shapeId) {
836
- this._executeCommand((d) => insertShapeOnEdge(moveShapes(d, moves), edgeDropId, shapeId));
1049
+ this._executeCommand((d) => insertShapeOnEdge(moveShapes(d, moves), edgeDropId, shapeId), "Insert on flow");
837
1050
  }
838
1051
  else {
839
- this._executeCommand((d) => moveShapes(d, moves));
1052
+ this._executeCommand((d) => moveShapes(d, moves), "Move");
840
1053
  }
841
1054
  if (this._isDragging) {
842
1055
  this._isDragging = false;
@@ -911,7 +1124,7 @@ export class BpmnEditor {
911
1124
  }
912
1125
  }
913
1126
  if (moves.length > 0) {
914
- this._executeCommand((d) => moveShapes(d, moves));
1127
+ this._executeCommand((d) => moveShapes(d, moves), "Move");
915
1128
  }
916
1129
  }
917
1130
  _cancelSpace() {
@@ -985,7 +1198,7 @@ export class BpmnEditor {
985
1198
  .filter((s) => s.id !== srcId && s.id !== tgtId)
986
1199
  .map((s) => s.shape.bounds);
987
1200
  const waypoints = computeWaypointsAvoiding(srcShape.shape.bounds, tgtShape.shape.bounds, obstacles);
988
- this._executeCommand((d) => createConnection(d, srcId, tgtId, waypoints).defs);
1201
+ this._executeCommand((d) => createConnection(d, srcId, tgtId, waypoints).defs, "Connect");
989
1202
  }
990
1203
  _doCopy() {
991
1204
  if (!this._defs || this._selectedIds.length === 0)
@@ -997,12 +1210,19 @@ export class BpmnEditor {
997
1210
  return;
998
1211
  const base = this._defs ?? createEmptyDefinitions();
999
1212
  const result = pasteElements(base, this._clipboard, 20, 20);
1000
- const newIds = [...result.newIds.values()];
1001
- this._selectedIds = newIds;
1002
- this._commandStack.push(result.defs);
1213
+ this._selectedIds = result.topLevelIds;
1214
+ this._commandStack.push(result.defs, "Paste");
1003
1215
  this._renderDefs(result.defs);
1004
1216
  this._emit("diagram:change", result.defs);
1005
- this._emit("editor:select", newIds);
1217
+ this._emit("editor:select", result.topLevelIds);
1218
+ }
1219
+ _doCut() {
1220
+ if (!this._defs || this._selectedIds.length === 0)
1221
+ return;
1222
+ this._doCopy();
1223
+ const ids = [...this._selectedIds];
1224
+ this._executeCommand((d) => deleteElements(d, ids), "Cut");
1225
+ this._setSelection([]);
1006
1226
  }
1007
1227
  _startLabelEdit(id) {
1008
1228
  if (this._readOnly || !id)
@@ -1021,15 +1241,31 @@ export class BpmnEditor {
1021
1241
  this._labelEditor.start(id, currentText, shape.shape.bounds, this._viewport.state, this._svg.getBoundingClientRect());
1022
1242
  }
1023
1243
  _connectSourceBounds() {
1244
+ const sourceId = this._connectSourceId();
1245
+ if (!sourceId)
1246
+ return null;
1247
+ const shape = this._shapes.find((s) => s.id === sourceId);
1248
+ return shape ? shape.shape.bounds : null;
1249
+ }
1250
+ _connectSourceId() {
1024
1251
  const mode = this._stateMachine.mode;
1025
1252
  if (mode.mode !== "select")
1026
1253
  return null;
1027
1254
  const sub = mode.sub;
1028
- const sourceId = sub.name === "connecting" ? sub.sourceId : sub.name === "pointing-port" ? sub.sourceId : null;
1255
+ return sub.name === "connecting" || sub.name === "pointing-port" ? sub.sourceId : null;
1256
+ }
1257
+ /** True when hovering a target the connect tool would reject (self, or a rule violation). */
1258
+ _isConnectTargetInvalid(targetId) {
1259
+ if (!targetId)
1260
+ return false;
1261
+ const sourceId = this._connectSourceId();
1029
1262
  if (!sourceId)
1030
- return null;
1031
- const shape = this._shapes.find((s) => s.id === sourceId);
1032
- return shape ? shape.shape.bounds : null;
1263
+ return false;
1264
+ if (targetId === sourceId)
1265
+ return true;
1266
+ const srcType = this._shapes.find((s) => s.id === sourceId)?.flowElement?.type;
1267
+ const tgtType = this._shapes.find((s) => s.id === targetId)?.flowElement?.type;
1268
+ return srcType !== undefined && tgtType !== undefined && !canConnect(srcType, tgtType);
1033
1269
  }
1034
1270
  // ── New public helpers ─────────────────────────────────────────────
1035
1271
  /** Returns screen-space bounds of a shape (for positioning overlays). */
@@ -1202,7 +1438,7 @@ export class BpmnEditor {
1202
1438
  if (!shape)
1203
1439
  return;
1204
1440
  const labelBounds = labelBoundsForPosition(shape.shape.bounds, position);
1205
- this._executeCommand((d) => updateLabelPosition(d, shapeId, labelBounds));
1441
+ this._executeCommand((d) => updateLabelPosition(d, shapeId, labelBounds), "Move label");
1206
1442
  }
1207
1443
  /** Starts inline label editing for the element with the given id. */
1208
1444
  editLabel(id) {
@@ -1254,7 +1490,7 @@ export class BpmnEditor {
1254
1490
  }
1255
1491
  /** Updates the color of a shape in the diagram. Pass `{}` to clear colors. */
1256
1492
  updateColor(id, color) {
1257
- this._executeCommand((d) => updateShapeColor(d, id, color));
1493
+ this._executeCommand((d) => updateShapeColor(d, id, color), "Change colour", `color:${id}`);
1258
1494
  }
1259
1495
  // ── Private helpers ────────────────────────────────────────────────
1260
1496
  _setEdgeSelected(edgeId) {
@@ -1276,7 +1512,10 @@ export class BpmnEditor {
1276
1512
  }
1277
1513
  /** Changes a flow element's type (e.g. exclusiveGateway → parallelGateway). */
1278
1514
  changeElementType(id, newType) {
1279
- this._executeCommand((d) => changeElementTypeFn(d, id, newType));
1515
+ const current = this._shapes.find((s) => s.id === id)?.flowElement?.type;
1516
+ if (current && !canMorph(current, newType))
1517
+ return;
1518
+ this._executeCommand((d) => changeElementTypeFn(d, id, newType), "Change type");
1280
1519
  }
1281
1520
  _findEdgeDropTarget(dx, dy) {
1282
1521
  if (this._selectedIds.length !== 1)
@@ -1381,7 +1620,7 @@ export class BpmnEditor {
1381
1620
  const newPort = isStart
1382
1621
  ? closestPort(diagPoint, srcDi.bounds)
1383
1622
  : closestPort(diagPoint, tgtDi.bounds);
1384
- this._executeCommand((d) => updateEdgeEndpoint(d, edgeId, isStart, newPort));
1623
+ this._executeCommand((d) => updateEdgeEndpoint(d, edgeId, isStart, newPort), "Reconnect");
1385
1624
  }
1386
1625
  _isResizable(id) {
1387
1626
  const shape = this._shapes.find((s) => s.id === id);
@@ -1389,13 +1628,13 @@ export class BpmnEditor {
1389
1628
  return false;
1390
1629
  if (shape.annotation !== undefined)
1391
1630
  return true;
1392
- return shape.flowElement !== undefined && RESIZABLE_TYPES.has(shape.flowElement.type);
1631
+ return shape.flowElement !== undefined && canResize(shape.flowElement.type);
1393
1632
  }
1394
1633
  _getResizableIds() {
1395
1634
  const ids = new Set();
1396
1635
  for (const shape of this._shapes) {
1397
1636
  if (shape.annotation !== undefined ||
1398
- (shape.flowElement && RESIZABLE_TYPES.has(shape.flowElement.type))) {
1637
+ (shape.flowElement && canResize(shape.flowElement.type))) {
1399
1638
  ids.add(shape.id);
1400
1639
  }
1401
1640
  }
@@ -1726,7 +1965,7 @@ export class BpmnEditor {
1726
1965
  // Detect boundary event attachment target for intermediate event types
1727
1966
  if (isIntermediateEventType(mode.elementType) && hit.type === "shape") {
1728
1967
  const shape = this._shapes.find((s) => s.id === hit.id);
1729
- if (shape?.flowElement && isActivityType(shape.flowElement.type)) {
1968
+ if (shape?.flowElement && canAttach(shape.flowElement.type)) {
1730
1969
  this._setBoundaryHost(hit.id);
1731
1970
  }
1732
1971
  else {
@@ -1745,7 +1984,28 @@ export class BpmnEditor {
1745
1984
  const diag = screenToDiagram(e.clientX, e.clientY, this._viewport.state, rect);
1746
1985
  const hit = this._hitTest(e.clientX, e.clientY);
1747
1986
  this._stateMachine.onPointerUp(e, diag, hit);
1987
+ if (e.pointerType === "touch")
1988
+ this._detectDoubleTap(e, hit);
1748
1989
  };
1990
+ /** On coarse pointers, a double-tap on a shape starts label editing (mirrors dblclick). */
1991
+ _detectDoubleTap(e, hit) {
1992
+ if (hit.type !== "shape") {
1993
+ this._lastTap = null;
1994
+ return;
1995
+ }
1996
+ const now = Date.now();
1997
+ const prev = this._lastTap;
1998
+ if (prev &&
1999
+ prev.id === hit.id &&
2000
+ now - prev.t < 300 &&
2001
+ Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < 20) {
2002
+ this._lastTap = null;
2003
+ this._startLabelEdit(hit.id);
2004
+ }
2005
+ else {
2006
+ this._lastTap = { t: now, x: e.clientX, y: e.clientY, id: hit.id };
2007
+ }
2008
+ }
1749
2009
  _onPointerCancel = (_e) => {
1750
2010
  this._stateMachine.cancel();
1751
2011
  };
@@ -1793,6 +2053,10 @@ export class BpmnEditor {
1793
2053
  e.preventDefault();
1794
2054
  this._doPaste();
1795
2055
  break;
2056
+ case "x":
2057
+ e.preventDefault();
2058
+ this._doCut();
2059
+ break;
1796
2060
  }
1797
2061
  }
1798
2062
  };
@@ -1841,14 +2105,20 @@ export class BpmnEditor {
1841
2105
  // can cause elementFromPoint to return the wrong edge's hit area when
1842
2106
  // edges are close together.
1843
2107
  const seg = this._nearestEdgeSegment(diag);
1844
- if (seg)
2108
+ if (seg) {
2109
+ // A press near the segment midpoint inserts a waypoint; a press
2110
+ // elsewhere along the segment moves the whole segment.
2111
+ const midDist = Math.hypot(seg.projPt.x - seg.mid.x, seg.projPt.y - seg.mid.y);
2112
+ const nearMidpoint = midDist < 12 / this._viewport.state.scale;
1845
2113
  return {
1846
2114
  type: "edge-segment",
1847
2115
  id: seg.edgeId,
1848
2116
  segIdx: seg.segIdx,
1849
2117
  isHoriz: seg.isHoriz,
1850
2118
  projPt: seg.projPt,
2119
+ nearMidpoint,
1851
2120
  };
2121
+ }
1852
2122
  }
1853
2123
  const shapeEl = el.closest("[data-bpmnkit-id]");
1854
2124
  if (shapeEl && (this._shapesG.contains(shapeEl) || this._containersG.contains(shapeEl))) {
@@ -1864,6 +2134,7 @@ export class BpmnEditor {
1864
2134
  let bestEdgeId = "";
1865
2135
  let bestIdx = 0;
1866
2136
  let bestProj = { x: 0, y: 0 };
2137
+ let bestMid = { x: 0, y: 0 };
1867
2138
  let bestHoriz = true;
1868
2139
  let found = false;
1869
2140
  for (const edge of this._edges) {
@@ -1890,6 +2161,7 @@ export class BpmnEditor {
1890
2161
  bestEdgeId = edge.id;
1891
2162
  bestIdx = i;
1892
2163
  bestProj = proj;
2164
+ bestMid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
1893
2165
  bestHoriz = Math.abs(dy) <= Math.abs(dx);
1894
2166
  found = true;
1895
2167
  }
@@ -1897,7 +2169,13 @@ export class BpmnEditor {
1897
2169
  }
1898
2170
  if (!found)
1899
2171
  return null;
1900
- return { edgeId: bestEdgeId, segIdx: bestIdx, isHoriz: bestHoriz, projPt: bestProj };
2172
+ return {
2173
+ edgeId: bestEdgeId,
2174
+ segIdx: bestIdx,
2175
+ isHoriz: bestHoriz,
2176
+ projPt: bestProj,
2177
+ mid: bestMid,
2178
+ };
1901
2179
  }
1902
2180
  /** Snaps a diagram point to nearby shape/waypoint positions and returns guide lines. */
1903
2181
  _snapWaypoint(pt) {
@@ -1954,6 +2232,41 @@ export class BpmnEditor {
1954
2232
  this._host.setAttribute("data-theme", resolved);
1955
2233
  }
1956
2234
  }
2235
+ /** Finds the SVG group for a shape or edge by BPMN id. */
2236
+ _elementById(id) {
2237
+ return (this._shapes.find((s) => s.id === id)?.element ??
2238
+ this._edges.find((e) => e.id === id)?.element);
2239
+ }
2240
+ /** Element bounding box in screen pixels relative to the host, or `null`. */
2241
+ _absoluteBBox(id) {
2242
+ const box = this._boundsById(id);
2243
+ if (!box)
2244
+ return null;
2245
+ const { tx, ty, scale } = this._viewport.state;
2246
+ return {
2247
+ x: box.x * scale + tx,
2248
+ y: box.y * scale + ty,
2249
+ width: box.width * scale,
2250
+ height: box.height * scale,
2251
+ };
2252
+ }
2253
+ /** Diagram-coordinate bounds of a shape (from DI) or edge (waypoint bbox). */
2254
+ _boundsById(id) {
2255
+ const shape = this._shapes.find((s) => s.id === id);
2256
+ if (shape) {
2257
+ const { x, y, width, height } = shape.shape.bounds;
2258
+ return { x, y, width, height };
2259
+ }
2260
+ const edge = this._edges.find((e) => e.id === id);
2261
+ if (edge && edge.edge.waypoints.length > 0) {
2262
+ const xs = edge.edge.waypoints.map((w) => w.x);
2263
+ const ys = edge.edge.waypoints.map((w) => w.y);
2264
+ const minX = Math.min(...xs);
2265
+ const minY = Math.min(...ys);
2266
+ return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
2267
+ }
2268
+ return null;
2269
+ }
1957
2270
  _installPlugin(plugin) {
1958
2271
  this._plugins.push(plugin);
1959
2272
  const self = this;
@@ -1967,6 +2280,42 @@ export class BpmnEditor {
1967
2280
  getEdges: () => [...this._edges],
1968
2281
  getTheme: () => this._theme,
1969
2282
  setTheme: (theme) => this.setTheme(theme),
2283
+ overlays: this._htmlOverlays,
2284
+ addMarker(id, cls) {
2285
+ self._elementById(id)?.classList.add(cls);
2286
+ },
2287
+ removeMarker(id, cls) {
2288
+ self._elementById(id)?.classList.remove(cls);
2289
+ },
2290
+ hasMarker(id, cls) {
2291
+ return self._elementById(id)?.classList.contains(cls) ?? false;
2292
+ },
2293
+ toggleMarker(id, cls) {
2294
+ self._elementById(id)?.classList.toggle(cls);
2295
+ },
2296
+ zoom(scaleOrFit = "fit") {
2297
+ if (scaleOrFit === "fit")
2298
+ self.fitView();
2299
+ else
2300
+ self.setZoom(scaleOrFit);
2301
+ },
2302
+ viewbox() {
2303
+ const { tx, ty, scale } = self._viewport.state;
2304
+ const { width, height } = self._svg.getBoundingClientRect();
2305
+ return {
2306
+ x: -tx / scale,
2307
+ y: -ty / scale,
2308
+ width: width / scale,
2309
+ height: height / scale,
2310
+ scale,
2311
+ };
2312
+ },
2313
+ scrollToElement(id) {
2314
+ self.scrollToElement(id);
2315
+ },
2316
+ getAbsoluteBBox(id) {
2317
+ return self._absoluteBBox(id);
2318
+ },
1970
2319
  on(event, handler) {
1971
2320
  return self.on(event, handler);
1972
2321
  },