@unofficialbox/box-open-elements 0.31.1 → 0.32.0

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.
@@ -74,7 +74,9 @@ export class ProcessModeler extends BaseElement {
74
74
  activeDrawer;
75
75
  drawerReturn;
76
76
  selectedIds = new Set();
77
- clipboardIds = [];
77
+ clipboard = null;
78
+ clipboardSession = 0;
79
+ restoringBoxFocus = false;
78
80
  selectionToolbarKey = "";
79
81
  sectionSequence = 0;
80
82
  versionValue = 0;
@@ -99,6 +101,14 @@ export class ProcessModeler extends BaseElement {
99
101
  spacePressed = false;
100
102
  dropLine;
101
103
  paletteDragKind = null;
104
+ palettePointer;
105
+ suppressPaletteClick = false;
106
+ escapePalettePointer = (event) => {
107
+ if (event.key !== 'Escape' || !this.palettePointer)
108
+ return;
109
+ this.cancelPalettePointer();
110
+ event.preventDefault();
111
+ };
102
112
  narrowValue = false;
103
113
  suppressClick = false;
104
114
  resizeObserver;
@@ -157,6 +167,7 @@ export class ProcessModeler extends BaseElement {
157
167
  };
158
168
  }
159
169
  load(document, options = {}) {
170
+ this.cancelPalettePointer();
160
171
  const projection = this.model.project(document);
161
172
  validateProjection(projection);
162
173
  this.documentValue = document;
@@ -170,7 +181,7 @@ export class ProcessModeler extends BaseElement {
170
181
  this.selectedId = null;
171
182
  this.selectedLineId = null;
172
183
  this.selectedIds.clear();
173
- this.clipboardIds = [];
184
+ this.clearClipboard();
174
185
  this.refresh();
175
186
  if (options.selectedPath)
176
187
  this.selectedPath = options.selectedPath;
@@ -259,6 +270,8 @@ export class ProcessModeler extends BaseElement {
259
270
  }
260
271
  set locked(value) {
261
272
  this.toggleAttribute("locked", value);
273
+ if (value)
274
+ this.cancelPalettePointer();
262
275
  }
263
276
  get snapToGrid() {
264
277
  return this.hasAttribute("snap-to-grid");
@@ -281,6 +294,8 @@ export class ProcessModeler extends BaseElement {
281
294
  }
282
295
  disconnectedCallback() {
283
296
  this.resizeObserver?.disconnect();
297
+ this.clearClipboard();
298
+ this.cancelPalettePointer();
284
299
  this.closeDrawer();
285
300
  const chooser = this.shadowRoot?.querySelector("[part=insert-chooser]");
286
301
  if (chooser)
@@ -341,56 +356,85 @@ export class ProcessModeler extends BaseElement {
341
356
  setValidationAtPath(message, path) {
342
357
  this.setValidation(message ? [{ message, path }] : []);
343
358
  }
359
+ connectionProblem(edit) {
360
+ if (!this.model.connectionProblem || this.documentValue === undefined || !['connect', 'reattach'].includes(edit.type))
361
+ return null;
362
+ if (!this.isRendered) {
363
+ this.projection = this.model.project(this.documentValue);
364
+ validateProjection(this.projection);
365
+ }
366
+ const line = edit.type === 'reattach' ? this.projection.lines.find(line => line.id === edit.lineId) : undefined;
367
+ const from = edit.from ?? line?.from;
368
+ const to = edit.to ?? line?.to;
369
+ if (!from || !to)
370
+ return null;
371
+ return this.model.connectionProblem(this.documentValue, { from, to, ...(line ? { ignoreLineId: line.id } : {}) }, this.projection) || null;
372
+ }
344
373
  requestEdit(edit) {
345
374
  if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect" || edit.type === 'reattach')))
346
375
  return;
376
+ const problem = this.connectionProblem(edit);
377
+ if (problem) {
378
+ this.setStatus(problem, true);
379
+ return;
380
+ }
381
+ const request = { ...edit, accept: this.editAcceptance(edit) };
382
+ emit(this, "process-edit-request", request);
383
+ }
384
+ editAcceptance(edit) {
347
385
  const before = this.layout;
386
+ const beforeSelection = [...this.selectedIds];
348
387
  const previousIds = new Set(this.projection.boxes.map((box) => box.id));
349
388
  let accepted = false;
350
- const request = {
351
- ...edit,
352
- accept: (command) => {
353
- if (accepted)
354
- return;
355
- accepted = true;
356
- if (command.layout)
357
- this.layoutValue = structuredClone(command.layout);
358
- this.refresh();
359
- const inserted = edit.type === 'insert' && !edit.boxId && !command.layout
360
- ? this.projection.boxes.find(box => !previousIds.has(box.id)) : undefined;
361
- const reflowed = inserted ? this.reflowStraightInsert(edit, inserted.id, before) : false;
362
- if (!reflowed &&
363
- !command.layout &&
364
- edit.position &&
365
- Number.isFinite(edit.position.x) &&
366
- Number.isFinite(edit.position.y)) {
367
- const added = this.projection.boxes.find(box => box.id === edit.boxId) ?? this.projection.boxes.find((box) => !previousIds.has(box.id));
368
- if (added)
369
- this.layoutValue = this.translatedLayout(added.id, edit.position.x, edit.position.y);
370
- }
371
- const after = this.layout;
372
- const restore = (layout) => {
373
- this.layoutValue = structuredClone(layout);
374
- this.refresh();
375
- this.emitPositions();
376
- };
377
- this.history.record({
378
- undo: () => {
379
- command.undo();
380
- restore(before);
381
- },
382
- redo: () => {
383
- command.redo();
384
- restore(after);
385
- },
386
- });
389
+ return (command) => {
390
+ if (accepted)
391
+ return;
392
+ accepted = true;
393
+ if (command.layout)
394
+ this.layoutValue = structuredClone(command.layout);
395
+ if (edit.type === 'reset-line' && edit.lineId)
396
+ delete this.layoutValue.lines?.[edit.lineId];
397
+ this.refresh();
398
+ const inserted = edit.type === 'insert' && !edit.boxId && !command.layout
399
+ ? this.projection.boxes.find(box => !previousIds.has(box.id)) : undefined;
400
+ const reflowed = inserted ? this.reflowStraightInsert(edit, inserted.id, before) : false;
401
+ if (!reflowed &&
402
+ !command.layout &&
403
+ edit.position &&
404
+ Number.isFinite(edit.position.x) &&
405
+ Number.isFinite(edit.position.y)) {
406
+ const added = this.projection.boxes.find(box => box.id === edit.boxId) ?? this.projection.boxes.find((box) => !previousIds.has(box.id));
407
+ if (added)
408
+ this.layoutValue = this.translatedLayout(added.id, edit.position.x, edit.position.y);
409
+ }
410
+ const after = this.layout;
411
+ const acceptedSelection = command.selectionIds ? [...command.selectionIds] : undefined;
412
+ const restore = (layout) => {
413
+ this.layoutValue = structuredClone(layout);
387
414
  this.refresh();
388
- if (JSON.stringify(before) !== JSON.stringify(after))
389
- this.emitPositions();
390
- announce("Process updated", "polite", this.ownerDocument);
391
- },
415
+ this.emitPositions();
416
+ };
417
+ this.history.record({
418
+ undo: () => {
419
+ command.undo();
420
+ restore(before);
421
+ if (acceptedSelection)
422
+ this.selectMany(beforeSelection);
423
+ },
424
+ redo: () => {
425
+ command.redo();
426
+ restore(after);
427
+ if (acceptedSelection)
428
+ this.selectMany(acceptedSelection);
429
+ },
430
+ });
431
+ if (acceptedSelection)
432
+ this.selectMany(acceptedSelection);
433
+ this.refresh();
434
+ if (JSON.stringify(before) !== JSON.stringify(after))
435
+ this.emitPositions();
436
+ announce("Process updated", "polite", this.ownerDocument);
392
437
  };
393
- emit(this, "process-edit-request", request);
394
438
  }
395
439
  undo() {
396
440
  if (this.locked)
@@ -541,6 +585,13 @@ export class ProcessModeler extends BaseElement {
541
585
  });
542
586
  }
543
587
  resetLine(id) {
588
+ const line = this.projection.lines.find(line => line.id === id);
589
+ if (this.locked || !line)
590
+ return;
591
+ if (line.fromSide || line.toSide || line.points?.length) {
592
+ this.requestEdit({ type: 'reset-line', lineId: id });
593
+ return;
594
+ }
544
595
  if (!this.layoutValue.lines?.[id])
545
596
  return;
546
597
  const next = this.layout;
@@ -763,7 +814,7 @@ export class ProcessModeler extends BaseElement {
763
814
  palette.prepend(skip, paletteHeading);
764
815
  const hint = document.createElement("p");
765
816
  hint.setAttribute("part", "palette-hint");
766
- hint.textContent = "Drag onto the canvas, a connection, or a frame. Select a step first to add after it.";
817
+ hint.textContent = "Drag onto the canvas, onto a line to insert, or into a frame. Choosing one with a step selected adds it after that step. Or click a dot beside any step to add one that way.";
767
818
  palette.append(hint);
768
819
  for (const [part, label] of [["palette", "Building blocks"], ["inspector", "Process details"]]) {
769
820
  const pane = root.querySelector(`[part=${part}]`);
@@ -864,6 +915,12 @@ export class ProcessModeler extends BaseElement {
864
915
  controls.setAttribute("role", "toolbar");
865
916
  controls.setAttribute("aria-label", "Canvas view controls");
866
917
  for (const command of ["zoom-out", "reset", "zoom-in", "fit", "snap", "lock"]) {
918
+ if (command === 'snap') {
919
+ const divider = document.createElement('span');
920
+ divider.setAttribute('part', 'controls-divider');
921
+ divider.setAttribute('aria-hidden', 'true');
922
+ controls.append(divider);
923
+ }
867
924
  const button = root.querySelector(`[data-command=${command}]`);
868
925
  if (button)
869
926
  controls.append(button);
@@ -896,6 +953,7 @@ export class ProcessModeler extends BaseElement {
896
953
  this.narrowValue = narrow;
897
954
  this.toggleAttribute("data-narrow", narrow);
898
955
  this.toggleAttribute('data-phone', entries[0].contentRect.width < 560);
956
+ this.toggleAttribute('data-hide-tidy', entries[0].contentRect.width <= 560);
899
957
  this.renderSelectionToolbar();
900
958
  this.positionSelectionToolbar();
901
959
  if (!narrow)
@@ -1152,6 +1210,12 @@ export class ProcessModeler extends BaseElement {
1152
1210
  emit(this, 'process-variable-edit-request', edit);
1153
1211
  }
1154
1212
  }
1213
+ selectionDimensions(box) {
1214
+ const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
1215
+ const compact = shape === 'gateway' || shape === 'event';
1216
+ const position = this.layoutValue.boxes[box.id];
1217
+ return { width: position.width ?? (compact ? 56 : 224), height: position.height ?? (compact ? 56 : 64) };
1218
+ }
1155
1219
  selectionCommand(command) {
1156
1220
  if (this.locked)
1157
1221
  return;
@@ -1165,7 +1229,10 @@ export class ProcessModeler extends BaseElement {
1165
1229
  this.tidySelection();
1166
1230
  return;
1167
1231
  }
1168
- this.arrangeSelection('top');
1232
+ const rectangles = boxes.map(box => ({ ...this.layoutValue.boxes[box.id], ...this.selectionDimensions(box) }));
1233
+ const width = Math.max(...rectangles.map(r => r.x + r.width)) - Math.min(...rectangles.map(r => r.x));
1234
+ const height = Math.max(...rectangles.map(r => r.y + r.height)) - Math.min(...rectangles.map(r => r.y));
1235
+ this.arrangeSelection(width >= height ? 'middle' : 'center');
1169
1236
  }
1170
1237
  tidySelection() {
1171
1238
  if (this.locked || this.selectedBoxes.length < 2)
@@ -1234,20 +1301,62 @@ export class ProcessModeler extends BaseElement {
1234
1301
  });
1235
1302
  if (sorted.length < 2)
1236
1303
  return;
1237
- const width = (box) => next.boxes[box.id].width ?? (box.shape === 'gateway' || box.shape === 'event' ? 56 : 224);
1238
- const height = (box) => next.boxes[box.id].height ?? (box.shape === 'gateway' || box.shape === 'event' ? 56 : 64);
1304
+ const width = (box) => this.selectionDimensions(box).width;
1305
+ const height = (box) => this.selectionDimensions(box).height;
1239
1306
  const minX = Math.min(...sorted.map(box => next.boxes[box.id].x));
1240
1307
  const maxX = Math.max(...sorted.map(box => next.boxes[box.id].x + width(box)));
1241
1308
  const minY = Math.min(...sorted.map(box => next.boxes[box.id].y));
1242
1309
  const maxY = Math.max(...sorted.map(box => next.boxes[box.id].y + height(box)));
1243
- const ordered = [...sorted].sort((a, b) => action === 'distribute-vertical' ? next.boxes[a.id].y - next.boxes[b.id].y : next.boxes[a.id].x - next.boxes[b.id].x);
1244
- const starts = ordered.map(box => action === 'distribute-vertical' ? next.boxes[box.id].y : next.boxes[box.id].x);
1310
+ const snap = (value) => Math.round(value / 16) * 16;
1311
+ const targets = new Map(sorted.map(box => [box.id, { x: next.boxes[box.id].x, y: next.boxes[box.id].y }]));
1312
+ const vertical = action === 'distribute-vertical';
1313
+ if (action === 'distribute-horizontal' || vertical) {
1314
+ if (sorted.length < 3)
1315
+ return;
1316
+ const axis = vertical ? 'y' : 'x';
1317
+ const size = vertical ? height : width;
1318
+ const ordered = [...sorted].sort((a, b) => targets.get(a.id)[axis] - targets.get(b.id)[axis]);
1319
+ const first = targets.get(ordered[0].id)[axis];
1320
+ const last = targets.get(ordered.at(-1).id)[axis] + size(ordered.at(-1));
1321
+ const gap = (last - first - ordered.reduce((total, box) => total + size(box), 0)) / (ordered.length - 1);
1322
+ let position = first + size(ordered[0]) + gap;
1323
+ for (const box of ordered.slice(1, -1)) {
1324
+ targets.get(box.id)[axis] = position;
1325
+ position += size(box) + gap;
1326
+ }
1327
+ }
1328
+ else {
1329
+ for (const box of sorted) {
1330
+ const target = targets.get(box.id);
1331
+ if (action === 'left')
1332
+ target.x = minX;
1333
+ if (action === 'center')
1334
+ target.x = snap((minX + maxX) / 2) - width(box) / 2;
1335
+ if (action === 'right')
1336
+ target.x = maxX - width(box);
1337
+ if (action === 'top')
1338
+ target.y = minY;
1339
+ if (action === 'middle')
1340
+ target.y = snap((minY + maxY) / 2) - height(box) / 2;
1341
+ if (action === 'bottom')
1342
+ target.y = maxY - height(box);
1343
+ }
1344
+ if (action === 'middle' || action === 'center') {
1345
+ const axis = action === 'middle' ? 'x' : 'y';
1346
+ const size = action === 'middle' ? width : height;
1347
+ const ordered = [...sorted].sort((a, b) => targets.get(a.id)[axis] - targets.get(b.id)[axis]);
1348
+ for (let index = 1; index < ordered.length; index++) {
1349
+ const previous = ordered[index - 1], box = ordered[index];
1350
+ const minimum = targets.get(previous.id)[axis] + size(previous) + 48;
1351
+ const target = targets.get(box.id);
1352
+ if (target[axis] < minimum)
1353
+ target[axis] += snap(minimum - target[axis] + 8);
1354
+ }
1355
+ }
1356
+ }
1245
1357
  for (const box of sorted) {
1246
- const position = next.boxes[box.id];
1247
- const index = ordered.indexOf(box);
1248
- const x = action === 'left' ? minX : action === 'center' ? (minX + maxX - width(box)) / 2 : action === 'right' ? maxX - width(box) : action === 'distribute-horizontal' ? starts[0] + index * (starts.at(-1) - starts[0]) / (ordered.length - 1) : position.x;
1249
- const y = action === 'top' ? minY : action === 'middle' ? (minY + maxY - height(box)) / 2 : action === 'bottom' ? maxY - height(box) : action === 'distribute-vertical' ? starts[0] + index * (starts.at(-1) - starts[0]) / (ordered.length - 1) : position.y;
1250
- const dx = x - position.x, dy = y - position.y;
1358
+ const position = next.boxes[box.id], target = targets.get(box.id);
1359
+ const dx = target.x - position.x, dy = target.y - position.y;
1251
1360
  const group = new Set([box.id]);
1252
1361
  for (let changed = true; changed;) {
1253
1362
  changed = false;
@@ -1275,6 +1384,16 @@ export class ProcessModeler extends BaseElement {
1275
1384
  this.setSection({ id, title, x, y, width: right - x, height: bottom - y });
1276
1385
  }
1277
1386
  setupListeners() {
1387
+ this.addEventListener('pointermove', event => this.movePalettePointer(event));
1388
+ this.addEventListener('pointerup', event => this.finishPalettePointer(event));
1389
+ this.addEventListener('pointercancel', event => { if (event.pointerId === this.palettePointer?.id)
1390
+ this.cancelPalettePointer(); });
1391
+ this.addEventListener('lostpointercapture', event => { if (event.pointerId === this.palettePointer?.id)
1392
+ this.cancelPalettePointer(); });
1393
+ this.shadowRoot.addEventListener('keydown', event => { if (event.key === 'Escape' && this.palettePointer) {
1394
+ this.cancelPalettePointer();
1395
+ event.preventDefault();
1396
+ } }, true);
1278
1397
  this.shadowRoot.addEventListener("click", (event) => {
1279
1398
  const command = event.target.closest("button")?.dataset.command;
1280
1399
  if (command === "zoom-in")
@@ -1404,6 +1523,92 @@ export class ProcessModeler extends BaseElement {
1404
1523
  this.paintViewport();
1405
1524
  });
1406
1525
  }
1526
+ clearClipboard() {
1527
+ const previous = this.clipboard;
1528
+ this.clipboard = null;
1529
+ this.clipboardSession++;
1530
+ previous?.dispose?.();
1531
+ }
1532
+ copySelection() {
1533
+ this.clearClipboard();
1534
+ const session = this.clipboardSession;
1535
+ let active = true;
1536
+ let claimed = false;
1537
+ const request = {
1538
+ sourceIds: Object.freeze([...this.selectedIds]),
1539
+ capture: clipboard => {
1540
+ if (!active || claimed || session !== this.clipboardSession || !this.isConnected)
1541
+ return;
1542
+ claimed = true;
1543
+ if (!Number.isInteger(clipboard?.itemCount) || clipboard.itemCount < 1 || typeof clipboard.paste !== 'function' || (clipboard.dispose !== undefined && typeof clipboard.dispose !== 'function')) {
1544
+ this.setStatus('The host did not supply a valid clipboard');
1545
+ return;
1546
+ }
1547
+ this.clipboard = Object.freeze({ itemCount: clipboard.itemCount, paste: clipboard.paste.bind(clipboard), ...(clipboard.dispose ? { dispose: clipboard.dispose.bind(clipboard) } : {}) });
1548
+ this.setStatus(`${clipboard.itemCount} steps copied`);
1549
+ },
1550
+ refuse: message => { if (!active || claimed || session !== this.clipboardSession || !this.isConnected)
1551
+ return; claimed = true; this.setStatus(message ?? 'Copy is not supported by this host'); },
1552
+ };
1553
+ try {
1554
+ emit(this, 'process-copy-request', request);
1555
+ }
1556
+ finally {
1557
+ active = false;
1558
+ }
1559
+ if (!claimed && session === this.clipboardSession && this.isConnected)
1560
+ this.setStatus('Copy is not supported by this host');
1561
+ return claimed;
1562
+ }
1563
+ pasteClipboard() {
1564
+ const clipboard = this.clipboard;
1565
+ if (!clipboard || this.locked)
1566
+ return;
1567
+ const session = this.clipboardSession;
1568
+ const accept = this.editAcceptance({ type: 'duplicate' });
1569
+ let active = true;
1570
+ let settled = false;
1571
+ const request = {
1572
+ offset: Object.freeze({ x: 32, y: 32 }),
1573
+ accept: command => {
1574
+ if (!active || settled || session !== this.clipboardSession || this.locked)
1575
+ return;
1576
+ settled = true;
1577
+ accept(command);
1578
+ },
1579
+ refuse: message => { if (!active || settled || session !== this.clipboardSession || !this.isConnected)
1580
+ return; settled = true; this.setStatus(message ?? 'Paste was refused by this host'); },
1581
+ };
1582
+ try {
1583
+ clipboard.paste(request);
1584
+ }
1585
+ finally {
1586
+ active = false;
1587
+ }
1588
+ if (!settled && session === this.clipboardSession)
1589
+ this.setStatus('Paste was not accepted by this host');
1590
+ }
1591
+ duplicateBoxes(ids, distance) {
1592
+ const selected = new Set(ids);
1593
+ if (!selected.size || [...selected].some(id => !this.projection.boxes.some(box => box.id === id) || !this.layoutValue.boxes[id]))
1594
+ return;
1595
+ const sources = [...selected].filter(id => {
1596
+ let parent = this.projection.boxes.find(box => box.id === id)?.parentId;
1597
+ while (parent) {
1598
+ if (selected.has(parent))
1599
+ return false;
1600
+ parent = this.projection.boxes.find(box => box.id === parent)?.parentId;
1601
+ }
1602
+ return true;
1603
+ });
1604
+ if (sources.length === 1) {
1605
+ const position = this.layoutValue.boxes[sources[0]];
1606
+ this.requestEdit({ type: 'duplicate', sourceId: sources[0], position: { x: position.x + distance, y: position.y + distance } });
1607
+ }
1608
+ else {
1609
+ this.requestEdit({ type: 'duplicate', sourceIds: sources, offset: { x: distance, y: distance } });
1610
+ }
1611
+ }
1407
1612
  onKey(event) {
1408
1613
  if (event.defaultPrevented)
1409
1614
  return;
@@ -1432,20 +1637,18 @@ export class ProcessModeler extends BaseElement {
1432
1637
  return;
1433
1638
  }
1434
1639
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && this.selectedIds.size) {
1435
- event.preventDefault();
1436
- this.clipboardIds = [...this.selectedIds];
1437
- this.setStatus(`${this.clipboardIds.length} steps copied`);
1640
+ if (this.copySelection())
1641
+ event.preventDefault();
1438
1642
  return;
1439
1643
  }
1440
- if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v' && this.clipboardIds.length && !this.locked) {
1644
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v' && this.clipboard && !this.locked) {
1441
1645
  event.preventDefault();
1442
- this.clipboardIds.forEach((id, index) => { const position = this.layoutValue.boxes[id]; if (position)
1443
- this.requestEdit({ type: 'duplicate', sourceId: id, position: { x: position.x + 32 * (index + 1), y: position.y + 32 * (index + 1) } }); });
1646
+ this.pasteClipboard();
1444
1647
  return;
1445
1648
  }
1446
1649
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd' && this.selectedIds.size && !this.locked) {
1447
1650
  event.preventDefault();
1448
- [...this.selectedIds].forEach(id => { const position = this.layoutValue.boxes[id]; this.requestEdit({ type: 'duplicate', sourceId: id, position: { x: position.x + 32, y: position.y + 32 } }); });
1651
+ this.duplicateBoxes([...this.selectedIds], 48);
1449
1652
  return;
1450
1653
  }
1451
1654
  if (event.key === "Escape") {
@@ -1455,6 +1658,7 @@ export class ProcessModeler extends BaseElement {
1455
1658
  if (this.selectedLineId)
1456
1659
  this.selectLine(null);
1457
1660
  this.portDrag = undefined;
1661
+ this.endDrag = undefined;
1458
1662
  this.marquee = undefined;
1459
1663
  this.segmentDrag = undefined;
1460
1664
  this.drag = undefined;
@@ -1481,6 +1685,18 @@ export class ProcessModeler extends BaseElement {
1481
1685
  this.openKeyboardChooser(box);
1482
1686
  return;
1483
1687
  }
1688
+ const direction = {
1689
+ ArrowLeft: [-1, 0],
1690
+ ArrowRight: [1, 0],
1691
+ ArrowUp: [0, -1],
1692
+ ArrowDown: [0, 1],
1693
+ }[event.key];
1694
+ const canInsertDirection = event.ctrlKey && event.altKey && box && !this.locked && this.catalog.length && !this.disableConnections;
1695
+ if (direction && !canInsertDirection && (event.altKey || !box || this.locked)) {
1696
+ event.preventDefault();
1697
+ this.navigateSelection(direction);
1698
+ return;
1699
+ }
1484
1700
  if (!box || this.locked)
1485
1701
  return;
1486
1702
  if (event.key === "Enter") {
@@ -1509,12 +1725,6 @@ export class ProcessModeler extends BaseElement {
1509
1725
  event.preventDefault();
1510
1726
  this.selectionCommand("delete-many");
1511
1727
  }
1512
- const direction = {
1513
- ArrowLeft: [-1, 0],
1514
- ArrowRight: [1, 0],
1515
- ArrowUp: [0, -1],
1516
- ArrowDown: [0, 1],
1517
- }[event.key];
1518
1728
  if (direction) {
1519
1729
  event.preventDefault();
1520
1730
  if (event.ctrlKey && event.altKey && this.catalog.length && !this.disableConnections) {
@@ -1527,31 +1737,52 @@ export class ProcessModeler extends BaseElement {
1527
1737
  this.openAnchoredChooser(this.nextEdit(box, side, { x: pos.x + direction[0] * 320, y: pos.y + direction[1] * 170 }), this.nextChooserTitle(box, side), anchor, returnFocus);
1528
1738
  return;
1529
1739
  }
1530
- if (event.altKey) {
1531
- const origin = this.layoutValue.boxes[box.id];
1532
- const center = { x: origin.x + (origin.width ?? 224) / 2, y: origin.y + (origin.height ?? 64) / 2 };
1533
- const candidates = this.projection.boxes.filter(candidate => candidate.id !== box.id).map(candidate => {
1534
- const position = this.layoutValue.boxes[candidate.id];
1535
- const dx = position.x + (position.width ?? 224) / 2 - center.x;
1536
- const dy = position.y + (position.height ?? 64) / 2 - center.y;
1537
- const forward = dx * direction[0] + dy * direction[1];
1538
- const sideways = Math.abs(dx * direction[1] - dy * direction[0]);
1539
- return { candidate, forward, score: forward + sideways * 2 };
1540
- }).filter(item => item.forward > 0).sort((a, b) => a.score - b.score || a.candidate.id.localeCompare(b.candidate.id));
1541
- const next = candidates[0]?.candidate;
1542
- if (next) {
1543
- this.select(next.id);
1544
- this.focusBox(next.id);
1545
- this.setStatus(`${next.title} selected`);
1546
- }
1547
- return;
1548
- }
1549
1740
  const pos = this.layoutValue.boxes[box.id];
1550
1741
  const distance = event.shiftKey ? 64 : 16;
1551
1742
  this.move(box.id, pos.x + direction[0] * distance, pos.y + direction[1] * distance);
1552
1743
  this.focusBox(box.id);
1553
1744
  }
1554
1745
  }
1746
+ navigateSelection(direction) {
1747
+ const canvas = this.shadowRoot.querySelector('[part=canvas]');
1748
+ const box = this.selected;
1749
+ const origin = box ? this.layoutValue.boxes[box.id] : undefined;
1750
+ const originSize = box ? this.selectionDimensions(box) : undefined;
1751
+ const center = origin && originSize
1752
+ ? { x: origin.x + originSize.width / 2, y: origin.y + originSize.height / 2 }
1753
+ : { x: (canvas.clientWidth / 2 - this.viewport.x) / this.viewport.zoom, y: (canvas.clientHeight / 2 - this.viewport.y) / this.viewport.zoom };
1754
+ const candidates = this.projection.boxes.filter(candidate => candidate.id !== box?.id && candidate.kind !== 'section').map(candidate => {
1755
+ const position = this.layoutValue.boxes[candidate.id];
1756
+ const size = this.selectionDimensions(candidate);
1757
+ const dx = position.x + size.width / 2 - center.x;
1758
+ const dy = position.y + size.height / 2 - center.y;
1759
+ const forward = dx * direction[0] + dy * direction[1];
1760
+ const sideways = Math.abs(dx * direction[1] - dy * direction[0]);
1761
+ return { candidate, forward, score: forward + sideways * 2.5 };
1762
+ }).filter(item => item.forward > 4).sort((a, b) => a.score - b.score);
1763
+ const next = candidates[0]?.candidate;
1764
+ if (!next) {
1765
+ this.setStatus('Nothing further that way');
1766
+ return;
1767
+ }
1768
+ this.select(next.id);
1769
+ this.revealBox(next);
1770
+ this.focusBox(next.id);
1771
+ this.setStatus(`${next.title} selected`);
1772
+ }
1773
+ revealBox(box) {
1774
+ const canvas = this.shadowRoot.querySelector('[part=canvas]');
1775
+ if (!canvas.clientWidth || !canvas.clientHeight)
1776
+ return;
1777
+ const position = this.layoutValue.boxes[box.id];
1778
+ const size = this.selectionDimensions(box);
1779
+ const left = position.x * this.viewport.zoom + this.viewport.x;
1780
+ const top = position.y * this.viewport.zoom + this.viewport.y;
1781
+ if (left > 48 && top > 48 && left + size.width * this.viewport.zoom < canvas.clientWidth - 48 && top + size.height * this.viewport.zoom < canvas.clientHeight - 48)
1782
+ return;
1783
+ this.setView({ x: canvas.clientWidth / 2 - (position.x + size.width / 2) * this.viewport.zoom,
1784
+ y: canvas.clientHeight / 2 - (position.y + size.height / 2) * this.viewport.zoom, zoom: this.viewport.zoom });
1785
+ }
1555
1786
  pointerDown(event) {
1556
1787
  if (event.button !== 0)
1557
1788
  return;
@@ -1655,10 +1886,12 @@ export class ProcessModeler extends BaseElement {
1655
1886
  const start = this.portDrag.start;
1656
1887
  ghost.setAttribute("points", `${start.x},${start.y} ${point.x},${start.y} ${point.x},${point.y}`);
1657
1888
  const target = this.boxAt(point, this.portDrag.id);
1658
- const pinned = target ? this.sideAt(target, point) : undefined;
1659
- this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); });
1889
+ const problem = target ? this.connectionProblem({ type: 'connect', from: this.portDrag.id, to: target.id }) : null;
1890
+ const pinned = target && !problem ? this.sideAt(target, point) : undefined;
1891
+ ghost.dataset.invalid = String(Boolean(problem));
1892
+ this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); box.dataset.connectInvalid = String(box.dataset.boxId === target?.id && Boolean(problem)); });
1660
1893
  this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { port.dataset.hot = String(port.dataset.owner === target?.id && port.dataset.side === pinned); });
1661
- this.showConnectTooltip(point, target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Connect to ${target.title}` : 'Drop here to add a connected step');
1894
+ this.showConnectTooltip(point, problem ?? (target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Connect to ${target.title}` : 'Drop here to add a connected step'), Boolean(problem));
1662
1895
  return;
1663
1896
  }
1664
1897
  if (this.endDrag) {
@@ -1674,11 +1907,13 @@ export class ProcessModeler extends BaseElement {
1674
1907
  if (fixed)
1675
1908
  ghost.setAttribute('points', `${fixed.x},${fixed.y} ${point.x},${fixed.y} ${point.x},${point.y}`);
1676
1909
  const target = this.boxAt(point);
1677
- const pinned = target ? this.sideAt(target, point) : undefined;
1678
- this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); });
1910
+ const problem = target ? this.connectionProblem({ type: 'reattach', lineId: this.endDrag.lineId, [this.endDrag.end]: target.id }) : null;
1911
+ const pinned = target && !problem ? this.sideAt(target, point) : undefined;
1912
+ ghost.dataset.invalid = String(Boolean(problem) || !target);
1913
+ this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); box.dataset.connectInvalid = String(box.dataset.boxId === target?.id && Boolean(problem)); });
1679
1914
  this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { port.dataset.hot = String(port.dataset.owner === target?.id && port.dataset.side === pinned); });
1680
- this.showConnectTooltip(point, target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Attach to ${target.title}` : 'Drop on a step to reattach', !target);
1681
- this.setStatus(target ? `Release to attach the connection to ${target.title}` : 'Move over a step to attach this connection');
1915
+ this.showConnectTooltip(point, problem ?? (target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Attach to ${target.title}` : 'Drop on a step to reattach'), Boolean(problem) || !target);
1916
+ this.setStatus(problem ?? (target ? `Release to attach the connection to ${target.title}` : 'Move over a step to attach this connection'));
1682
1917
  return;
1683
1918
  }
1684
1919
  if (this.segmentDrag) {
@@ -1777,7 +2012,7 @@ export class ProcessModeler extends BaseElement {
1777
2012
  this.portDrag = undefined;
1778
2013
  this.shadowRoot.querySelector('[part=connection-preview]')?.remove();
1779
2014
  this.shadowRoot.querySelector('[part=connect-tooltip]')?.remove();
1780
- this.shadowRoot.querySelectorAll('[data-connect-target]').forEach(box => { delete box.dataset.connectTarget; });
2015
+ this.shadowRoot.querySelectorAll('[data-connect-target]').forEach(box => { delete box.dataset.connectTarget; delete box.dataset.connectInvalid; });
1781
2016
  this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { delete port.dataset.hot; });
1782
2017
  if (commit) {
1783
2018
  if (Math.hypot(point.x - port.start.x, point.y - port.start.y) < 8) {
@@ -1809,7 +2044,7 @@ export class ProcessModeler extends BaseElement {
1809
2044
  this.endDrag = undefined;
1810
2045
  this.shadowRoot.querySelector('[part=connection-preview]')?.remove();
1811
2046
  this.shadowRoot.querySelector('[part=connect-tooltip]')?.remove();
1812
- this.shadowRoot.querySelectorAll('[data-connect-target]').forEach(box => { delete box.dataset.connectTarget; });
2047
+ this.shadowRoot.querySelectorAll('[data-connect-target]').forEach(box => { delete box.dataset.connectTarget; delete box.dataset.connectInvalid; });
1813
2048
  this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { delete port.dataset.hot; });
1814
2049
  if (commit) {
1815
2050
  if (Math.hypot(point.x - drag.start.x, point.y - drag.start.y) < 8) {
@@ -1925,6 +2160,9 @@ export class ProcessModeler extends BaseElement {
1925
2160
  }
1926
2161
  markDropLine(point, except) {
1927
2162
  this.dropLine = point ? this.lineAt(point, except)?.id : undefined;
2163
+ this.paintDropLine();
2164
+ }
2165
+ paintDropLine() {
1928
2166
  this.shadowRoot.querySelectorAll('[part=line]').forEach(path => { const active = path.dataset.lineId === this.dropLine; path.dataset.drop = String(active); path.setAttribute('marker-end', `url(#${active || path.dataset.selected === 'true' ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`); });
1929
2167
  }
1930
2168
  alignedPoint(id, x, y) {
@@ -2141,8 +2379,15 @@ export class ProcessModeler extends BaseElement {
2141
2379
  this.updateToolbar();
2142
2380
  if (selectionBefore !== JSON.stringify({ ids: [...this.selectedIds], path: this.selectedPath }))
2143
2381
  emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
2144
- if (focused)
2145
- this.focusBox(focused);
2382
+ if (focused) {
2383
+ this.restoringBoxFocus = true;
2384
+ try {
2385
+ this.focusBox(focused);
2386
+ }
2387
+ finally {
2388
+ this.restoringBoxFocus = false;
2389
+ }
2390
+ }
2146
2391
  }
2147
2392
  get allChecks() { return this.computedChecks; }
2148
2393
  collectChecks() {
@@ -2171,6 +2416,13 @@ export class ProcessModeler extends BaseElement {
2171
2416
  for (const box of [...this.projection.boxes].sort((a, b) => Number(Boolean(b.frame)) - Number(Boolean(a.frame)))) {
2172
2417
  const element = document.createElement("div");
2173
2418
  element.tabIndex = 0;
2419
+ element.addEventListener('focus', () => {
2420
+ if (!this.restoringBoxFocus && !this.pointers.size && !this.portDrag && !this.endDrag && !this.segmentDrag && !this.marquee && !this.frameResize) {
2421
+ this.select(box.id);
2422
+ if (box.kind !== 'section')
2423
+ this.revealBox(box);
2424
+ }
2425
+ });
2174
2426
  element.setAttribute("role", "group");
2175
2427
  element.setAttribute("part", box.frame ? "frame" : "box");
2176
2428
  element.dataset.boxId = box.id;
@@ -2305,13 +2557,10 @@ export class ProcessModeler extends BaseElement {
2305
2557
  element.addEventListener("keydown", event => {
2306
2558
  if (event.target !== element)
2307
2559
  return;
2308
- if (event.key === " ") {
2309
- event.preventDefault();
2310
- element.click();
2311
- }
2560
+ // Space reaches the canvas handler so holding it pans from a focused step.
2312
2561
  // Let Enter reach the canvas handler after selecting the focused step.
2313
2562
  // Cancelling it here prevents the editor from opening and taking focus.
2314
- else if (event.key === "Enter") {
2563
+ if (event.key === "Enter") {
2315
2564
  // Connection completion consumes Enter; it must not also edit the target.
2316
2565
  if (this.connecting || this.pendingReattach)
2317
2566
  event.preventDefault();
@@ -2346,6 +2595,7 @@ export class ProcessModeler extends BaseElement {
2346
2595
  for (const side of ["north", "east", "south", "west"]) {
2347
2596
  const port = document.createElement("button");
2348
2597
  port.type = "button";
2598
+ port.tabIndex = -1;
2349
2599
  port.setAttribute("part", "port");
2350
2600
  port.dataset.side = side;
2351
2601
  port.dataset.owner = box.id;
@@ -2551,6 +2801,145 @@ export class ProcessModeler extends BaseElement {
2551
2801
  height: `${position.height ?? 64}px`,
2552
2802
  });
2553
2803
  }
2804
+ activatePaletteKind(kind) {
2805
+ if (this.locked)
2806
+ return;
2807
+ if (this.narrowValue) {
2808
+ this.closeDrawer();
2809
+ this.shadowRoot.querySelector('[part=canvas]').focus({ preventScroll: true });
2810
+ }
2811
+ if (kind.placement) {
2812
+ this.addLayoutKind(kind);
2813
+ return;
2814
+ }
2815
+ const selected = this.selectedIds.size === 1 ? this.selected : null;
2816
+ const from = selected && !selected.frame && selected.kind !== 'section' && !['end', 'finish'].includes(selected.kind) ? selected.id : undefined;
2817
+ const outgoing = from ? this.projection.lines.filter(line => line.from === from) : [];
2818
+ const line = outgoing.length === 1 ? outgoing[0] : undefined;
2819
+ this.requestEdit(line ? { type: 'insert', kind, lineId: line.id, from: line.from, to: line.to } : { type: 'add', kind, ...(from ? { from } : {}) });
2820
+ }
2821
+ paletteLineAt(point) {
2822
+ let nearest;
2823
+ let best = 20 / this.viewport.zoom;
2824
+ for (const line of this.projection.lines) {
2825
+ const points = this.routedLines.get(line.id) ?? [];
2826
+ for (let i = 1; i < points.length; i++) {
2827
+ const a = points[i - 1], b = points[i], dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy;
2828
+ const t = length ? Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / length)) : 0;
2829
+ const distance = Math.hypot(point.x - a.x - t * dx, point.y - a.y - t * dy);
2830
+ if (distance < best) {
2831
+ best = distance;
2832
+ nearest = line;
2833
+ }
2834
+ }
2835
+ }
2836
+ return nearest;
2837
+ }
2838
+ paletteFrameAt(point) {
2839
+ let nearest;
2840
+ let depth = -1;
2841
+ for (const box of this.projection.boxes) {
2842
+ if (!box.frame)
2843
+ continue;
2844
+ const p = this.layoutValue.boxes[box.id];
2845
+ if (!p)
2846
+ continue;
2847
+ const { width, height } = this.selectionDimensions(box);
2848
+ if (point.x < p.x || point.x > p.x + width || point.y < p.y || point.y > p.y + height)
2849
+ continue;
2850
+ let level = 0, parent = box.parentId;
2851
+ while (parent) {
2852
+ level++;
2853
+ parent = this.projection.boxes.find(box => box.id === parent)?.parentId;
2854
+ }
2855
+ if (level > depth) {
2856
+ depth = level;
2857
+ nearest = box;
2858
+ }
2859
+ }
2860
+ return nearest;
2861
+ }
2862
+ markPaletteDrop(point) {
2863
+ this.dropLine = point ? this.paletteLineAt(point)?.id : undefined;
2864
+ const frame = point && !this.dropLine ? this.paletteFrameAt(point) : undefined;
2865
+ this.shadowRoot.querySelectorAll('[part=frame]').forEach(element => { element.dataset.paletteDrop = String(element.dataset.boxId === frame?.id); });
2866
+ this.paintDropLine();
2867
+ }
2868
+ paletteCanvasPoint(event) {
2869
+ const rect = this.shadowRoot.querySelector('[part=canvas]').getBoundingClientRect();
2870
+ if (event.clientX <= rect.left || event.clientX >= rect.right || event.clientY <= rect.top || event.clientY >= rect.bottom)
2871
+ return;
2872
+ return this.canvasPoint(event);
2873
+ }
2874
+ movePalettePointer(event) {
2875
+ const drag = this.palettePointer;
2876
+ if (!drag || drag.id !== event.pointerId)
2877
+ return;
2878
+ if (this.locked) {
2879
+ this.cancelPalettePointer();
2880
+ return;
2881
+ }
2882
+ if (!drag.moved && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 5)
2883
+ return;
2884
+ if (!drag.moved) {
2885
+ drag.moved = true;
2886
+ if (this.narrowValue)
2887
+ this.closeDrawer();
2888
+ }
2889
+ let ghost = this.shadowRoot.querySelector('[part=palette-ghost]');
2890
+ if (!ghost) {
2891
+ ghost = document.createElement('div');
2892
+ ghost.setAttribute('part', 'palette-ghost');
2893
+ ghost.dataset.pointer = 'true';
2894
+ ghost.setAttribute('aria-hidden', 'true');
2895
+ if (drag.kind.icon)
2896
+ ghost.append(drag.kind.icon());
2897
+ ghost.append(document.createTextNode(drag.kind.label));
2898
+ this.shadowRoot.append(ghost);
2899
+ }
2900
+ ghost.style.left = `${event.clientX}px`;
2901
+ ghost.style.top = `${event.clientY}px`;
2902
+ this.markPaletteDrop(!drag.kind.placement && drag.kind.kind !== 'end' ? this.paletteCanvasPoint(event) : undefined);
2903
+ event.preventDefault();
2904
+ }
2905
+ cancelPalettePointer() {
2906
+ const drag = this.palettePointer;
2907
+ this.palettePointer = undefined;
2908
+ this.ownerDocument.removeEventListener('keydown', this.escapePalettePointer, true);
2909
+ if (!drag)
2910
+ return;
2911
+ this.suppressPaletteClick = true;
2912
+ this.shadowRoot?.querySelector('[part=palette-ghost]')?.remove();
2913
+ this.markPaletteDrop(undefined);
2914
+ try {
2915
+ this.releasePointerCapture(drag.id);
2916
+ }
2917
+ catch { /* Capture may already have been lost. */ }
2918
+ }
2919
+ finishPalettePointer(event) {
2920
+ const drag = this.palettePointer;
2921
+ if (!drag || drag.id !== event.pointerId)
2922
+ return;
2923
+ const point = this.paletteCanvasPoint(event);
2924
+ this.cancelPalettePointer();
2925
+ if (this.locked)
2926
+ return;
2927
+ if (!drag.moved) {
2928
+ this.activatePaletteKind(drag.kind);
2929
+ if (this.narrowValue)
2930
+ this.closeDrawer();
2931
+ return;
2932
+ }
2933
+ if (!point)
2934
+ return;
2935
+ if (drag.kind.placement) {
2936
+ this.addLayoutKind(drag.kind, point);
2937
+ return;
2938
+ }
2939
+ const line = drag.kind.kind === 'end' ? undefined : this.paletteLineAt(point);
2940
+ const frame = line ? undefined : this.paletteFrameAt(point);
2941
+ this.requestEdit({ type: line ? 'insert' : 'add', kind: drag.kind, lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id, position: point });
2942
+ }
2554
2943
  renderPalette() {
2555
2944
  const choices = this.shadowRoot.querySelector("[part=choices]");
2556
2945
  const query = this.shadowRoot.querySelector("[part=search]").value.toLowerCase();
@@ -2583,19 +2972,30 @@ export class ProcessModeler extends BaseElement {
2583
2972
  description.textContent = kind.description;
2584
2973
  button.append(icon, name, description);
2585
2974
  button.disabled = this.locked;
2586
- button.draggable = !this.locked;
2587
- button.addEventListener("click", () => {
2588
- if (kind.placement) {
2589
- this.addLayoutKind(kind);
2975
+ button.draggable = false;
2976
+ button.addEventListener('pointerdown', event => {
2977
+ if (this.locked || event.button !== 0 || event.isPrimary === false)
2590
2978
  return;
2979
+ this.cancelPalettePointer();
2980
+ this.suppressPaletteClick = false;
2981
+ this.palettePointer = { kind, id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
2982
+ this.ownerDocument.addEventListener('keydown', this.escapePalettePointer, true);
2983
+ try {
2984
+ this.setPointerCapture(event.pointerId);
2591
2985
  }
2592
- const from = this.selectedId;
2593
- const outgoing = from ? this.projection.lines.filter(line => line.from === from) : [];
2594
- const line = outgoing.length === 1 ? outgoing[0] : undefined;
2595
- this.requestEdit(line ? { type: "insert", kind, lineId: line.id, from: line.from, to: line.to } : { type: "add", kind, ...(from ? { from } : {}) });
2986
+ catch { /* Detached synthetic pointers cannot be captured. */ }
2987
+ event.preventDefault();
2988
+ event.stopPropagation();
2596
2989
  });
2597
- button.addEventListener("dragstart", event => { this.paletteDragKind = kind; event.dataTransfer?.setData("application/boe-process-kind", kind.kind); });
2598
- button.addEventListener("dragend", () => { this.paletteDragKind = null; this.shadowRoot?.querySelector('[part=palette-ghost]')?.remove(); this.markDropLine(undefined); });
2990
+ button.addEventListener('click', event => {
2991
+ if (event.detail > 0 && this.suppressPaletteClick) {
2992
+ this.suppressPaletteClick = false;
2993
+ return;
2994
+ }
2995
+ this.activatePaletteKind(kind);
2996
+ });
2997
+ button.addEventListener('dragstart', event => { this.paletteDragKind = kind; event.dataTransfer?.setData('application/boe-process-kind', kind.kind); });
2998
+ button.addEventListener('dragend', () => { this.paletteDragKind = null; this.shadowRoot?.querySelector('[part=palette-ghost]')?.remove(); this.markDropLine(undefined); });
2599
2999
  choices.append(button);
2600
3000
  }
2601
3001
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
@@ -3058,7 +3458,7 @@ export class ProcessModeler extends BaseElement {
3058
3458
  duplicate.type = 'button';
3059
3459
  duplicate.textContent = 'Duplicate';
3060
3460
  duplicate.disabled = this.locked;
3061
- duplicate.onclick = () => this.requestEdit({ type: 'duplicate', sourceId: selected.id, position: { x: this.layoutValue.boxes[selected.id].x + 32, y: this.layoutValue.boxes[selected.id].y + 32 } });
3461
+ duplicate.onclick = () => this.duplicateBoxes([selected.id], 48);
3062
3462
  const remove = document.createElement('button');
3063
3463
  remove.type = 'button';
3064
3464
  remove.textContent = 'Delete';
@@ -3071,7 +3471,7 @@ export class ProcessModeler extends BaseElement {
3071
3471
  }
3072
3472
  renderSelectionToolbar() {
3073
3473
  const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
3074
- const key = `${[...this.selectedIds].join(',')}|${this.selected?.kind}|${this.selected?.shape}|${this.selectedLineId}|${Boolean(this.selectedLineId && this.layoutValue.lines?.[this.selectedLineId])}|${this.locked}|${this.narrowValue}|${this.projection.lines.filter(line => line.from === this.selectedId).map(line => `${line.id}:${line.label}:${line.dashed}`).join(',')}`;
3474
+ const key = `${[...this.selectedIds].join(',')}|${this.selected?.kind}|${this.selected?.shape}|${this.selectedLineId}|${Boolean(this.selectedLine && (this.layoutValue.lines?.[this.selectedLine.id] || this.selectedLine.fromSide || this.selectedLine.toSide || this.selectedLine.points?.length))}|${this.locked}|${this.narrowValue}|${this.projection.lines.filter(line => line.from === this.selectedId).map(line => `${line.id}:${line.label}:${line.dashed}`).join(',')}`;
3075
3475
  if (key === this.selectionToolbarKey)
3076
3476
  return;
3077
3477
  this.selectionToolbarKey = key;
@@ -3100,7 +3500,7 @@ export class ProcessModeler extends BaseElement {
3100
3500
  if (this.selectedLine) {
3101
3501
  const line = this.selectedLine;
3102
3502
  add('Insert a step', 'insert', () => this.openLineChooser(line, toolbar.querySelector('[data-selection-command=insert]') ?? undefined), true);
3103
- if (this.layoutValue.lines?.[line.id])
3503
+ if (this.layoutValue.lines?.[line.id] || line.fromSide || line.toSide || line.points?.length)
3104
3504
  add('Reset line', 'reset-line', () => this.resetLine(line.id));
3105
3505
  if (!this.disableConnections)
3106
3506
  add('Delete', 'delete-line', () => this.requestEdit({ type: 'disconnect', lineId: line.id }));
@@ -3115,7 +3515,7 @@ export class ProcessModeler extends BaseElement {
3115
3515
  if (failurePath)
3116
3516
  add('If it fails', 'add-failure', () => this.openAnchoredChooser({ type: 'add', from: box.id, routeLabel: 'If it fails', dashed: true }, `If a step in ${box.title} fails`, toolbar.querySelector('[data-selection-command=add-failure]') ?? undefined, Array.from(this.shadowRoot.querySelectorAll('[data-box-id]')).find(element => element.dataset.boxId === box.id)), true);
3117
3517
  if (!['start', 'timer', 'section'].includes(box.kind))
3118
- add('Duplicate', 'duplicate', () => { const position = this.layoutValue.boxes[box.id]; this.requestEdit({ type: 'duplicate', sourceId: box.id, position: { x: position.x + 32, y: position.y + 32 } }); });
3518
+ add('Duplicate', 'duplicate', () => this.duplicateBoxes([box.id], 48));
3119
3519
  if (!['start', 'timer'].includes(box.kind))
3120
3520
  add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
3121
3521
  }
@@ -3218,9 +3618,11 @@ export class ProcessModeler extends BaseElement {
3218
3618
  focusBox(id) {
3219
3619
  Array.from(this.shadowRoot.querySelectorAll("[data-box-id]"))
3220
3620
  .find((box) => box.dataset.boxId === id)
3221
- ?.focus();
3621
+ ?.focus({ preventScroll: true });
3222
3622
  }
3223
3623
  setStatus(message, urgent = false) {
3624
+ if (!this.isRendered)
3625
+ return;
3224
3626
  this.shadowRoot.querySelector(urgent ? "[part=status]" : "[part=urgent-status]").textContent = "";
3225
3627
  this.shadowRoot.querySelector(urgent ? "[part=urgent-status]" : "[part=status]").textContent = message;
3226
3628
  }