@bpmnkit/editor 0.0.33 → 0.0.34

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/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![ai-assisted](https://img.shields.io/badge/AI--assisted-claude-8b5cf6?style=flat-square)](https://github.com/bpmnkit/monorepo)
10
10
  [![experimental](https://img.shields.io/badge/status-experimental-f59e0b?style=flat-square)](https://github.com/bpmnkit/monorepo)
11
11
 
12
- [Website](https://bpmnkit.com) · [Documentation](https://docs.bpmnkit.com) · [GitHub](https://github.com/bpmnkit/monorepo) · [Changelog](https://github.com/bpmnkit/monorepo/blob/main/packages/editor/CHANGELOG.md)
12
+ [Website](https://bpmnkit.com) · [Documentation](https://bpmnkit.com/docs) · [GitHub](https://github.com/bpmnkit/monorepo) · [Changelog](https://github.com/bpmnkit/monorepo/blob/main/packages/editor/CHANGELOG.md)
13
13
  </div>
14
14
 
15
15
  ---
package/dist/editor.d.ts CHANGED
@@ -3,10 +3,6 @@ import type { Theme } from "@bpmnkit/canvas";
3
3
  import type { BpmnDefinitions, DiColor } from "@bpmnkit/core";
4
4
  import type { Translate } from "./i18n.js";
5
5
  import type { CreateShapeType, EditorEvents, EditorOptions, LabelPosition, Tool } from "./types.js";
6
- /**
7
- * BpmnEditor — a full BPMN 2.0 diagram editor with create, move, resize,
8
- * connect, delete, label-edit, undo/redo, and copy/paste.
9
- */
10
6
  export declare class BpmnEditor {
11
7
  private readonly _id;
12
8
  private readonly _host;
@@ -28,6 +24,12 @@ export declare class BpmnEditor {
28
24
  private readonly _labelEditor;
29
25
  private readonly _plugins;
30
26
  private _shapes;
27
+ /** id → rendered shape/edge/flow, rebuilt with every render so lookups stay O(1). */
28
+ private readonly _shapeById;
29
+ private readonly _edgeById;
30
+ private readonly _flowById;
31
+ /** Equal-spacing snap candidates for the current drag; see _spacingCandidates. */
32
+ private _spacingIndex;
31
33
  private _edges;
32
34
  private _defs;
33
35
  private _selectedIds;
@@ -195,6 +197,13 @@ export declare class BpmnEditor {
195
197
  private _computeSnap;
196
198
  private _computeAlignGuides;
197
199
  private _computeSpacingSnap;
200
+ /**
201
+ * Candidate snap positions from every ordered pair (A, B) of static shapes
202
+ * where B lies right of / below A: the moving shape may sit right of B or
203
+ * left of A (below B / above A) at the same gap. Cached for the drag: it is
204
+ * rebuilt only when the rendered shapes or the selection change.
205
+ */
206
+ private _spacingCandidates;
198
207
  private _computeCreateSnap;
199
208
  private _computeCreateGuides;
200
209
  private _findCreateEdgeDrop;
package/dist/editor.js CHANGED
@@ -195,10 +195,49 @@ function intermediateEventDefType(type) {
195
195
  return null;
196
196
  }
197
197
  }
198
+ /** Sort by value and drop duplicate values, keeping the earliest pair for each. */
199
+ function sortCandidates(entries) {
200
+ entries.sort((x, y) => x.value - y.value || x.ord - y.ord);
201
+ const out = [];
202
+ for (const e of entries) {
203
+ const last = out[out.length - 1];
204
+ if (last && last.value === e.value)
205
+ continue;
206
+ out.push(e);
207
+ }
208
+ return out;
209
+ }
198
210
  /**
199
- * BpmnEditor a full BPMN 2.0 diagram editor with create, move, resize,
200
- * connect, delete, label-edit, undo/redo, and copy/paste.
211
+ * The candidate strictly closer than `threshold` to its target, choosing the
212
+ * earliest pair on exact ties — the same winner the full pair scan produced.
201
213
  */
214
+ function nearestCandidate(queries, threshold) {
215
+ let best = null;
216
+ for (const { list, target } of queries) {
217
+ // Binary search for the insertion point; only the two neighbours can be nearest.
218
+ let lo = 0;
219
+ let hi = list.length;
220
+ while (lo < hi) {
221
+ const mid = (lo + hi) >> 1;
222
+ if (list[mid].value < target)
223
+ lo = mid + 1;
224
+ else
225
+ hi = mid;
226
+ }
227
+ for (const i of [lo - 1, lo]) {
228
+ const entry = list[i];
229
+ if (!entry)
230
+ continue;
231
+ const dist = Math.abs(target - entry.value);
232
+ if (dist >= threshold)
233
+ continue;
234
+ if (!best || dist < best.dist || (dist === best.dist && entry.ord < best.entry.ord)) {
235
+ best = { entry, target, dist };
236
+ }
237
+ }
238
+ }
239
+ return best;
240
+ }
202
241
  export class BpmnEditor {
203
242
  // ── DOM ────────────────────────────────────────────────────────────
204
243
  _id;
@@ -223,6 +262,12 @@ export class BpmnEditor {
223
262
  _plugins = [];
224
263
  // ── State ──────────────────────────────────────────────────────────
225
264
  _shapes = [];
265
+ /** id → rendered shape/edge/flow, rebuilt with every render so lookups stay O(1). */
266
+ _shapeById = new Map();
267
+ _edgeById = new Map();
268
+ _flowById = new Map();
269
+ /** Equal-spacing snap candidates for the current drag; see _spacingCandidates. */
270
+ _spacingIndex = null;
226
271
  _edges = [];
227
272
  _defs = null;
228
273
  _selectedIds = [];
@@ -367,7 +412,7 @@ export class BpmnEditor {
367
412
  cancelEndpointMove: () => {
368
413
  this._overlay.setEndpointDragGhost(null);
369
414
  if (this._selectedEdgeId) {
370
- const edge = this._edges.find((e) => e.id === this._selectedEdgeId);
415
+ const edge = this._edgeById.get(this._selectedEdgeId);
371
416
  this._overlay.setEdgeEndpoints(edge?.edge.waypoints ?? null, this._selectedEdgeId);
372
417
  }
373
418
  },
@@ -430,7 +475,7 @@ export class BpmnEditor {
430
475
  this._overlay.setEdgeHoverDot(null);
431
476
  },
432
477
  showEdgeWaypointBalls: (edgeId) => {
433
- const edge = this._edges.find((e) => e.id === edgeId);
478
+ const edge = this._edgeById.get(edgeId);
434
479
  if (edge)
435
480
  this._overlay.setEdgeWaypointBalls(edge.edge.waypoints, edgeId);
436
481
  },
@@ -451,7 +496,7 @@ export class BpmnEditor {
451
496
  });
452
497
  // ── Keyboard ─────────────────────────────────────────────────
453
498
  this._keyboard = new KeyboardHandler(this._host, this._viewport, () => this.fitView(), (id) => {
454
- const shape = this._shapes.find((s) => s.id === id);
499
+ const shape = this._shapeById.get(id);
455
500
  if (shape) {
456
501
  this._emit("element:click", id, new PointerEvent("click"));
457
502
  }
@@ -585,7 +630,7 @@ export class BpmnEditor {
585
630
  _selectedShapeBounds() {
586
631
  const out = [];
587
632
  for (const id of this._selectedIds) {
588
- const shape = this._shapes.find((s) => s.id === id);
633
+ const shape = this._shapeById.get(id);
589
634
  if (shape)
590
635
  out.push({ id, ...shape.shape.bounds });
591
636
  }
@@ -807,7 +852,7 @@ export class BpmnEditor {
807
852
  }
808
853
  /** Pans the viewport to center on the element with the given id (preserves zoom). */
809
854
  scrollToElement(id) {
810
- const shape = this._shapes.find((s) => s.id === id);
855
+ const shape = this._shapeById.get(id);
811
856
  if (!shape)
812
857
  return;
813
858
  const { x, y, width, height } = shape.shape.bounds;
@@ -904,6 +949,16 @@ export class BpmnEditor {
904
949
  this._shapes = result.shapes;
905
950
  this._edges = result.edges;
906
951
  this._defs = defs;
952
+ this._shapeById.clear();
953
+ for (const shape of this._shapes)
954
+ this._shapeById.set(shape.id, shape);
955
+ this._edgeById.clear();
956
+ for (const edge of this._edges)
957
+ this._edgeById.set(edge.id, edge);
958
+ this._flowById.clear();
959
+ for (const flow of defs.processes[0]?.sequenceFlows ?? [])
960
+ this._flowById.set(flow.id, flow);
961
+ this._spacingIndex = null;
907
962
  // Add transparent hit-area polylines for edge clicking
908
963
  for (const edge of this._edges) {
909
964
  const waypoints = edge.edge.waypoints;
@@ -920,7 +975,7 @@ export class BpmnEditor {
920
975
  this._overlay.setSelection(this._selectedIds, this._shapes, this._getResizableIds());
921
976
  // Restore edge selection if the edge still exists after re-render
922
977
  if (this._selectedEdgeId) {
923
- const edge = this._edges.find((e) => e.id === this._selectedEdgeId);
978
+ const edge = this._edgeById.get(this._selectedEdgeId);
924
979
  if (edge) {
925
980
  this._overlay.setEdgeEndpoints(edge.edge.waypoints, this._selectedEdgeId);
926
981
  }
@@ -962,7 +1017,7 @@ export class BpmnEditor {
962
1017
  }
963
1018
  /** Human-readable name for an element (its label, else its type). */
964
1019
  _displayName(id) {
965
- const el = this._shapes.find((s) => s.id === id)?.flowElement;
1020
+ const el = this._shapeById.get(id)?.flowElement;
966
1021
  return el?.name ?? el?.type ?? id;
967
1022
  }
968
1023
  /** Pushes `message` to the aria-live region for assistive technology. */
@@ -1004,7 +1059,7 @@ export class BpmnEditor {
1004
1059
  const finalDy = useSpacingY ? spacingResult.dy : alignSnap.dy;
1005
1060
  this._snapDelta = { dx: finalDx, dy: finalDy };
1006
1061
  for (const id of this._selectedIds) {
1007
- const shape = this._shapes.find((s) => s.id === id);
1062
+ const shape = this._shapeById.get(id);
1008
1063
  if (!shape)
1009
1064
  continue;
1010
1065
  const { x, y } = shape.shape.bounds;
@@ -1023,7 +1078,7 @@ export class BpmnEditor {
1023
1078
  this._overlay.setDistanceGuides([]);
1024
1079
  this._setEdgeDropHighlight(null);
1025
1080
  for (const id of this._selectedIds) {
1026
- const shape = this._shapes.find((s) => s.id === id);
1081
+ const shape = this._shapeById.get(id);
1027
1082
  if (!shape)
1028
1083
  continue;
1029
1084
  const { x, y } = shape.shape.bounds;
@@ -1159,7 +1214,7 @@ export class BpmnEditor {
1159
1214
  const boundaryHostId = this._boundaryHostId;
1160
1215
  this._setBoundaryHost(null);
1161
1216
  if (boundaryHostId && isIntermediateEventType(type) && this._defs) {
1162
- const hostShape = this._shapes.find((s) => s.id === boundaryHostId);
1217
+ const hostShape = this._shapeById.get(boundaryHostId);
1163
1218
  if (hostShape) {
1164
1219
  const hostBounds = hostShape.shape.bounds;
1165
1220
  // Snap the event center to the nearest point on the host boundary
@@ -1186,8 +1241,8 @@ export class BpmnEditor {
1186
1241
  this._emit("editor:select", [result.id]);
1187
1242
  }
1188
1243
  _doConnect(srcId, tgtId) {
1189
- const srcShape = this._shapes.find((s) => s.id === srcId);
1190
- const tgtShape = this._shapes.find((s) => s.id === tgtId);
1244
+ const srcShape = this._shapeById.get(srcId);
1245
+ const tgtShape = this._shapeById.get(tgtId);
1191
1246
  if (!srcShape || !tgtShape)
1192
1247
  return;
1193
1248
  const srcType = srcShape.flowElement?.type;
@@ -1227,7 +1282,7 @@ export class BpmnEditor {
1227
1282
  _startLabelEdit(id) {
1228
1283
  if (this._readOnly || !id)
1229
1284
  return;
1230
- const shape = this._shapes.find((s) => s.id === id);
1285
+ const shape = this._shapeById.get(id);
1231
1286
  if (!shape)
1232
1287
  return;
1233
1288
  const defs = this._defs;
@@ -1244,7 +1299,7 @@ export class BpmnEditor {
1244
1299
  const sourceId = this._connectSourceId();
1245
1300
  if (!sourceId)
1246
1301
  return null;
1247
- const shape = this._shapes.find((s) => s.id === sourceId);
1302
+ const shape = this._shapeById.get(sourceId);
1248
1303
  return shape ? shape.shape.bounds : null;
1249
1304
  }
1250
1305
  _connectSourceId() {
@@ -1263,14 +1318,14 @@ export class BpmnEditor {
1263
1318
  return false;
1264
1319
  if (targetId === sourceId)
1265
1320
  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;
1321
+ const srcType = this._shapeById.get(sourceId)?.flowElement?.type;
1322
+ const tgtType = this._shapeById.get(targetId)?.flowElement?.type;
1268
1323
  return srcType !== undefined && tgtType !== undefined && !canConnect(srcType, tgtType);
1269
1324
  }
1270
1325
  // ── New public helpers ─────────────────────────────────────────────
1271
1326
  /** Returns screen-space bounds of a shape (for positioning overlays). */
1272
1327
  getShapeBounds(id) {
1273
- const shape = this._shapes.find((s) => s.id === id);
1328
+ const shape = this._shapeById.get(id);
1274
1329
  if (!shape)
1275
1330
  return null;
1276
1331
  const b = shape.shape.bounds;
@@ -1281,7 +1336,7 @@ export class BpmnEditor {
1281
1336
  }
1282
1337
  /** Returns the BPMN element type for a given id, or null if not found. */
1283
1338
  getElementType(id) {
1284
- const shape = this._shapes.find((s) => s.id === id);
1339
+ const shape = this._shapeById.get(id);
1285
1340
  if (!shape) {
1286
1341
  if (this._edges.some((e) => e.id === id))
1287
1342
  return "sequenceFlow";
@@ -1314,7 +1369,7 @@ export class BpmnEditor {
1314
1369
  addConnectedElement(sourceId, type, name) {
1315
1370
  if (!this._defs)
1316
1371
  return null;
1317
- const srcShape = this._shapes.find((s) => s.id === sourceId);
1372
+ const srcShape = this._shapeById.get(sourceId);
1318
1373
  if (!srcShape)
1319
1374
  return null;
1320
1375
  const srcBounds = srcShape.shape.bounds;
@@ -1354,7 +1409,7 @@ export class BpmnEditor {
1354
1409
  for (const flow of process.sequenceFlows) {
1355
1410
  if (flow.sourceRef !== sourceId)
1356
1411
  continue;
1357
- const tgt = this._shapes.find((s) => s.id === flow.targetRef);
1412
+ const tgt = this._shapeById.get(flow.targetRef);
1358
1413
  if (!tgt)
1359
1414
  continue;
1360
1415
  const tCx = tgt.shape.bounds.x + tgt.shape.bounds.width / 2;
@@ -1434,7 +1489,7 @@ export class BpmnEditor {
1434
1489
  * Sets the external label position for an event or gateway shape.
1435
1490
  */
1436
1491
  setLabelPosition(shapeId, position) {
1437
- const shape = this._shapes.find((s) => s.id === shapeId);
1492
+ const shape = this._shapeById.get(shapeId);
1438
1493
  if (!shape)
1439
1494
  return;
1440
1495
  const labelBounds = labelBoundsForPosition(shape.shape.bounds, position);
@@ -1454,7 +1509,7 @@ export class BpmnEditor {
1454
1509
  * The user then moves the mouse and clicks a target shape to complete the connection.
1455
1510
  */
1456
1511
  startConnectionFrom(sourceId) {
1457
- const shape = this._shapes.find((s) => s.id === sourceId);
1512
+ const shape = this._shapeById.get(sourceId);
1458
1513
  if (!shape)
1459
1514
  return;
1460
1515
  this._viewport.lock(true);
@@ -1467,7 +1522,7 @@ export class BpmnEditor {
1467
1522
  createAnnotationFor(sourceId) {
1468
1523
  if (!this._defs)
1469
1524
  return;
1470
- const srcShape = this._shapes.find((s) => s.id === sourceId);
1525
+ const srcShape = this._shapeById.get(sourceId);
1471
1526
  if (!srcShape)
1472
1527
  return;
1473
1528
  const srcBounds = srcShape.shape.bounds;
@@ -1501,7 +1556,7 @@ export class BpmnEditor {
1501
1556
  }
1502
1557
  this._selectedEdgeId = edgeId;
1503
1558
  if (edgeId) {
1504
- const edge = this._edges.find((e) => e.id === edgeId);
1559
+ const edge = this._edgeById.get(edgeId);
1505
1560
  this._overlay.setEdgeEndpoints(edge?.edge.waypoints ?? null, edgeId);
1506
1561
  this._emit("editor:select", [edgeId]);
1507
1562
  }
@@ -1512,7 +1567,7 @@ export class BpmnEditor {
1512
1567
  }
1513
1568
  /** Changes a flow element's type (e.g. exclusiveGateway → parallelGateway). */
1514
1569
  changeElementType(id, newType) {
1515
- const current = this._shapes.find((s) => s.id === id)?.flowElement?.type;
1570
+ const current = this._shapeById.get(id)?.flowElement?.type;
1516
1571
  if (current && !canMorph(current, newType))
1517
1572
  return;
1518
1573
  this._executeCommand((d) => changeElementTypeFn(d, id, newType), "Change type");
@@ -1523,7 +1578,7 @@ export class BpmnEditor {
1523
1578
  const id = this._selectedIds[0];
1524
1579
  if (!id || !this._defs)
1525
1580
  return null;
1526
- const shape = this._shapes.find((s) => s.id === id);
1581
+ const shape = this._shapeById.get(id);
1527
1582
  if (!shape)
1528
1583
  return null;
1529
1584
  const b = shape.shape.bounds;
@@ -1534,7 +1589,7 @@ export class BpmnEditor {
1534
1589
  return null;
1535
1590
  const TOLERANCE = 20;
1536
1591
  for (const edge of this._edges) {
1537
- const flow = process.sequenceFlows.find((sf) => sf.id === edge.id);
1592
+ const flow = this._flowById.get(edge.id);
1538
1593
  if (!flow)
1539
1594
  continue;
1540
1595
  // Skip edges that are already connected to the shape being moved
@@ -1559,19 +1614,19 @@ export class BpmnEditor {
1559
1614
  }
1560
1615
  _setEdgeDropHighlight(edgeId) {
1561
1616
  if (this._edgeDropTarget) {
1562
- const prev = this._edges.find((e) => e.id === this._edgeDropTarget);
1617
+ const prev = this._edgeById.get(this._edgeDropTarget);
1563
1618
  prev?.element.classList.remove("bpmnkit-edge-split-highlight");
1564
1619
  }
1565
1620
  this._edgeDropTarget = edgeId;
1566
1621
  if (edgeId) {
1567
- const edge = this._edges.find((e) => e.id === edgeId);
1622
+ const edge = this._edgeById.get(edgeId);
1568
1623
  edge?.element.classList.add("bpmnkit-edge-split-highlight");
1569
1624
  }
1570
1625
  }
1571
1626
  _previewEndpointMove(edgeId, isStart, diagPoint) {
1572
1627
  if (!this._defs)
1573
1628
  return;
1574
- const edge = this._edges.find((e) => e.id === edgeId);
1629
+ const edge = this._edgeById.get(edgeId);
1575
1630
  if (!edge)
1576
1631
  return;
1577
1632
  const flow = this._defs.processes[0]?.sequenceFlows.find((sf) => sf.id === edgeId);
@@ -1604,7 +1659,7 @@ export class BpmnEditor {
1604
1659
  if (!this._defs)
1605
1660
  return;
1606
1661
  this._overlay.setEndpointDragGhost(null);
1607
- const edge = this._edges.find((e) => e.id === edgeId);
1662
+ const edge = this._edgeById.get(edgeId);
1608
1663
  if (!edge)
1609
1664
  return;
1610
1665
  const flow = this._defs.processes[0]?.sequenceFlows.find((sf) => sf.id === edgeId);
@@ -1623,7 +1678,7 @@ export class BpmnEditor {
1623
1678
  this._executeCommand((d) => updateEdgeEndpoint(d, edgeId, isStart, newPort), "Reconnect");
1624
1679
  }
1625
1680
  _isResizable(id) {
1626
- const shape = this._shapes.find((s) => s.id === id);
1681
+ const shape = this._shapeById.get(id);
1627
1682
  if (!shape)
1628
1683
  return false;
1629
1684
  if (shape.annotation !== undefined)
@@ -1743,81 +1798,98 @@ export class BpmnEditor {
1743
1798
  const moving = movingShape.shape.bounds;
1744
1799
  const scale = this._viewport.state.scale;
1745
1800
  const threshold = 8 / scale;
1746
- let bestDx = dx;
1747
- let bestDy = dy;
1748
- let minDistX = threshold;
1749
- let minDistY = threshold;
1750
- const hGuides = [];
1751
- const vGuides = [];
1801
+ // Every candidate position derives from a pair of static shapes and is
1802
+ // independent of the pointer, so the O(S²) pair scan runs once per drag
1803
+ // and each move is a binary search over the sorted candidates.
1804
+ const index = this._spacingCandidates(staticShapes);
1805
+ const mx = moving.x + dx;
1806
+ const my = moving.y + dy;
1752
1807
  const mCy = moving.y + dy + moving.height / 2;
1753
1808
  const mCx = moving.x + dx + moving.width / 2;
1754
- // Horizontal spacing: for each pair (A, B) where B is to the right of A
1755
- for (const A of staticShapes) {
1756
- const aRight = A.shape.bounds.x + A.shape.bounds.width;
1757
- for (const B of staticShapes) {
1758
- if (A.id === B.id)
1759
- continue;
1760
- const bLeft = B.shape.bounds.x;
1761
- if (bLeft <= aRight)
1762
- continue;
1763
- const gap = bLeft - aRight;
1764
- // Candidate: moving is to the right of B by the same gap
1765
- const bRight = B.shape.bounds.x + B.shape.bounds.width;
1766
- const candLeft = bRight + gap;
1767
- const distX = Math.abs(moving.x + dx - candLeft);
1768
- if (distX < minDistX) {
1769
- minDistX = distX;
1770
- bestDx = dx + (candLeft - (moving.x + dx));
1771
- hGuides.length = 0;
1772
- hGuides.push({ x1: aRight, y1: mCy, x2: bLeft, y2: mCy }, { x1: bRight, y1: mCy, x2: candLeft, y2: mCy });
1773
- }
1774
- // Candidate: moving is to the left of A by the same gap
1775
- const aLeft = A.shape.bounds.x;
1776
- const candRight = aLeft - gap;
1777
- const movRight = moving.x + dx + moving.width;
1778
- const distX2 = Math.abs(movRight - candRight);
1779
- if (distX2 < minDistX) {
1780
- minDistX = distX2;
1781
- bestDx = dx + (candRight - movRight);
1782
- hGuides.length = 0;
1783
- hGuides.push({ x1: candRight, y1: mCy, x2: aLeft, y2: mCy }, { x1: aRight, y1: mCy, x2: bLeft, y2: mCy });
1784
- }
1809
+ const guides = [];
1810
+ let bestDx = dx;
1811
+ let bestDy = dy;
1812
+ const bestX = nearestCandidate([
1813
+ { list: index.h0, target: mx },
1814
+ { list: index.h1, target: mx + moving.width },
1815
+ ], threshold);
1816
+ if (bestX) {
1817
+ const { entry, target } = bestX;
1818
+ bestDx = dx + (entry.value - target);
1819
+ if (entry.kind === 0) {
1820
+ guides.push({ x1: entry.a, y1: mCy, x2: entry.b, y2: mCy }, { x1: entry.c, y1: mCy, x2: entry.value, y2: mCy });
1821
+ }
1822
+ else {
1823
+ guides.push({ x1: entry.value, y1: mCy, x2: entry.c, y2: mCy }, { x1: entry.a, y1: mCy, x2: entry.b, y2: mCy });
1785
1824
  }
1786
1825
  }
1787
- // Vertical spacing: for each pair (A, B) where B is below A
1788
- for (const A of staticShapes) {
1789
- const aBottom = A.shape.bounds.y + A.shape.bounds.height;
1790
- for (const B of staticShapes) {
1791
- if (A.id === B.id)
1792
- continue;
1793
- const bTop = B.shape.bounds.y;
1794
- if (bTop <= aBottom)
1826
+ const bestY = nearestCandidate([
1827
+ { list: index.v0, target: my },
1828
+ { list: index.v1, target: my + moving.height },
1829
+ ], threshold);
1830
+ if (bestY) {
1831
+ const { entry, target } = bestY;
1832
+ bestDy = dy + (entry.value - target);
1833
+ if (entry.kind === 0) {
1834
+ guides.push({ x1: mCx, y1: entry.a, x2: mCx, y2: entry.b }, { x1: mCx, y1: entry.c, x2: mCx, y2: entry.value });
1835
+ }
1836
+ else {
1837
+ guides.push({ x1: mCx, y1: entry.value, x2: mCx, y2: entry.c }, { x1: mCx, y1: entry.a, x2: mCx, y2: entry.b });
1838
+ }
1839
+ }
1840
+ return { dx: bestDx, dy: bestDy, guides };
1841
+ }
1842
+ /**
1843
+ * Candidate snap positions from every ordered pair (A, B) of static shapes
1844
+ * where B lies right of / below A: the moving shape may sit right of B or
1845
+ * left of A (below B / above A) at the same gap. Cached for the drag: it is
1846
+ * rebuilt only when the rendered shapes or the selection change.
1847
+ */
1848
+ _spacingCandidates(staticShapes) {
1849
+ const key = this._selectedIds.join("\0");
1850
+ const cached = this._spacingIndex;
1851
+ if (cached && cached.shapes === this._shapes && cached.key === key)
1852
+ return cached;
1853
+ const h = [];
1854
+ const v = [];
1855
+ const count = staticShapes.length;
1856
+ for (let ia = 0; ia < count; ia++) {
1857
+ const A = staticShapes[ia].shape.bounds;
1858
+ const aRight = A.x + A.width;
1859
+ const aBottom = A.y + A.height;
1860
+ for (let ib = 0; ib < count; ib++) {
1861
+ if (ia === ib)
1795
1862
  continue;
1796
- const gap = bTop - aBottom;
1797
- // Candidate: moving is below B by the same gap
1798
- const bBottom = B.shape.bounds.y + B.shape.bounds.height;
1799
- const candTop = bBottom + gap;
1800
- const distY = Math.abs(moving.y + dy - candTop);
1801
- if (distY < minDistY) {
1802
- minDistY = distY;
1803
- bestDy = dy + (candTop - (moving.y + dy));
1804
- vGuides.length = 0;
1805
- vGuides.push({ x1: mCx, y1: aBottom, x2: mCx, y2: bTop }, { x1: mCx, y1: bBottom, x2: mCx, y2: candTop });
1863
+ const B = staticShapes[ib].shape.bounds;
1864
+ const ord = (ia * count + ib) * 2;
1865
+ const bLeft = B.x;
1866
+ if (bLeft > aRight) {
1867
+ const gap = bLeft - aRight;
1868
+ const bRight = B.x + B.width;
1869
+ // kind 0: moving sits right of B; a→b is the reference gap, c→value the new one.
1870
+ h.push({ kind: 0, value: bRight + gap, ord, a: aRight, b: bLeft, c: bRight });
1871
+ // kind 1: moving sits left of A; value→c is the new gap, a→b the reference.
1872
+ h.push({ kind: 1, value: A.x - gap, ord: ord + 1, a: aRight, b: bLeft, c: A.x });
1806
1873
  }
1807
- // Candidate: moving is above A by the same gap
1808
- const aTop = A.shape.bounds.y;
1809
- const candBottom = aTop - gap;
1810
- const movBottom = moving.y + dy + moving.height;
1811
- const distY2 = Math.abs(movBottom - candBottom);
1812
- if (distY2 < minDistY) {
1813
- minDistY = distY2;
1814
- bestDy = dy + (candBottom - movBottom);
1815
- vGuides.length = 0;
1816
- vGuides.push({ x1: mCx, y1: candBottom, x2: mCx, y2: aTop }, { x1: mCx, y1: aBottom, x2: mCx, y2: bTop });
1874
+ const bTop = B.y;
1875
+ if (bTop > aBottom) {
1876
+ const gap = bTop - aBottom;
1877
+ const bBottom = B.y + B.height;
1878
+ v.push({ kind: 0, value: bBottom + gap, ord, a: aBottom, b: bTop, c: bBottom });
1879
+ v.push({ kind: 1, value: A.y - gap, ord: ord + 1, a: aBottom, b: bTop, c: A.y });
1817
1880
  }
1818
1881
  }
1819
1882
  }
1820
- return { dx: bestDx, dy: bestDy, guides: [...hGuides, ...vGuides] };
1883
+ const index = {
1884
+ shapes: this._shapes,
1885
+ key,
1886
+ h0: sortCandidates(h.filter((e) => e.kind === 0)),
1887
+ h1: sortCandidates(h.filter((e) => e.kind === 1)),
1888
+ v0: sortCandidates(v.filter((e) => e.kind === 0)),
1889
+ v1: sortCandidates(v.filter((e) => e.kind === 1)),
1890
+ };
1891
+ this._spacingIndex = index;
1892
+ return index;
1821
1893
  }
1822
1894
  // ── Create-mode helpers ────────────────────────────────────────────
1823
1895
  _computeCreateSnap(bounds) {
@@ -1892,7 +1964,7 @@ export class BpmnEditor {
1892
1964
  return null;
1893
1965
  const TOLERANCE = 20;
1894
1966
  for (const edge of this._edges) {
1895
- const flow = process.sequenceFlows.find((sf) => sf.id === edge.id);
1967
+ const flow = this._flowById.get(edge.id);
1896
1968
  if (!flow)
1897
1969
  continue;
1898
1970
  const wps = edge.edge.waypoints;
@@ -1916,7 +1988,7 @@ export class BpmnEditor {
1916
1988
  return;
1917
1989
  this._boundaryHostId = shapeId;
1918
1990
  if (shapeId) {
1919
- const shape = this._shapes.find((s) => s.id === shapeId);
1991
+ const shape = this._shapeById.get(shapeId);
1920
1992
  this._overlay.setBoundaryHostHighlight(shape ? shape.shape.bounds : null);
1921
1993
  }
1922
1994
  else {
@@ -1927,12 +1999,12 @@ export class BpmnEditor {
1927
1999
  if (this._createEdgeDropTarget === edgeId)
1928
2000
  return;
1929
2001
  if (this._createEdgeDropTarget) {
1930
- const prev = this._edges.find((e) => e.id === this._createEdgeDropTarget);
2002
+ const prev = this._edgeById.get(this._createEdgeDropTarget);
1931
2003
  prev?.element.classList.remove("bpmnkit-edge-split-highlight");
1932
2004
  }
1933
2005
  this._createEdgeDropTarget = edgeId;
1934
2006
  if (edgeId) {
1935
- const edge = this._edges.find((e) => e.id === edgeId);
2007
+ const edge = this._edgeById.get(edgeId);
1936
2008
  edge?.element.classList.add("bpmnkit-edge-split-highlight");
1937
2009
  }
1938
2010
  }
@@ -1964,7 +2036,7 @@ export class BpmnEditor {
1964
2036
  this._setCreateEdgeDropHighlight(this._findCreateEdgeDrop(snapped));
1965
2037
  // Detect boundary event attachment target for intermediate event types
1966
2038
  if (isIntermediateEventType(mode.elementType) && hit.type === "shape") {
1967
- const shape = this._shapes.find((s) => s.id === hit.id);
2039
+ const shape = this._shapeById.get(hit.id);
1968
2040
  if (shape?.flowElement && canAttach(shape.flowElement.type)) {
1969
2041
  this._setBoundaryHost(hit.id);
1970
2042
  }
@@ -2234,8 +2306,7 @@ export class BpmnEditor {
2234
2306
  }
2235
2307
  /** Finds the SVG group for a shape or edge by BPMN id. */
2236
2308
  _elementById(id) {
2237
- return (this._shapes.find((s) => s.id === id)?.element ??
2238
- this._edges.find((e) => e.id === id)?.element);
2309
+ return this._shapeById.get(id)?.element ?? this._edgeById.get(id)?.element;
2239
2310
  }
2240
2311
  /** Element bounding box in screen pixels relative to the host, or `null`. */
2241
2312
  _absoluteBBox(id) {
@@ -2252,12 +2323,12 @@ export class BpmnEditor {
2252
2323
  }
2253
2324
  /** Diagram-coordinate bounds of a shape (from DI) or edge (waypoint bbox). */
2254
2325
  _boundsById(id) {
2255
- const shape = this._shapes.find((s) => s.id === id);
2326
+ const shape = this._shapeById.get(id);
2256
2327
  if (shape) {
2257
2328
  const { x, y, width, height } = shape.shape.bounds;
2258
2329
  return { x, y, width, height };
2259
2330
  }
2260
- const edge = this._edges.find((e) => e.id === id);
2331
+ const edge = this._edgeById.get(id);
2261
2332
  if (edge && edge.edge.waypoints.length > 0) {
2262
2333
  const xs = edge.edge.waypoints.map((w) => w.x);
2263
2334
  const ys = edge.edge.waypoints.map((w) => w.y);
package/dist/overlay.js CHANGED
@@ -67,7 +67,8 @@ export class OverlayRenderer {
67
67
  // ── Selection + handles ───────────────────────────────────────────
68
68
  setSelection(ids, shapes, resizableIds) {
69
69
  this._selectionG.innerHTML = "";
70
- const selected = shapes.filter((s) => ids.includes(s.id));
70
+ const idSet = new Set(ids);
71
+ const selected = shapes.filter((s) => idSet.has(s.id));
71
72
  if (selected.length === 0)
72
73
  return;
73
74
  const isSingle = selected.length === 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bpmnkit/editor",
3
- "version": "0.0.33",
3
+ "version": "0.0.34",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -17,8 +17,8 @@
17
17
  "dist/**/*.d.ts"
18
18
  ],
19
19
  "dependencies": {
20
- "@bpmnkit/canvas": "0.0.30",
21
- "@bpmnkit/core": "0.1.2"
20
+ "@bpmnkit/canvas": "0.0.31",
21
+ "@bpmnkit/core": "0.2.0"
22
22
  },
23
23
  "description": "Full-featured interactive BPMN editor with undo/redo, HUD, and side-dock UI",
24
24
  "keywords": [