@unofficialbox/box-open-elements 0.35.0 → 0.36.1

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.
@@ -6,7 +6,8 @@ import { KindPicker } from "../flow-builder/primitives.js";
6
6
  import { arrangeProcess, routeProcessLine, lineMidpoint, nearProcessLine, roundedProcessPath } from "./geometry.js";
7
7
  import { restoreProcessPositions, snapshotProcessPositions, snapshotProcessProjection, graphChecks, normalizeProcessSelectionPath } from "./bridge.js";
8
8
  import { processModelerDesign } from "./design.js";
9
- import { completeLayout, ProcessHistory, validateProjection, } from "./model.js";
9
+ import { completeLayout, isFlowBox, isFlowLine, ProcessHistory, validateProjection, } from "./model.js";
10
+ let actionControlSequence = 0;
10
11
  const svgElement = (tag) => document.createElementNS("http://www.w3.org/2000/svg", tag);
11
12
  const variableLabel = (text) => {
12
13
  const label = document.createElement('label'), name = document.createElement('span');
@@ -75,6 +76,11 @@ export class ProcessModeler extends BaseElement {
75
76
  checks = [];
76
77
  renderer;
77
78
  cleanupInspector;
79
+ inspectorFieldCleanups = [];
80
+ pendingActionChoice;
81
+ quietActionFocus = false;
82
+ cleanupInspectorFields() { for (const cleanup of this.inspectorFieldCleanups.splice(0))
83
+ cleanup(); }
78
84
  inspected;
79
85
  inspectedId;
80
86
  inspectedControls = "";
@@ -160,6 +166,8 @@ export class ProcessModeler extends BaseElement {
160
166
  narrowValue = false;
161
167
  suppressClick = false;
162
168
  resizeObserver;
169
+ autoNoteHeights = new Set();
170
+ unplacedNotes = new Set();
163
171
  routedLines = new Map();
164
172
  computedChecks = [];
165
173
  get selectedBoxes() { return this.projection.boxes.filter(box => this.selectedIds.has(box.id)); }
@@ -236,6 +244,9 @@ export class ProcessModeler extends BaseElement {
236
244
  this.lastProjection = "";
237
245
  this.lastReadableProjection = "";
238
246
  this.readableValue = null;
247
+ this.marquee = undefined;
248
+ this.pointers.clear();
249
+ this.autoNoteHeights.clear();
239
250
  this.layoutValue = restoreProcessPositions(projection, options.positions ?? []);
240
251
  this.clearMixedSelection();
241
252
  this.history.clear();
@@ -311,7 +322,10 @@ export class ProcessModeler extends BaseElement {
311
322
  set embedMode(value) { this.toggleAttribute('embed-mode', value); }
312
323
  arrangedLayout(layout) {
313
324
  const arranged = this.model.arrange?.(this.projection) ?? arrangeProcess(this.projection);
314
- return completeLayout(this.projection, { ...layout, boxes: { ...arranged?.boxes, ...layout.boxes } });
325
+ const notes = Object.fromEntries(this.projection.boxes.filter(box => !isFlowBox(box) && this.layoutValue.boxes[box.id]).map(box => [box.id, this.layoutValue.boxes[box.id]]));
326
+ const boxes = { ...arranged?.boxes, ...notes, ...layout.boxes };
327
+ this.unplacedNotes = new Set(this.projection.boxes.filter(box => !isFlowBox(box) && !boxes[box.id]).map(box => box.id));
328
+ return completeLayout(this.projection, { ...layout, boxes });
315
329
  }
316
330
  get document() {
317
331
  return this.documentValue;
@@ -346,6 +360,7 @@ export class ProcessModeler extends BaseElement {
346
360
  if (JSON.stringify(this.layoutValue) === JSON.stringify(value))
347
361
  return;
348
362
  this.layoutValue = structuredClone(value);
363
+ this.autoNoteHeights.clear();
349
364
  if (!this.history.replaying)
350
365
  this.history.clear();
351
366
  this.refresh();
@@ -391,6 +406,7 @@ export class ProcessModeler extends BaseElement {
391
406
  return this.renderer;
392
407
  }
393
408
  set renderInspector(value) {
409
+ this.cleanupInspectorFields();
394
410
  this.cleanupInspector?.();
395
411
  this.cleanupInspector = undefined;
396
412
  this.inspected = undefined;
@@ -404,6 +420,7 @@ export class ProcessModeler extends BaseElement {
404
420
  this.viewMenuDocument?.removeEventListener('pointerdown', this.dismissViewMenuOutside, true);
405
421
  this.viewMenuDocument = undefined;
406
422
  this.layoutEditSession++;
423
+ this.pendingActionChoice = undefined;
407
424
  this.resizeObserver?.disconnect();
408
425
  this.clearClipboard();
409
426
  this.cancelPalettePointer();
@@ -412,6 +429,7 @@ export class ProcessModeler extends BaseElement {
412
429
  if (chooser)
413
430
  dismissModal(chooser);
414
431
  this.closeKeyboardChooser();
432
+ this.cleanupInspectorFields();
415
433
  this.cleanupInspector?.();
416
434
  this.cleanupInspector = undefined;
417
435
  this.inspected = undefined;
@@ -485,7 +503,20 @@ export class ProcessModeler extends BaseElement {
485
503
  return this.model.connectionProblem(this.documentValue, { from, to, ...(line ? { ignoreLineId: line.id } : {}) }, this.projection) || null;
486
504
  }
487
505
  requestEdit(edit) {
488
- if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect" || edit.type === 'reattach')))
506
+ if (!this.isRendered && this.documentValue !== undefined) {
507
+ this.projection = this.model.project(this.documentValue);
508
+ validateProjection(this.projection);
509
+ }
510
+ const line = edit.lineId ? this.projection.lines.find(line => line.id === edit.lineId) : undefined;
511
+ const source = edit.from ? this.projection.boxes.find(box => box.id === edit.from) : undefined;
512
+ const inserted = edit.boxId ? this.projection.boxes.find(box => box.id === edit.boxId) : undefined;
513
+ if (line && !isFlowLine(line) && ["insert", "reattach", "reset-line", "edit-line"].includes(edit.type))
514
+ return;
515
+ if ((edit.type === "add" && source && !isFlowBox(source)) || (edit.type === "insert" && inserted && !isFlowBox(inserted)))
516
+ return;
517
+ if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect" || edit.type === 'reattach' || edit.type === 'edit-line')))
518
+ return;
519
+ if (edit.type === 'edit-line' && (!this.projection.lines.some(line => line.id === edit.lineId) || (edit.label === undefined && edit.weight === undefined) || (edit.label !== undefined && typeof edit.label !== 'string') || (edit.weight !== undefined && (!Number.isFinite(edit.weight) || edit.weight < 0))))
489
520
  return;
490
521
  if (edit.type === 'delete-selection' && (!edit.selection?.length || edit.selection.some(item => !['box', 'line', 'note'].includes(item.type) || typeof item.id !== 'string' || !item.id))) {
491
522
  this.setStatus('The selected graph items are invalid', true);
@@ -544,6 +575,7 @@ export class ProcessModeler extends BaseElement {
544
575
  }
545
576
  editAcceptance(edit) {
546
577
  const before = this.layout;
578
+ const beforeAutoHeights = new Set(this.autoNoteHeights);
547
579
  const beforeSelection = [...this.selectedIds];
548
580
  const beforeItems = this.selection;
549
581
  const previousIds = new Set(this.projection.boxes.map((box) => box.id));
@@ -557,8 +589,10 @@ export class ProcessModeler extends BaseElement {
557
589
  // replace the document. Publish only after the whole history entry exists.
558
590
  this.acceptanceNotifications++;
559
591
  try {
560
- if (command.layout)
592
+ if (command.layout) {
593
+ this.autoNoteHeights.clear();
561
594
  this.layoutValue = structuredClone(command.layout);
595
+ }
562
596
  if (edit.type === 'reset-line' && edit.lineId)
563
597
  delete this.layoutValue.lines?.[edit.lineId];
564
598
  this.refresh();
@@ -579,9 +613,11 @@ export class ProcessModeler extends BaseElement {
579
613
  if (session !== this.layoutEditSession)
580
614
  return;
581
615
  const after = this.layout;
616
+ const afterAutoHeights = new Set(this.autoNoteHeights);
582
617
  const acceptedItems = command.selection ? command.selection.map(item => ({ type: item.type, id: item.id })) : undefined;
583
618
  const acceptedSelection = command.selectionIds ? [...command.selectionIds] : undefined;
584
- const restore = (layout) => {
619
+ const restore = (layout, autoHeights) => {
620
+ this.autoNoteHeights = new Set(autoHeights);
585
621
  this.layoutValue = structuredClone(layout);
586
622
  this.refresh();
587
623
  this.emitPositions();
@@ -589,7 +625,7 @@ export class ProcessModeler extends BaseElement {
589
625
  this.history.record({
590
626
  undo: () => {
591
627
  command.undo();
592
- restore(before);
628
+ restore(before, beforeAutoHeights);
593
629
  if (acceptedItems)
594
630
  this.selectItems(beforeItems);
595
631
  else if (acceptedSelection)
@@ -597,7 +633,7 @@ export class ProcessModeler extends BaseElement {
597
633
  },
598
634
  redo: () => {
599
635
  command.redo();
600
- restore(after);
636
+ restore(after, afterAutoHeights);
601
637
  if (acceptedItems)
602
638
  this.selectItems(acceptedItems);
603
639
  else if (acceptedSelection)
@@ -681,17 +717,20 @@ export class ProcessModeler extends BaseElement {
681
717
  commitLayout(next) {
682
718
  const before = this.layout;
683
719
  const after = structuredClone(next);
720
+ const beforeAutoHeights = new Set(this.autoNoteHeights);
721
+ const afterAutoHeights = new Set([...beforeAutoHeights].filter(id => before.boxes[id]?.height === after.boxes[id]?.height));
684
722
  if (JSON.stringify(before) === JSON.stringify(after) || this.locked)
685
723
  return;
686
- const apply = (layout) => {
724
+ const apply = (layout, autoHeights) => {
725
+ this.autoNoteHeights = new Set(autoHeights);
687
726
  this.layoutValue = structuredClone(layout);
688
727
  this.refresh();
689
728
  this.emitPositions();
690
729
  };
691
- apply(after);
730
+ apply(after, afterAutoHeights);
692
731
  this.history.record({
693
- undo: () => apply(before),
694
- redo: () => apply(after),
732
+ undo: () => apply(before, beforeAutoHeights),
733
+ redo: () => apply(after, afterAutoHeights),
695
734
  });
696
735
  this.updateToolbar();
697
736
  }
@@ -806,7 +845,7 @@ export class ProcessModeler extends BaseElement {
806
845
  this.commitLayout(next);
807
846
  }
808
847
  routeLine(id, points) {
809
- if (!this.projection.lines.some((line) => line.id === id) ||
848
+ if (!this.projection.lines.some((line) => line.id === id && isFlowLine(line)) ||
810
849
  points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y)))
811
850
  return;
812
851
  this.commitLayout({
@@ -819,7 +858,7 @@ export class ProcessModeler extends BaseElement {
819
858
  }
820
859
  resetLine(id) {
821
860
  const line = this.projection.lines.find(line => line.id === id);
822
- if (this.locked || !line)
861
+ if (this.locked || !line || !isFlowLine(line))
823
862
  return;
824
863
  if (line.fromSide || line.toSide || line.points?.length) {
825
864
  this.requestEdit({ type: 'reset-line', lineId: id });
@@ -1345,7 +1384,10 @@ export class ProcessModeler extends BaseElement {
1345
1384
  const root = this.shadowRoot;
1346
1385
  const selected = this.selected;
1347
1386
  root.querySelector('[part=process-title]').textContent = this.selectedIds.size > 1 ? `${this.selectedIds.size} steps selected` : selected?.title ?? this.processTitleValue;
1348
- root.querySelector('[part=process-summary]').textContent = this.selectedIds.size > 1 ? 'Arrange or group these steps' : selected ? this.catalog.find(kind => kind.kind === selected.kind)?.label ?? selected.kind : this.processSummaryValue;
1387
+ const kind = selected && this.catalog.find(kind => kind.kind === selected.kind);
1388
+ const kindSummary = kind ? `${kind.label}${kind.description !== undefined ? `. ${kind.description}` : ''}` : selected?.kind;
1389
+ root.querySelector('[part=process-heading]').toggleAttribute('data-selected', Boolean(selected));
1390
+ root.querySelector('[part=process-summary]').textContent = this.selectedIds.size > 1 ? 'Arrange or group these steps' : selected ? kindSummary : this.processSummaryValue;
1349
1391
  root.querySelector('[part=pane-tabs]').hidden = Boolean(selected);
1350
1392
  root.querySelectorAll('[part=pane-tabs] button').forEach(button => {
1351
1393
  button.textContent = button.dataset.pane === "Checks" ? `Checks ${this.allChecks.length}` : button.dataset.pane ?? "";
@@ -1632,7 +1674,34 @@ export class ProcessModeler extends BaseElement {
1632
1674
  content.append(actions);
1633
1675
  }
1634
1676
  else if (this.activePane === "Shortcuts") {
1635
- content.textContent = "Tab: move between steps. Alt+arrows: select the nearest step that way. Arrows: move one grid square. Shift+arrows: move four. N: add the next step. Ctrl+Alt+arrows: add in a direction. Enter: edit. Delete: remove. Ctrl/Command+A: select all. Ctrl/Command+C/V/D: copy, paste, duplicate. Ctrl/Command+Z and Shift+Ctrl/Command+Z: undo and redo. Shift+1: fit the whole process. Shift+drag: select a region. Drag a port: connect. Escape: cancel.";
1677
+ const shortcuts = document.createElement('dl');
1678
+ shortcuts.setAttribute('part', 'shortcut-list');
1679
+ for (const [term, description] of [
1680
+ ['Drag the canvas', 'Move around'], ['Scroll', 'Move around'], ['`Ctrl` + scroll, or pinch', 'Zoom'], ['`Shift` + drag', 'Select an area'],
1681
+ ['Drag a dot beside a step', 'Draw a line. Drop on a box to attach it (the side follows the layout), or on one of its points to pin it there'],
1682
+ ['Click a dot', 'Add a connected step that way'], ['`Alt` `Ctrl` + arrow', 'Add a connected step that way'],
1683
+ ["Drag a line's handle", 'Move that part of the line by hand (Reset line undoes it)'], ["Drag a line's end", 'Attach it somewhere else'],
1684
+ ['`N`', 'Add the next step'], ['Arrow keys', 'Nudge by one grid square (`Shift` for four)'], ['`Alt` + arrow', 'Go to the nearest step that way'],
1685
+ ['`Enter`', 'Edit the selected step'], ['`Delete`', 'Delete the selection'], ['`Ctrl` `Z` / `Shift` `Z`', 'Undo / redo'],
1686
+ ['`Ctrl` `C` `V` `D`', 'Copy, paste, duplicate'], ['`Shift` `1`', 'Fit the whole process'], ['`+` `-` `0`', 'Zoom in, out, 100%'],
1687
+ ['Steps view', ''], ['`↑` `↓`', 'Go to the step above or below'], ['`Alt` + `↑` `↓`', 'Move the step up or down its path'], ['`Delete`', 'Delete the step'],
1688
+ ['Pinch, or `Ctrl` + scroll', 'Zoom'], ['Code view', ''], ['`Ctrl` `Space`', 'Suggest what can go here'], ['`F8`', 'Go to the problem'], ['`Esc`', 'Leave the editor'],
1689
+ ]) {
1690
+ const dt = document.createElement('dt'), dd = document.createElement('dd');
1691
+ if (!description)
1692
+ dt.setAttribute('part', 'shortcut-heading');
1693
+ for (const [element, text] of [[dt, term], [dd, description]]) {
1694
+ text.split('`').forEach((run, index) => { if (index % 2) {
1695
+ const key = document.createElement('kbd');
1696
+ key.textContent = run;
1697
+ element.append(key);
1698
+ }
1699
+ else
1700
+ element.append(document.createTextNode(run)); });
1701
+ }
1702
+ shortcuts.append(dt, dd);
1703
+ }
1704
+ content.append(shortcuts);
1636
1705
  }
1637
1706
  }
1638
1707
  requestVariableEdit(edit) {
@@ -1643,6 +1712,10 @@ export class ProcessModeler extends BaseElement {
1643
1712
  }
1644
1713
  }
1645
1714
  selectionDimensions(box) {
1715
+ if (!isFlowBox(box)) {
1716
+ const p = this.layoutValue.boxes[box.id];
1717
+ return { width: p.width ?? 208, height: p.height ?? 64 };
1718
+ }
1646
1719
  const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
1647
1720
  const compact = shape === 'gateway' || shape === 'event';
1648
1721
  const position = this.layoutValue.boxes[box.id];
@@ -1675,12 +1748,13 @@ export class ProcessModeler extends BaseElement {
1675
1748
  this.arrangeSelection(width >= height ? 'middle' : 'center');
1676
1749
  }
1677
1750
  tidySelection() {
1678
- if (this.locked || this.selectedBoxes.length < 2)
1751
+ const flow = this.selectedBoxes.filter(isFlowBox);
1752
+ if (this.locked || flow.length < 2)
1679
1753
  return;
1680
- if (this.requestLayoutEdit('tidy', [...this.selectedIds]))
1754
+ if (this.requestLayoutEdit('tidy', flow.map(box => box.id)))
1681
1755
  return;
1682
1756
  const next = this.layout;
1683
- const roots = this.selectedBoxes.filter(box => {
1757
+ const roots = this.selectedBoxes.filter(isFlowBox).filter(box => {
1684
1758
  let parent = box.parentId;
1685
1759
  while (parent) {
1686
1760
  if (this.selectedIds.has(parent))
@@ -1732,7 +1806,9 @@ export class ProcessModeler extends BaseElement {
1732
1806
  arrangeSelection(action) {
1733
1807
  if (this.locked || this.selectedBoxes.length < 2)
1734
1808
  return;
1735
- const boxes = this.selectedBoxes;
1809
+ const boxes = this.selectedBoxes.filter(isFlowBox);
1810
+ if (boxes.length < 2)
1811
+ return;
1736
1812
  const next = this.layout;
1737
1813
  const sorted = [...boxes].filter(box => {
1738
1814
  let parent = box.parentId;
@@ -1817,13 +1893,14 @@ export class ProcessModeler extends BaseElement {
1817
1893
  this.commitLayout(next);
1818
1894
  }
1819
1895
  makeSection(title = 'New section') {
1820
- if (this.locked || !this.selectedBoxes.length)
1896
+ const flow = this.selectedBoxes.filter(isFlowBox);
1897
+ if (this.locked || !flow.length)
1821
1898
  return;
1822
- if (this.requestLayoutEdit('make-section', [...this.selectedIds], title))
1899
+ if (this.requestLayoutEdit('make-section', flow.map(box => box.id), title))
1823
1900
  return;
1824
- if (this.selectedBoxes.length < 2)
1901
+ if (flow.length < 2)
1825
1902
  return;
1826
- const positions = this.selectedBoxes.map(box => this.layoutValue.boxes[box.id]);
1903
+ const positions = flow.map(box => this.layoutValue.boxes[box.id]);
1827
1904
  const x = Math.min(...positions.map(position => position.x)) - 24;
1828
1905
  const y = Math.min(...positions.map(position => position.y)) - 48;
1829
1906
  const right = Math.max(...positions.map(position => position.x + (position.width ?? 224))) + 24;
@@ -2064,6 +2141,11 @@ export class ProcessModeler extends BaseElement {
2064
2141
  const target = event.target;
2065
2142
  if (target.closest("input,textarea,select,[contenteditable],button,summary"))
2066
2143
  return;
2144
+ if (target.closest('[part=canvas]') && !event.ctrlKey && !event.metaKey && !event.altKey && ['+', '=', '-', '0'].includes(event.key)) {
2145
+ event.preventDefault();
2146
+ this.zoomBy(event.key === '0' ? 1 / this.viewport.zoom : event.key === '-' ? 1 / 1.2 : 1.2);
2147
+ return;
2148
+ }
2067
2149
  if (event.key === ' ') {
2068
2150
  event.preventDefault();
2069
2151
  this.spacePressed = true;
@@ -2138,7 +2220,7 @@ export class ProcessModeler extends BaseElement {
2138
2220
  this.requestEdit({ type: 'disconnect', lineId: this.selectedLine.id });
2139
2221
  return;
2140
2222
  }
2141
- if ((event.key === "n" || event.key === "N") && box && !this.locked && this.catalog.length && !['finish', 'end'].includes(box.kind)) {
2223
+ if ((event.key === "n" || event.key === "N") && box && isFlowBox(box) && !this.locked && this.catalog.length && !['finish', 'end'].includes(box.kind)) {
2142
2224
  event.preventDefault();
2143
2225
  this.openKeyboardChooser(box);
2144
2226
  return;
@@ -2175,7 +2257,11 @@ export class ProcessModeler extends BaseElement {
2175
2257
  this.commitLayout(next);
2176
2258
  return;
2177
2259
  }
2178
- const canInsertDirection = event.ctrlKey && event.altKey && box && !this.locked && this.catalog.length && !this.disableConnections;
2260
+ if (direction && event.ctrlKey && event.altKey && box && !isFlowBox(box)) {
2261
+ event.preventDefault();
2262
+ return;
2263
+ }
2264
+ const canInsertDirection = event.ctrlKey && event.altKey && box && isFlowBox(box) && !this.locked && this.catalog.length && !this.disableConnections;
2179
2265
  if (direction && !canInsertDirection && (event.altKey || !box || this.locked)) {
2180
2266
  event.preventDefault();
2181
2267
  this.navigateSelection(direction);
@@ -2211,6 +2297,8 @@ export class ProcessModeler extends BaseElement {
2211
2297
  }
2212
2298
  if (direction) {
2213
2299
  event.preventDefault();
2300
+ if (event.ctrlKey && event.altKey && !isFlowBox(box))
2301
+ return;
2214
2302
  if (event.ctrlKey && event.altKey && this.catalog.length && !this.disableConnections) {
2215
2303
  const side = direction[0] < 0 ? "west" : direction[0] > 0 ? "east" : direction[1] < 0 ? "north" : "south";
2216
2304
  const pos = this.layoutValue.boxes[box.id];
@@ -2309,7 +2397,7 @@ export class ProcessModeler extends BaseElement {
2309
2397
  return;
2310
2398
  }
2311
2399
  if (event.button !== 0 ||
2312
- event.target.closest("[part=connection],button,input,select,textarea,[contenteditable]"))
2400
+ event.target.closest("[part=connection],[part=line-hit],button,input,select,textarea,[contenteditable]"))
2313
2401
  return;
2314
2402
  this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
2315
2403
  if (this.pointers.size === 2) {
@@ -2327,7 +2415,7 @@ export class ProcessModeler extends BaseElement {
2327
2415
  this.select(id);
2328
2416
  if (!id && event.shiftKey && !this.locked) {
2329
2417
  const point = this.canvasPoint(event);
2330
- this.marquee = { pointerId: event.pointerId, start: point, point };
2418
+ this.marquee = { pointerId: event.pointerId, start: point, point, add: this.selection };
2331
2419
  canvas.setPointerCapture?.(event.pointerId);
2332
2420
  event.preventDefault();
2333
2421
  return;
@@ -2341,6 +2429,23 @@ export class ProcessModeler extends BaseElement {
2341
2429
  panY: this.viewport.y,
2342
2430
  };
2343
2431
  }
2432
+ selectMarquee(start, point, add) {
2433
+ const items = [...add];
2434
+ const ids = new Set(add.filter(item => item.type === 'box').map(item => item.id));
2435
+ for (const box of this.projection.boxes) {
2436
+ if (box.kind === 'section')
2437
+ continue;
2438
+ const p = this.layoutValue.boxes[box.id], size = this.selectionDimensions(box);
2439
+ if (p.x < Math.max(start.x, point.x) && p.x + size.width > Math.min(start.x, point.x) && p.y < Math.max(start.y, point.y) && p.y + size.height > Math.min(start.y, point.y)) {
2440
+ ids.add(box.id);
2441
+ items.push({ type: 'box', id: box.id });
2442
+ }
2443
+ }
2444
+ for (const line of this.projection.lines)
2445
+ if (ids.has(line.from) && ids.has(line.to))
2446
+ items.push({ type: 'line', id: line.id });
2447
+ this.selectItems(items);
2448
+ }
2344
2449
  distance() {
2345
2450
  const [a, b] = [...this.pointers.values()];
2346
2451
  return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
@@ -2411,7 +2516,11 @@ export class ProcessModeler extends BaseElement {
2411
2516
  return;
2412
2517
  }
2413
2518
  if (this.marquee) {
2414
- this.marquee.point = point;
2519
+ const marquee = this.marquee;
2520
+ marquee.point = point;
2521
+ this.selectMarquee(marquee.start, point, marquee.add);
2522
+ if (this.marquee !== marquee)
2523
+ return;
2415
2524
  let region = this.shadowRoot.querySelector('[part=marquee]');
2416
2525
  if (!region) {
2417
2526
  region = document.createElement("div");
@@ -2503,6 +2612,11 @@ export class ProcessModeler extends BaseElement {
2503
2612
  const pos = this.layoutValue.boxes[port.id];
2504
2613
  const offsets = { east: [320, 0], west: [-320, 0], north: [0, -170], south: [0, 170] }[port.side];
2505
2614
  const source = this.projection.boxes.find(box => box.id === port.id);
2615
+ if (!isFlowBox(source)) {
2616
+ this.connecting = source.id;
2617
+ this.setStatus(`Select a step to attach ${source.title}`);
2618
+ return;
2619
+ }
2506
2620
  const anchor = Array.from(this.shadowRoot.querySelectorAll('[part=port]'))
2507
2621
  .find(element => element.dataset.owner === port.id && element.dataset.side === port.side);
2508
2622
  this.openAnchoredChooser(this.nextEdit(source, port.side, { x: pos.x + offsets[0], y: pos.y + offsets[1] }), this.nextChooserTitle(source, port.side), anchor);
@@ -2513,6 +2627,10 @@ export class ProcessModeler extends BaseElement {
2513
2627
  this.requestEdit({ type: "connect", from: port.id, to: target.id, fromSide: port.side, toSide: this.sideAt(target, point) });
2514
2628
  else {
2515
2629
  const source = this.projection.boxes.find(box => box.id === port.id);
2630
+ if (!isFlowBox(source)) {
2631
+ this.setStatus("Drop on a step to attach the note");
2632
+ return;
2633
+ }
2516
2634
  const frame = this.boxAt(point, undefined, true);
2517
2635
  if ((frame?.id ?? null) !== (source.parentId ?? null))
2518
2636
  this.setStatus('Drop inside the same frame to add a connected step');
@@ -2555,10 +2673,10 @@ export class ProcessModeler extends BaseElement {
2555
2673
  return;
2556
2674
  }
2557
2675
  if (this.marquee) {
2558
- const { start } = this.marquee;
2676
+ const { start, add } = this.marquee;
2559
2677
  this.marquee = undefined;
2560
2678
  if (commit)
2561
- this.selectMany(this.projection.boxes.filter(box => { const p = this.layoutValue.boxes[box.id]; return p.x < Math.max(start.x, point.x) && p.x + (p.width ?? 224) > Math.min(start.x, point.x) && p.y < Math.max(start.y, point.y) && p.y + (p.height ?? 64) > Math.min(start.y, point.y); }).map(box => box.id));
2679
+ this.selectMarquee(start, point, add);
2562
2680
  this.shadowRoot.querySelector('[part=marquee]')?.remove();
2563
2681
  this.pointers.delete(event.pointerId);
2564
2682
  return;
@@ -2576,7 +2694,8 @@ export class ProcessModeler extends BaseElement {
2576
2694
  this.suppressClick = true;
2577
2695
  const position = this.alignedPoint(drag.id, drag.position.x + dx / this.viewport.zoom, drag.position.y + dy / this.viewport.zoom);
2578
2696
  const line = this.lineAt(point, drag.id);
2579
- const frame = this.boxAt(point, drag.id, true);
2697
+ const draggedBox = this.projection.boxes.find(box => box.id === drag.id);
2698
+ const frame = isFlowBox(draggedBox) ? this.boxAt(point, drag.id, true) : this.projection.boxes.find(box => box.id === draggedBox.parentId);
2580
2699
  if (line) {
2581
2700
  const box = this.projection.boxes.find(box => box.id === drag.id);
2582
2701
  const { width, height } = this.selectionDimensions(box);
@@ -2643,14 +2762,15 @@ export class ProcessModeler extends BaseElement {
2643
2762
  return sides.find(([, x, y]) => Math.hypot(point.x - x, point.y - y) <= 12 / this.viewport.zoom)?.[0];
2644
2763
  }
2645
2764
  lineAt(point, except) {
2646
- return this.projection.lines.find(line => line.from !== except && line.to !== except && nearProcessLine(point, this.routedLines.get(line.id) ?? [], 16 / this.viewport.zoom));
2765
+ return this.projection.lines.find(line => isFlowLine(line) && (!except || isFlowBox(this.projection.boxes.find(box => box.id === except))) && line.from !== except && line.to !== except && nearProcessLine(point, this.routedLines.get(line.id) ?? [], 16 / this.viewport.zoom));
2647
2766
  }
2648
2767
  markDropLine(point, except) {
2649
2768
  this.dropLine = point ? this.lineAt(point, except)?.id : undefined;
2650
2769
  this.paintDropLine();
2651
2770
  }
2652
2771
  paintDropLine() {
2653
- 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'})`); });
2772
+ this.shadowRoot.querySelectorAll('[part=line]').forEach(path => { const active = path.dataset.lineId === this.dropLine; path.dataset.drop = String(active); if (path.dataset.role !== 'association')
2773
+ path.setAttribute('marker-end', `url(#${active || path.dataset.selected === 'true' ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`); });
2654
2774
  }
2655
2775
  alignedPoint(id, x, y) {
2656
2776
  const world = this.shadowRoot.querySelector('[part=world]');
@@ -2698,14 +2818,14 @@ export class ProcessModeler extends BaseElement {
2698
2818
  }
2699
2819
  chooser.addEventListener('close', () => this.shadowRoot?.querySelector('[part=ghost-box]')?.remove(), { once: true });
2700
2820
  const picker = chooser.querySelector('box-kind-picker');
2701
- picker.catalog = this.catalog.filter(kind => !kind.placement);
2821
+ picker.catalog = this.catalog.filter(kind => kind.addable !== false && !kind.placement);
2702
2822
  picker.refresh();
2703
2823
  promoteModal(chooser);
2704
2824
  picker.focus();
2705
2825
  }
2706
2826
  nextEdit(box, side, position) {
2707
2827
  const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
2708
- const outgoing = this.projection.lines.filter(line => line.from === box.id);
2828
+ const outgoing = this.projection.lines.filter(line => isFlowLine(line) && line.from === box.id);
2709
2829
  const placement = side ? { source: 'direction', side } : { source: 'next' };
2710
2830
  if ((!side || side === 'east') && shape !== 'gateway' && outgoing.length === 1)
2711
2831
  return { type: 'insert', lineId: outgoing[0].id, from: box.id, to: outgoing[0].to, placement };
@@ -2732,6 +2852,8 @@ export class ProcessModeler extends BaseElement {
2732
2852
  this.openAnchoredChooser(edit, title, anchor, returnFocus ?? source ?? this.shadowRoot.querySelector('[part=canvas]'));
2733
2853
  }
2734
2854
  openLineChooser(line, returnFocus) {
2855
+ if (!isFlowLine(line))
2856
+ return;
2735
2857
  const source = this.projection.boxes.find(box => box.id === line.from);
2736
2858
  const target = this.projection.boxes.find(box => box.id === line.to);
2737
2859
  const points = this.routedLines.get(line.id) ?? routeProcessLine(line, this.layoutValue, this.projection);
@@ -2758,7 +2880,7 @@ export class ProcessModeler extends BaseElement {
2758
2880
  };
2759
2881
  openKeyboardChooser(box) {
2760
2882
  const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape ?? (box.frame ? 'frame' : 'task');
2761
- const outgoing = this.projection.lines.filter(line => line.from === box.id);
2883
+ const outgoing = this.projection.lines.filter(line => isFlowLine(line) && line.from === box.id);
2762
2884
  const target = outgoing.length === 1 ? this.projection.boxes.find(candidate => candidate.id === outgoing[0].to) : undefined;
2763
2885
  const insert = shape !== 'gateway' && outgoing.length === 1 && target;
2764
2886
  const anchor = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
@@ -2775,7 +2897,7 @@ export class ProcessModeler extends BaseElement {
2775
2897
  this.keyboardInsertion = edit;
2776
2898
  chooser.querySelector('[part=chooser-title]').textContent = title;
2777
2899
  const picker = chooser.querySelector('box-kind-picker');
2778
- picker.catalog = this.catalog.filter(kind => !kind.placement);
2900
+ picker.catalog = this.catalog.filter(kind => kind.addable !== false && !kind.placement);
2779
2901
  picker.refresh();
2780
2902
  this.keyboardReturn = returnFocus ?? anchor;
2781
2903
  if (!anchor || !promotePopover(chooser)) {
@@ -2822,6 +2944,7 @@ export class ProcessModeler extends BaseElement {
2822
2944
  else
2823
2945
  this.projection = { boxes: [], lines: [] };
2824
2946
  this.layoutValue = this.arrangedLayout(this.layoutValue);
2947
+ this.measureGraphNotes();
2825
2948
  this.routedLines = new Map(this.projection.lines.map(line => [line.id, routeProcessLine(line, this.layoutValue, this.projection)]));
2826
2949
  this.computedChecks = this.collectChecks();
2827
2950
  if (this.selectedId && !this.selected)
@@ -2834,7 +2957,7 @@ export class ProcessModeler extends BaseElement {
2834
2957
  if (!this.selectedId)
2835
2958
  this.selectedId = this.selectedIds.values().next().value ?? null;
2836
2959
  const serialized = JSON.stringify({
2837
- boxes: this.projection.boxes.map(box => ({ id: box.id, kind: box.kind, title: box.title, description: box.description, path: box.path, fingerprint: box.fingerprint, frame: box.frame, loopMark: box.loopMark, parentId: box.parentId, localVariables: box.localVariables, localVariablesEditable: box.localVariablesEditable, savedResult: box.savedResult })),
2960
+ boxes: this.projection.boxes.map(box => ({ id: box.id, role: box.role, kind: box.kind, title: box.title, description: box.description, path: box.path, fingerprint: box.fingerprint, frame: box.frame, loopMark: box.loopMark, parentId: box.parentId, localVariables: box.localVariables, localVariablesEditable: box.localVariablesEditable, savedResult: box.savedResult })),
2838
2961
  lines: this.projection.lines,
2839
2962
  });
2840
2963
  if (this.documentValue !== undefined && (serialized !== this.lastProjection || this.documentValue !== this.lastDocument)) {
@@ -2884,9 +3007,44 @@ export class ProcessModeler extends BaseElement {
2884
3007
  (note ?? this.shadowRoot.querySelector('[part=canvas]'))?.focus({ preventScroll: true });
2885
3008
  }
2886
3009
  }
3010
+ measureGraphNotes() {
3011
+ const world = this.shadowRoot.querySelector('[part=world]');
3012
+ for (const box of this.projection.boxes.filter(box => !isFlowBox(box))) {
3013
+ const position = this.layoutValue.boxes[box.id];
3014
+ if (position.height !== undefined && !this.autoNoteHeights.has(box.id))
3015
+ continue;
3016
+ this.autoNoteHeights.add(box.id);
3017
+ const element = document.createElement('div');
3018
+ element.setAttribute('part', 'box');
3019
+ element.dataset.shape = 'note';
3020
+ element.style.cssText = `visibility:hidden;pointer-events:none;height:auto;width:${position.width ?? 208}px`;
3021
+ const body = document.createElement('div');
3022
+ body.setAttribute('part', 'note-body');
3023
+ body.textContent = box.description ?? box.title;
3024
+ element.append(body);
3025
+ world.append(element);
3026
+ const bounds = body.getBoundingClientRect();
3027
+ if (bounds.width > 0 && bounds.height > 0)
3028
+ position.height = bounds.height * (position.width ?? 208) / bounds.width;
3029
+ element.remove();
3030
+ }
3031
+ // Only library fallback positions need collision placement; a host's
3032
+ // arrangement is authoritative, and a containing frame is not an obstacle.
3033
+ for (const box of this.projection.boxes.filter(box => this.unplacedNotes.has(box.id))) {
3034
+ const position = this.layoutValue.boxes[box.id], ancestors = new Set();
3035
+ for (let parent = box.parentId; parent; parent = this.projection.boxes.find(b => b.id === parent)?.parentId)
3036
+ ancestors.add(parent);
3037
+ let overlaps;
3038
+ do {
3039
+ overlaps = Object.entries(this.layoutValue.boxes).filter(([id, p]) => id !== box.id && !ancestors.has(id) && position.x < p.x + (p.width ?? 224) && position.x + (position.width ?? 208) > p.x && position.y < p.y + (p.height ?? 64) && position.y + (position.height ?? 64) > p.y).map(([, p]) => p);
3040
+ if (overlaps.length)
3041
+ position.y = Math.max(...overlaps.map(p => p.y + (p.height ?? 64))) + 60;
3042
+ } while (overlaps.length);
3043
+ }
3044
+ }
2887
3045
  get allChecks() { return this.computedChecks; }
2888
3046
  collectChecks() {
2889
- const routes = this.projection.lines.filter(line => !this.routedLines.get(line.id)?.length)
3047
+ const routes = this.projection.lines.filter(line => isFlowLine(line) && !this.routedLines.get(line.id)?.length)
2890
3048
  .map(line => ({ message: "A connection is blocked by overlapping steps. Move a step or adjust its route.", boxId: line.from }));
2891
3049
  const hostChecks = this.documentValue === undefined ? undefined : this.model.validate?.(this.documentValue, this.projection);
2892
3050
  return [...this.checks, ...(hostChecks ?? graphChecks(this.projection)), ...routes];
@@ -2922,28 +3080,38 @@ export class ProcessModeler extends BaseElement {
2922
3080
  element.setAttribute("part", box.frame ? "frame" : "box");
2923
3081
  element.dataset.boxId = box.id;
2924
3082
  const kind = this.catalog.find((entry) => entry.kind === box.kind);
2925
- const shape = box.shape ?? kind?.shape ?? (box.frame ? "frame" : "task");
3083
+ const shape = !isFlowBox(box) ? "note" : box.shape ?? kind?.shape ?? (box.frame ? "frame" : "task");
2926
3084
  element.dataset.shape = shape;
3085
+ if (!isFlowBox(box))
3086
+ element.setAttribute("aria-roledescription", "note");
2927
3087
  element.dataset.kind = box.kind;
2928
3088
  element.dataset.tone = kind?.tone === "accent" ? "accent" : "neutral";
2929
3089
  const problem = this.allChecks.find(check => check.boxId === box.id || (check.path && JSON.stringify(check.path) === JSON.stringify(box.path)));
2930
- const incoming = this.projection.lines.filter(line => line.to === box.id).length;
2931
- const outgoing = this.projection.lines.filter(line => line.from === box.id).length;
2932
- element.setAttribute("aria-label", [`${kind?.label ?? box.kind}: ${box.title}. ${incoming} in, ${outgoing} out`, problem ? `Needs attention: ${problem.title || problem.message}` : ""]
3090
+ const incoming = this.projection.lines.filter(line => isFlowLine(line) && line.to === box.id).length;
3091
+ const outgoing = this.projection.lines.filter(line => isFlowLine(line) && line.from === box.id).length;
3092
+ element.setAttribute("aria-label", [`${kind?.label ?? box.kind}: ${box.title}${isFlowBox(box) ? `. ${incoming} in, ${outgoing} out` : ""}`, problem ? `Needs attention: ${problem.title || problem.message}` : ""]
2933
3093
  .filter(Boolean)
2934
3094
  .join(". "));
2935
3095
  element.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
2936
- this.place(element, this.layoutValue.boxes[box.id]);
3096
+ this.place(element, { ...this.layoutValue.boxes[box.id], ...(!isFlowBox(box) ? { width: this.layoutValue.boxes[box.id].width ?? 208 } : {}) });
3097
+ if (!isFlowBox(box)) {
3098
+ const text = document.createElement("div");
3099
+ text.setAttribute("part", "note-body");
3100
+ text.textContent = box.description ?? box.title;
3101
+ element.append(text);
3102
+ }
2937
3103
  const icon = document.createElement("span");
2938
3104
  icon.setAttribute("part", "icon");
2939
3105
  icon.setAttribute("aria-hidden", "true");
2940
3106
  if (kind?.icon) {
2941
3107
  icon.append(kind.icon());
2942
3108
  }
2943
- element.append(icon);
3109
+ if (isFlowBox(box))
3110
+ element.append(icon);
2944
3111
  const title = document.createElement("strong");
2945
3112
  title.textContent = box.title;
2946
- element.append(title);
3113
+ if (isFlowBox(box))
3114
+ element.append(title);
2947
3115
  let descriptionParent = element;
2948
3116
  if (shape === 'event' && this.detailValue === 'technical' && box.technicalDetails?.length) {
2949
3117
  const details = document.createElement('div');
@@ -2973,7 +3141,7 @@ export class ProcessModeler extends BaseElement {
2973
3141
  }
2974
3142
  if (shape === 'task' && box.technicalDetails)
2975
3143
  icon.style.gridRow = 'span 4';
2976
- if (this.detailValue === "technical" && box.technicalDetails) {
3144
+ if (isFlowBox(box) && this.detailValue === "technical" && box.technicalDetails) {
2977
3145
  for (const line of box.technicalDetails) {
2978
3146
  if (!line.text && !line.segments?.length)
2979
3147
  continue;
@@ -2996,7 +3164,7 @@ export class ProcessModeler extends BaseElement {
2996
3164
  descriptionParent.append(description);
2997
3165
  }
2998
3166
  }
2999
- else {
3167
+ else if (isFlowBox(box)) {
3000
3168
  const descriptionText = this.detailValue === "technical" ? box.technicalDescription ?? box.description : box.description;
3001
3169
  if (descriptionText) {
3002
3170
  const description = document.createElement("small");
@@ -3081,7 +3249,9 @@ export class ProcessModeler extends BaseElement {
3081
3249
  });
3082
3250
  this.connecting = null;
3083
3251
  }
3084
- if (event.shiftKey)
3252
+ if (event.shiftKey && (this.graphSelection || this.selectedLineIds.size || this.selectedNoteIds.size))
3253
+ this.selectItems(this.selectedIds.has(box.id) ? this.selection.filter(item => item.type !== "box" || item.id !== box.id) : [...this.selection, { type: "box", id: box.id }]);
3254
+ else if (event.shiftKey)
3085
3255
  this.selectMany(this.selectedIds.has(box.id) ? [...this.selectedIds].filter(id => id !== box.id) : [...this.selectedIds, box.id]);
3086
3256
  else
3087
3257
  this.select(box.id);
@@ -3098,6 +3268,11 @@ export class ProcessModeler extends BaseElement {
3098
3268
  port.addEventListener("click", event => {
3099
3269
  event.stopPropagation();
3100
3270
  if (!event.detail) {
3271
+ if (!isFlowBox(box)) {
3272
+ this.connecting = box.id;
3273
+ this.setStatus(`Select a step to attach ${box.title}`);
3274
+ return;
3275
+ }
3101
3276
  const pos = this.layoutValue.boxes[box.id];
3102
3277
  const offsets = { east: [320, 0], west: [-320, 0], north: [0, -170], south: [0, 170] }[side];
3103
3278
  this.openAnchoredChooser(this.nextEdit(box, side, { x: pos.x + offsets[0], y: pos.y + offsets[1] }), this.nextChooserTitle(box, side), port);
@@ -3125,6 +3300,11 @@ export class ProcessModeler extends BaseElement {
3125
3300
  element.append(handle);
3126
3301
  }
3127
3302
  world.append(element);
3303
+ if (!isFlowBox(box)) {
3304
+ const height = this.layoutValue.boxes[box.id].height;
3305
+ if (height !== undefined)
3306
+ element.querySelector('[part=note-body]').style.minHeight = `${height}px`;
3307
+ }
3128
3308
  }
3129
3309
  const lines = svgElement("svg");
3130
3310
  lines.setAttribute("part", "lines");
@@ -3156,17 +3336,33 @@ export class ProcessModeler extends BaseElement {
3156
3336
  path.setAttribute("part", "line");
3157
3337
  path.dataset.lineId = line.id;
3158
3338
  path.dataset.selected = String(this.selectedLineIds.has(line.id));
3159
- path.setAttribute('marker-end', `url(#${this.selectedLineIds.has(line.id) ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`);
3339
+ if (isFlowLine(line))
3340
+ path.setAttribute('marker-end', `url(#${this.selectedLineIds.has(line.id) ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`);
3160
3341
  path.setAttribute("vector-effect", "non-scaling-stroke");
3161
3342
  path.setAttribute("d", roundedProcessPath(points));
3162
- if (line.dashed)
3343
+ path.dataset.role = line.role ?? "flow";
3344
+ if (!isFlowLine(line))
3345
+ path.setAttribute("stroke-dasharray", "2 4");
3346
+ else if (line.dashed)
3163
3347
  path.setAttribute("stroke-dasharray", "6 4");
3164
- if (this.showLastRunValue && line.share !== undefined) {
3348
+ if (!isFlowLine(line)) {
3349
+ const hit = svgElement("polyline");
3350
+ hit.setAttribute("part", "line-hit");
3351
+ hit.dataset.lineId = line.id;
3352
+ hit.setAttribute("vector-effect", "non-scaling-stroke");
3353
+ hit.setAttribute("points", points.map(p => `${p.x},${p.y}`).join(" "));
3354
+ hit.addEventListener("click", event => { if (event.shiftKey)
3355
+ this.selectItems(this.selectedLineIds.has(line.id) ? this.selection.filter(item => item.type !== "line" || item.id !== line.id) : [...this.selection, { type: "line", id: line.id }]);
3356
+ else
3357
+ this.selectLine(line.id); });
3358
+ lines.append(hit);
3359
+ }
3360
+ if (isFlowLine(line) && this.showLastRunValue && line.share !== undefined) {
3165
3361
  path.style.strokeWidth = `${Math.round((1.5 + Math.max(0, Math.min(1, line.share)) * 2.5) * 100) / 100}px`;
3166
3362
  path.setAttribute('aria-label', `${Math.round(line.share * 100)}% of last-run traffic`);
3167
3363
  }
3168
3364
  lines.append(path);
3169
- for (const point of [line.fromSide ? points[0] : null, line.toSide ? points.at(-1) : null]) {
3365
+ for (const point of [isFlowLine(line) && line.fromSide ? points[0] : null, isFlowLine(line) && line.toSide ? points.at(-1) : null]) {
3170
3366
  if (!point)
3171
3367
  continue;
3172
3368
  const pin = svgElement('circle');
@@ -3176,7 +3372,7 @@ export class ProcessModeler extends BaseElement {
3176
3372
  pin.setAttribute('r', '3');
3177
3373
  lines.append(pin);
3178
3374
  }
3179
- for (let i = 1; i < points.length - 2; i++) {
3375
+ for (let i = 1; isFlowLine(line) && i < points.length - 2; i++) {
3180
3376
  const hit = svgElement("polyline");
3181
3377
  hit.setAttribute("part", "line-hit");
3182
3378
  hit.dataset.lineId = line.id;
@@ -3187,6 +3383,8 @@ export class ProcessModeler extends BaseElement {
3187
3383
  }
3188
3384
  const label = document.createElement("div");
3189
3385
  label.setAttribute("part", "connection");
3386
+ label.dataset.lineId = line.id;
3387
+ label.dataset.role = line.role ?? "flow";
3190
3388
  label.setAttribute('role', 'group');
3191
3389
  label.tabIndex = 0;
3192
3390
  label.style.left = `${midpoint.x}px`;
@@ -3194,17 +3392,20 @@ export class ProcessModeler extends BaseElement {
3194
3392
  const names = `${this.projection.boxes.find((box) => box.id === line.from).title} to ${this.projection.boxes.find((box) => box.id === line.to).title}${line.label ? `, ${line.label}` : ""}`;
3195
3393
  label.setAttribute('aria-label', `Connection: ${names}${this.showLastRunValue && line.share !== undefined ? `. ${Math.round(line.share * 100)}% of last-run traffic` : ''}`);
3196
3394
  label.addEventListener('click', event => { if (event.target.closest('button'))
3197
- return; this.selectLine(line.id); });
3395
+ return; if (event.shiftKey)
3396
+ this.selectItems(this.selectedLineIds.has(line.id) ? this.selection.filter(item => item.type !== 'line' || item.id !== line.id) : [...this.selection, { type: 'line', id: line.id }]);
3397
+ else
3398
+ this.selectLine(line.id); });
3198
3399
  label.addEventListener('keydown', event => { if (event.target === label && (event.key === 'Enter' || event.key === ' ')) {
3199
3400
  event.preventDefault();
3200
3401
  this.selectLine(line.id);
3201
3402
  } });
3202
3403
  const text = document.createElement("span");
3203
3404
  const source = this.projection.boxes.find(box => box.id === line.from);
3204
- const branchIndex = this.projection.lines.filter(candidate => candidate.from === line.from).indexOf(line);
3205
- text.textContent = line.label ?? (line.weight !== undefined ? String(line.weight) : /decision/i.test(source.kind) ? ["Yes", "No"][branchIndex] ?? `Route ${branchIndex + 1}` : /try/i.test(source.kind) && line.dashed ? "If it fails" : "");
3405
+ const branchIndex = this.projection.lines.filter(candidate => isFlowLine(candidate) && candidate.from === line.from).indexOf(line);
3406
+ text.textContent = !isFlowLine(line) ? "" : line.label ?? (line.weight !== undefined ? String(line.weight) : /decision/i.test(source.kind) ? ["Yes", "No"][branchIndex] ?? `Route ${branchIndex + 1}` : /try/i.test(source.kind) && line.dashed ? "If it fails" : "");
3206
3407
  label.append(text);
3207
- if (this.showLastRunValue && line.share !== undefined) {
3408
+ if (isFlowLine(line) && this.showLastRunValue && line.share !== undefined) {
3208
3409
  const share = document.createElement('small');
3209
3410
  share.textContent = `${Math.round(line.share * 100)}%`;
3210
3411
  label.append(share);
@@ -3212,7 +3413,7 @@ export class ProcessModeler extends BaseElement {
3212
3413
  const actions = document.createElement("div");
3213
3414
  actions.setAttribute("part", "connection-actions");
3214
3415
  label.append(actions);
3215
- if (points.length >= 4 && !this.locked) {
3416
+ if (isFlowLine(line) && points.length >= 4 && !this.locked) {
3216
3417
  const adjust = document.createElement("button");
3217
3418
  adjust.type = "button";
3218
3419
  adjust.textContent = "Route";
@@ -3242,7 +3443,7 @@ export class ProcessModeler extends BaseElement {
3242
3443
  ["insert", "Insert a step here"],
3243
3444
  ["disconnect", "Remove connection"],
3244
3445
  ]) {
3245
- if (type === "disconnect" && this.disableConnections)
3446
+ if ((type === "disconnect" && this.disableConnections) || (type === "insert" && !isFlowLine(line)))
3246
3447
  continue;
3247
3448
  const button = document.createElement("button");
3248
3449
  button.type = "button";
@@ -3266,7 +3467,7 @@ export class ProcessModeler extends BaseElement {
3266
3467
  actions.append(button);
3267
3468
  }
3268
3469
  world.append(label);
3269
- if (this.selectedLineId === line.id && !this.locked && !this.disableConnections) {
3470
+ if (isFlowLine(line) && this.selectedLineId === line.id && !this.locked && !this.disableConnections) {
3270
3471
  for (const [end, point] of [['from', points[0]], ['to', points.at(-1)]]) {
3271
3472
  const grip = document.createElement('button');
3272
3473
  grip.type = 'button';
@@ -3321,7 +3522,7 @@ export class ProcessModeler extends BaseElement {
3321
3522
  return;
3322
3523
  }
3323
3524
  const selected = this.selectedIds.size === 1 ? this.selected : null;
3324
- const from = selected && !selected.frame && selected.kind !== 'section' && !['end', 'finish'].includes(selected.kind) ? selected.id : undefined;
3525
+ const from = selected && isFlowBox(selected) && !selected.frame && selected.kind !== 'section' && !['end', 'finish'].includes(selected.kind) ? selected.id : undefined;
3325
3526
  if (from && selected)
3326
3527
  this.requestEdit({ ...this.nextEdit(selected), kind });
3327
3528
  else {
@@ -3333,7 +3534,7 @@ export class ProcessModeler extends BaseElement {
3333
3534
  paletteLineAt(point) {
3334
3535
  let nearest;
3335
3536
  let best = 20 / this.viewport.zoom;
3336
- for (const line of this.projection.lines) {
3537
+ for (const line of this.projection.lines.filter(isFlowLine)) {
3337
3538
  const points = this.routedLines.get(line.id) ?? [];
3338
3539
  for (let i = 1; i < points.length; i++) {
3339
3540
  const a = points[i - 1], b = points[i], dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy;
@@ -3457,7 +3658,7 @@ export class ProcessModeler extends BaseElement {
3457
3658
  const query = this.shadowRoot.querySelector("[part=search]").value.toLowerCase();
3458
3659
  choices.replaceChildren();
3459
3660
  let group = "";
3460
- for (const kind of this.catalog.filter((kind) => `${kind.label} ${kind.description} ${kind.group ?? ""} ${(kind.aliases ?? []).join(" ")}`
3661
+ for (const kind of this.catalog.filter((kind) => kind.addable !== false && `${kind.label} ${kind.description} ${kind.group ?? ""} ${(kind.aliases ?? []).join(" ")}`
3461
3662
  .toLowerCase()
3462
3663
  .includes(query))) {
3463
3664
  if (kind.group && kind.group !== group) {
@@ -3593,16 +3794,99 @@ export class ProcessModeler extends BaseElement {
3593
3794
  checks.append(button);
3594
3795
  });
3595
3796
  }
3797
+ renderFields(editor, box, help, disclosures) {
3798
+ let sectionKey, disclosureKey, rowKey;
3799
+ let section = editor, disclosure = editor, row = editor;
3800
+ for (const field of this.fieldsValue[box.id] ?? []) {
3801
+ if (field.section?.key !== sectionKey) {
3802
+ sectionKey = field.section?.key;
3803
+ disclosureKey = rowKey = undefined;
3804
+ section = editor;
3805
+ if (field.section) {
3806
+ section = document.createElement('section');
3807
+ section.setAttribute('part', 'field-section');
3808
+ section.dataset.section = sectionKey;
3809
+ const title = document.createElement('h3');
3810
+ title.textContent = field.section.title;
3811
+ section.append(title);
3812
+ if (field.section.description || field.section.descriptionSegments) {
3813
+ const description = document.createElement('p');
3814
+ description.setAttribute('part', 'field-section-description');
3815
+ if (field.section.descriptionSegments)
3816
+ for (const segment of field.section.descriptionSegments) {
3817
+ if (segment.format === 'code') {
3818
+ const code = document.createElement('code');
3819
+ code.textContent = segment.text;
3820
+ description.append(code);
3821
+ }
3822
+ else
3823
+ description.append(document.createTextNode(segment.text));
3824
+ }
3825
+ else
3826
+ description.textContent = field.section.description;
3827
+ section.append(description);
3828
+ }
3829
+ editor.append(section);
3830
+ }
3831
+ disclosure = row = section;
3832
+ }
3833
+ if (field.disclosure?.key !== disclosureKey) {
3834
+ disclosureKey = field.disclosure?.key;
3835
+ rowKey = undefined;
3836
+ disclosure = section;
3837
+ if (field.disclosure) {
3838
+ const details = document.createElement('details');
3839
+ details.setAttribute('part', 'field-disclosure');
3840
+ details.dataset.disclosure = JSON.stringify([sectionKey, disclosureKey]);
3841
+ details.dataset.session = String(this.layoutEditSession);
3842
+ const configuredOpen = Boolean(field.disclosure.open);
3843
+ const previous = disclosures.get(details.dataset.disclosure);
3844
+ details.dataset.configuredOpen = String(configuredOpen);
3845
+ details.open = previous?.configuredOpen === configuredOpen ? previous.open : configuredOpen;
3846
+ const summary = document.createElement('summary');
3847
+ summary.textContent = field.disclosure.summary;
3848
+ details.append(summary);
3849
+ section.append(details);
3850
+ disclosure = details;
3851
+ }
3852
+ row = disclosure;
3853
+ }
3854
+ if (field.row?.key !== rowKey) {
3855
+ rowKey = field.row?.key;
3856
+ row = disclosure;
3857
+ if (field.row) {
3858
+ row = document.createElement('div');
3859
+ row.setAttribute('part', 'field-row');
3860
+ row.dataset.row = rowKey;
3861
+ if (field.row.leadingWidth !== undefined && Number.isFinite(field.row.leadingWidth) && field.row.leadingWidth > 0)
3862
+ row.style.gridTemplateColumns = `${field.row.leadingWidth}px minmax(0, 1fr)`;
3863
+ if (field.row.gap !== undefined && Number.isFinite(field.row.gap) && field.row.gap >= 0)
3864
+ row.style.gap = `${field.row.gap}px`;
3865
+ disclosure.append(row);
3866
+ }
3867
+ }
3868
+ row.append(this.renderField(box, field, help.get(field.key)));
3869
+ }
3870
+ }
3596
3871
  renderField(box, field, helpOpen = false) {
3597
3872
  const row = document.createElement('div');
3598
3873
  row.setAttribute('part', 'field');
3599
3874
  const label = document.createElement('label');
3600
3875
  label.textContent = field.label;
3876
+ if (field.optional) {
3877
+ const optional = document.createElement('em');
3878
+ optional.setAttribute('part', 'field-optional');
3879
+ optional.textContent = ' optional';
3880
+ const name = document.createElement('span');
3881
+ name.append(...Array.from(label.childNodes), optional);
3882
+ label.append(name);
3883
+ }
3601
3884
  let control;
3602
3885
  if (field.kind === 'multiline' || (field.kind === 'expression' && (field.expression?.rows ?? 1) > 1)) {
3603
3886
  control = document.createElement('textarea');
3604
- if (field.expression?.rows)
3605
- control.rows = Math.max(2, Math.floor(field.expression.rows));
3887
+ const rows = field.kind === 'expression' ? field.expression?.rows : field.rows;
3888
+ if (rows !== undefined && Number.isFinite(rows))
3889
+ control.rows = Math.max(field.kind === 'expression' ? 2 : 1, Math.floor(rows));
3606
3890
  }
3607
3891
  else if (field.kind === 'choice') {
3608
3892
  const select = document.createElement('select');
@@ -3628,7 +3912,7 @@ export class ProcessModeler extends BaseElement {
3628
3912
  }
3629
3913
  else {
3630
3914
  const input = document.createElement('input');
3631
- input.type = field.kind === 'boolean' ? 'checkbox' : field.kind === 'number' ? 'number' : field.kind === 'search' ? 'search' : 'text';
3915
+ input.type = field.kind === 'boolean' ? 'checkbox' : field.kind === 'number' ? 'number' : field.kind === 'search' ? 'search' : field.kind === 'time' || field.kind === 'datetime-local' ? field.kind : 'text';
3632
3916
  control = input;
3633
3917
  }
3634
3918
  control.dataset.field = field.key;
@@ -3641,6 +3925,18 @@ export class ProcessModeler extends BaseElement {
3641
3925
  control.required = Boolean(field.required);
3642
3926
  if (field.placeholder && (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement))
3643
3927
  control.placeholder = field.placeholder;
3928
+ if (control instanceof HTMLInputElement && field.kind === 'number') {
3929
+ if (field.min !== undefined && Number.isFinite(field.min))
3930
+ control.min = String(field.min);
3931
+ if (field.max !== undefined && Number.isFinite(field.max))
3932
+ control.max = String(field.max);
3933
+ if (field.step === 'any' || (typeof field.step === 'number' && Number.isFinite(field.step) && field.step > 0))
3934
+ control.step = String(field.step);
3935
+ }
3936
+ if (field.format === 'code') {
3937
+ control.dataset.format = 'code';
3938
+ control.spellcheck = false;
3939
+ }
3644
3940
  if (field.kind === 'expression') {
3645
3941
  control.spellcheck = false;
3646
3942
  control.setAttribute('autocomplete', 'off');
@@ -3648,17 +3944,27 @@ export class ProcessModeler extends BaseElement {
3648
3944
  }
3649
3945
  if (field.problem)
3650
3946
  control.setAttribute('aria-invalid', 'true');
3651
- const submit = () => emit(this, 'process-field-change-request', { boxId: box.id, path: box.path, key: field.key, value: control instanceof HTMLInputElement && control.type === 'checkbox' ? control.checked : control instanceof HTMLInputElement && control.type === 'number' && control.value !== '' ? Number(control.value) : control.value });
3947
+ const fieldSession = this.layoutEditSession;
3948
+ const submit = () => {
3949
+ if (!control.isConnected || control.disabled || this.locked || fieldSession !== this.layoutEditSession || this.selected?.id !== box.id)
3950
+ return;
3951
+ emit(this, 'process-field-change-request', { boxId: box.id, path: box.path, key: field.key, value: control instanceof HTMLInputElement && control.type === 'checkbox' ? control.checked : control instanceof HTMLInputElement && control.type === 'number' && control.value !== '' ? Number(control.value) : control.value });
3952
+ };
3652
3953
  if (field.kind !== 'action')
3653
3954
  control.addEventListener(field.kind === 'expression' ? 'input' : 'change', submit);
3654
- label.append(control);
3955
+ if (field.kind === 'boolean') {
3956
+ row.dataset.boolean = '';
3957
+ label.prepend(control);
3958
+ }
3959
+ else
3960
+ label.append(control);
3655
3961
  row.append(label);
3656
3962
  if (field.kind === 'action' && control instanceof HTMLInputElement) {
3657
3963
  const input = control;
3658
3964
  const options = field.options ?? [];
3659
3965
  const list = document.createElement('div');
3660
3966
  list.setAttribute('part', 'action-options');
3661
- list.id = `process-action-${box.id}-${field.key}`.replace(/[^a-zA-Z0-9-]/g, '-');
3967
+ list.id = `process-action-${++actionControlSequence}`;
3662
3968
  list.hidden = true;
3663
3969
  const optionList = document.createElement('div');
3664
3970
  optionList.setAttribute('role', 'listbox');
@@ -3669,106 +3975,234 @@ export class ProcessModeler extends BaseElement {
3669
3975
  input.setAttribute('aria-controls', optionList.id);
3670
3976
  input.setAttribute('aria-expanded', 'false');
3671
3977
  input.autocomplete = 'off';
3978
+ const availableGroups = [...new Set(options.map(option => option.group).filter((name) => Boolean(name)))];
3979
+ const groups = [...new Set([...(field.actionGroups ?? []).map(item => item.name).filter(name => availableGroups.includes(name)), ...availableGroups])];
3980
+ const shown = input.value;
3672
3981
  let group = null;
3673
3982
  let active = 0;
3674
- let browse = false;
3675
- const close = () => { list.hidden = true; browse = false; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); };
3676
- const choose = (option) => { input.focus(); input.value = option.value; close(); submit(); };
3983
+ let browse = true;
3984
+ const close = (restore = true) => { list.hidden = true; if (restore)
3985
+ input.value = shown; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); };
3986
+ const place = () => {
3987
+ if (list.hidden)
3988
+ return;
3989
+ const viewport = this.ownerDocument.defaultView;
3990
+ const rect = input.getBoundingClientRect();
3991
+ if (!input.isConnected) {
3992
+ close();
3993
+ return;
3994
+ }
3995
+ if (!rect.width && !rect.height)
3996
+ return;
3997
+ if (rect.bottom <= 0 || rect.top >= viewport.innerHeight) {
3998
+ close();
3999
+ return;
4000
+ }
4001
+ const below = Math.max(0, viewport.innerHeight - rect.bottom - 12), above = Math.max(0, rect.top - 12);
4002
+ const up = below < 260 && above > below;
4003
+ Object.assign(list.style, { left: `${Math.max(8, rect.left)}px`, right: 'auto', width: `${Math.min(rect.width, viewport.innerWidth - 16)}px`, maxHeight: `${Math.min(420, up ? above : below)}px`, top: up ? 'auto' : `${rect.bottom + 4}px`, bottom: up ? `${viewport.innerHeight - rect.top + 4}px` : 'auto' });
4004
+ };
4005
+ const dismissOutside = (event) => { const path = event.composedPath(); if (!list.hidden && !path.includes(input) && !path.includes(list) && !path.some(target => target instanceof HTMLElement && target.getAttribute('part') === 'action-caret'))
4006
+ close(); };
4007
+ const viewport = this.ownerDocument.defaultView;
4008
+ viewport.addEventListener('resize', place);
4009
+ this.ownerDocument.addEventListener('scroll', place, true);
4010
+ this.shadowRoot.addEventListener('scroll', place, true);
4011
+ this.ownerDocument.addEventListener('pointerdown', dismissOutside, true);
4012
+ this.inspectorFieldCleanups.push(() => { close(); viewport.removeEventListener('resize', place); this.ownerDocument.removeEventListener('scroll', place, true); this.shadowRoot?.removeEventListener('scroll', place, true); this.ownerDocument.removeEventListener('pointerdown', dismissOutside, true); });
4013
+ const choose = (option) => {
4014
+ const session = this.layoutEditSession;
4015
+ if (!input.isConnected || input.disabled || this.locked || this.selected?.id !== box.id)
4016
+ return;
4017
+ input.focus();
4018
+ if (session !== this.layoutEditSession || !input.isConnected || input.disabled || this.locked || this.selected?.id !== box.id || this.shadowRoot?.activeElement !== input || input.closest('[inert]'))
4019
+ return;
4020
+ input.value = option.value;
4021
+ close(false);
4022
+ this.pendingActionChoice = { boxId: box.id, key: field.key, value: option.value, session };
4023
+ submit();
4024
+ };
3677
4025
  const render = () => {
3678
4026
  list.replaceChildren();
3679
4027
  optionList.replaceChildren();
3680
4028
  const query = browse ? '' : input.value.trim().toLocaleLowerCase();
3681
- const matches = options.filter(option => (!group || option.group === group) && (!query || `${option.label} ${option.value} ${option.group ?? ''}`.toLocaleLowerCase().includes(query)));
3682
- if (!group && !query) {
3683
- const groups = [...new Set(options.map(option => option.group).filter((name) => Boolean(name)))];
3684
- for (const name of groups) {
3685
- const button = document.createElement('button');
3686
- button.type = 'button';
3687
- button.setAttribute('part', 'action-group');
3688
- button.textContent = name;
3689
- button.onclick = () => { input.focus(); group = name; browse = true; render(); };
3690
- list.append(button);
4029
+ const terms = query.split(/\s+/).filter(Boolean);
4030
+ let matches = options.filter(option => (!group || option.group === group) && terms.every(term => `${option.label} ${option.value} ${option.group ?? ''} ${option.description ?? ''}`.toLocaleLowerCase().includes(term)));
4031
+ if (!group && !query && groups.length)
4032
+ matches = matches.filter(option => !option.group);
4033
+ if (query) {
4034
+ const starts = (text, term) => text.toLocaleLowerCase().split(/[\s›_.]+/).some(word => word.startsWith(term));
4035
+ const score = (option) => terms.filter(term => starts(option.group ?? '', term) || starts(option.label, term)).length;
4036
+ matches.sort((a, b) => score(b) - score(a) || (a.group?.length ?? 0) - (b.group?.length ?? 0) || a.label.length - b.label.length);
4037
+ matches = matches.slice(0, 60);
4038
+ }
4039
+ const addHeading = (text) => { const heading = document.createElement('h3'); heading.setAttribute('part', 'action-heading'); heading.setAttribute('aria-hidden', 'true'); heading.textContent = text; optionList.append(heading); };
4040
+ const addNavigation = (name, part, activate) => {
4041
+ const button = document.createElement('button');
4042
+ button.type = 'button';
4043
+ button.tabIndex = -1;
4044
+ button.setAttribute('part', part);
4045
+ button.setAttribute('role', 'option');
4046
+ const title = document.createElement('strong');
4047
+ title.textContent = name;
4048
+ const chevron = document.createElement('span');
4049
+ chevron.setAttribute('part', 'action-chevron');
4050
+ chevron.setAttribute('aria-hidden', 'true');
4051
+ chevron.textContent = part === 'action-back' ? '‹' : '›';
4052
+ if (part === 'action-back') {
4053
+ button.append(chevron, title);
4054
+ button.setAttribute('aria-label', 'Back to all kinds of items');
4055
+ }
4056
+ else {
4057
+ const count = document.createElement('small');
4058
+ const total = options.filter(option => option.group === name).length;
4059
+ count.textContent = String(total);
4060
+ button.append(title, count, chevron);
4061
+ button.setAttribute('aria-label', `${name}, ${total} actions`);
4062
+ }
4063
+ button.onclick = activate;
4064
+ optionList.append(button);
4065
+ };
4066
+ if (!group && (!query || groups.some(name => name.toLocaleLowerCase().includes(query)))) {
4067
+ const visibleGroups = query ? groups.filter(name => name.toLocaleLowerCase().includes(query)).slice(0, 4) : groups;
4068
+ let heading;
4069
+ for (const name of visibleGroups) {
4070
+ const nextHeading = !query ? field.actionGroups?.find(item => item.name === name)?.heading : undefined;
4071
+ if (nextHeading && nextHeading !== heading)
4072
+ addHeading(nextHeading);
4073
+ heading = nextHeading;
4074
+ addNavigation(name, 'action-group', () => { group = name; browse = true; active = 1; render(); });
3691
4075
  }
3692
4076
  }
3693
4077
  if (group) {
3694
- const back = document.createElement('button');
3695
- back.type = 'button';
3696
- back.setAttribute('part', 'action-back');
3697
- back.textContent = `All actions / ${group}`;
3698
- back.onclick = () => { input.focus(); group = null; browse = true; render(); };
3699
- list.prepend(back);
4078
+ addNavigation('All kinds of items', 'action-back', () => { const previous = group; group = null; browse = true; active = Math.max(0, groups.indexOf(previous)); render(); });
4079
+ addHeading(group);
3700
4080
  }
3701
- for (const [index, option] of matches.entries()) {
4081
+ const addAction = (option, custom = false) => {
3702
4082
  const item = document.createElement('button');
3703
4083
  item.type = 'button';
4084
+ item.tabIndex = -1;
3704
4085
  item.setAttribute('role', 'option');
3705
4086
  item.setAttribute('part', 'action-option');
3706
- item.id = `${list.id}-${index}`;
3707
- item.setAttribute('aria-selected', String(index === active));
3708
4087
  const title = document.createElement('strong');
3709
- title.textContent = option.label;
4088
+ title.textContent = group && option.label.startsWith(`${group} › `) ? option.label.slice(group.length + 3) : query && option.group && !option.label.startsWith(`${option.group} › `) ? `${option.group} › ${option.label}` : option.label;
3710
4089
  item.append(title);
3711
- if (option.value !== option.label) {
4090
+ const actionLabel = option.group && option.label.startsWith(`${option.group} › `) ? option.label.slice(option.group.length + 3) : option.label;
4091
+ item.setAttribute('aria-label', custom ? `Use ${option.value}, a step named in your code` : query && option.group ? `${option.group}, ${actionLabel}` : title.textContent);
4092
+ if (custom)
4093
+ item.dataset.custom = '';
4094
+ if (option.value === field.value)
4095
+ item.dataset.current = '';
4096
+ const detail = option.description ?? (option.value !== option.label ? option.value : undefined);
4097
+ if (detail) {
3712
4098
  const code = document.createElement('small');
3713
- code.textContent = option.value;
4099
+ code.textContent = detail;
4100
+ if (!custom)
4101
+ code.dataset.format = 'code';
3714
4102
  item.append(code);
3715
4103
  }
3716
4104
  item.onclick = () => choose(option);
3717
4105
  optionList.append(item);
3718
- }
3719
- if (!matches.length) {
4106
+ };
4107
+ for (const option of matches)
4108
+ addAction(option);
4109
+ if (field.allowCustomValue && /^[a-z0-9_]+(\.[a-z0-9_]+)+$/.test(query) && !options.some(option => option.value === query))
4110
+ addAction({ value: query, label: `Use “${query}”`, description: 'A step named in your code' }, true);
4111
+ if (!optionList.querySelector('[role=option]')) {
3720
4112
  const empty = document.createElement('p');
3721
- empty.textContent = 'No matching actions';
4113
+ empty.setAttribute('part', 'action-empty');
4114
+ empty.textContent = 'No Box action matches. Try a kind of item, like “files”, or an action, like “delete”.';
3722
4115
  list.append(empty);
3723
4116
  }
3724
4117
  list.append(optionList);
3725
- active = Math.min(active, Math.max(0, matches.length - 1));
4118
+ const items = Array.from(optionList.querySelectorAll('[role=option]'));
4119
+ active = Math.min(active, Math.max(0, items.length - 1));
4120
+ items.forEach((item, index) => { item.id = `${list.id}-${index}`; item.setAttribute('aria-selected', String(index === active)); });
3726
4121
  list.hidden = false;
3727
4122
  input.setAttribute('aria-expanded', 'true');
3728
- const selected = list.querySelectorAll('[role=option]')[active];
3729
- if (selected)
3730
- input.setAttribute('aria-activedescendant', selected.id);
4123
+ if (items[active])
4124
+ input.setAttribute('aria-activedescendant', items[active].id);
3731
4125
  else
3732
4126
  input.removeAttribute('aria-activedescendant');
4127
+ place();
4128
+ items[active]?.scrollIntoView?.({ block: 'nearest' });
3733
4129
  };
3734
- input.addEventListener('focus', () => { group = null; browse = false; render(); });
3735
- input.addEventListener('input', () => { group = null; browse = false; active = 0; render(); });
4130
+ const openSelected = () => {
4131
+ if (this.quietActionFocus)
4132
+ return;
4133
+ this.pendingActionChoice = undefined;
4134
+ const current = options.find(option => option.value === field.value);
4135
+ group = current?.group ?? null;
4136
+ browse = true;
4137
+ active = group ? 1 + options.filter(option => option.group === group).findIndex(option => option === current) : current ? groups.length + options.filter(option => !option.group).indexOf(current) : 0;
4138
+ render();
4139
+ };
4140
+ list.addEventListener('pointerdown', event => event.preventDefault());
4141
+ input.addEventListener('focus', () => { if (!this.quietActionFocus) {
4142
+ input.select();
4143
+ openSelected();
4144
+ } });
4145
+ input.addEventListener('input', () => { this.pendingActionChoice = undefined; group = null; browse = false; active = 0; render(); });
4146
+ input.addEventListener('pointerdown', () => { if (list.hidden && this.shadowRoot?.activeElement === input)
4147
+ openSelected(); });
3736
4148
  input.addEventListener('keydown', event => {
4149
+ if (list.hidden) {
4150
+ if (event.key === 'ArrowDown' || event.key === 'Enter') {
4151
+ event.preventDefault();
4152
+ openSelected();
4153
+ }
4154
+ return;
4155
+ }
3737
4156
  if (event.key === 'Escape') {
3738
4157
  event.preventDefault();
4158
+ event.stopPropagation();
3739
4159
  close();
3740
4160
  return;
3741
4161
  }
3742
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
4162
+ if (event.key === 'Tab') {
4163
+ close();
4164
+ return;
4165
+ }
4166
+ const items = Array.from(list.querySelectorAll('[role=option]'));
4167
+ if (!items.length)
4168
+ return;
4169
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || group && (event.key === 'Home' || event.key === 'End')) {
3743
4170
  event.preventDefault();
3744
- if (list.hidden)
3745
- render();
3746
- const items = Array.from(list.querySelectorAll('[role=option]'));
3747
- if (!items.length)
3748
- return;
3749
- active = (active + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
4171
+ active = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (active + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
3750
4172
  items.forEach((item, index) => item.setAttribute('aria-selected', String(index === active)));
3751
4173
  input.setAttribute('aria-activedescendant', items[active].id);
3752
4174
  items[active].scrollIntoView?.({ block: 'nearest' });
3753
4175
  }
3754
- else if (event.key === 'Enter' && !list.hidden) {
3755
- const items = Array.from(list.querySelectorAll('[role=option]'));
4176
+ else if (event.key === 'Enter' || event.key === 'ArrowRight' && items[active]?.getAttribute('part') === 'action-group') {
3756
4177
  if (items[active]) {
3757
4178
  event.preventDefault();
3758
4179
  items[active].click();
3759
4180
  }
3760
4181
  }
4182
+ else if (event.key === 'ArrowLeft' && group && input.value === shown) {
4183
+ event.preventDefault();
4184
+ list.querySelector('[part=action-back]')?.click();
4185
+ }
3761
4186
  });
3762
4187
  const caret = document.createElement('button');
3763
4188
  caret.type = 'button';
3764
4189
  caret.setAttribute('part', 'action-caret');
3765
4190
  caret.setAttribute('aria-label', `Browse ${field.label.toLowerCase()} choices`);
3766
- caret.textContent = '⌄';
4191
+ const caretIcon = document.createElement('span');
4192
+ caretIcon.setAttribute('part', 'action-caret-icon');
4193
+ caretIcon.setAttribute('aria-hidden', 'true');
4194
+ caret.append(caretIcon);
3767
4195
  caret.disabled = input.disabled;
3768
- caret.onclick = () => { input.focus(); group = null; browse = true; active = 0; render(); };
4196
+ caret.onclick = () => { this.pendingActionChoice = undefined; input.focus(); group = null; browse = true; active = 0; render(); };
3769
4197
  row.addEventListener('focusout', event => { if (!row.contains(event.relatedTarget))
3770
4198
  close(); });
3771
- row.append(caret, list);
4199
+ const combo = document.createElement('span');
4200
+ combo.setAttribute('part', 'action-input');
4201
+ input.remove();
4202
+ input.id = `${list.id}-input`;
4203
+ label.htmlFor = input.id;
4204
+ combo.append(input, caret);
4205
+ row.append(combo, list);
3772
4206
  }
3773
4207
  if (field.kind === 'search' && field.options?.length && control instanceof HTMLInputElement) {
3774
4208
  const options = document.createElement('datalist');
@@ -3823,10 +4257,10 @@ export class ProcessModeler extends BaseElement {
3823
4257
  describedBy.push(help.id);
3824
4258
  }
3825
4259
  const feedback = field.problem || (field.kind === 'expression' ? field.expression?.feedback?.message : undefined);
3826
- if (feedback) {
4260
+ if (feedback || (field.kind === 'expression' && field.expression?.feedback !== undefined)) {
3827
4261
  const message = document.createElement('small');
3828
4262
  message.id = `${messageId}-feedback`;
3829
- message.textContent = feedback;
4263
+ message.textContent = feedback ?? '';
3830
4264
  message.setAttribute('part', field.problem ? 'field-problem' : 'field-feedback');
3831
4265
  if (field.kind === 'expression')
3832
4266
  message.setAttribute('aria-live', 'polite');
@@ -3836,6 +4270,14 @@ export class ProcessModeler extends BaseElement {
3836
4270
  row.insertBefore(message, chips);
3837
4271
  describedBy.push(message.id);
3838
4272
  }
4273
+ if (field.annotation) {
4274
+ const annotation = document.createElement('small');
4275
+ annotation.setAttribute('part', 'field-annotation');
4276
+ annotation.id = `${messageId}-annotation`;
4277
+ annotation.textContent = field.annotation;
4278
+ row.append(annotation);
4279
+ describedBy.push(annotation.id);
4280
+ }
3839
4281
  if (describedBy.length)
3840
4282
  control.setAttribute('aria-describedby', describedBy.join(' '));
3841
4283
  if (field.kind === 'expression' && field.expression?.help) {
@@ -3867,11 +4309,118 @@ export class ProcessModeler extends BaseElement {
3867
4309
  }
3868
4310
  return row;
3869
4311
  }
4312
+ renderLeadsTo(editor, selected) {
4313
+ const previous = editor.querySelector('[part=leads-to]');
4314
+ if (!isFlowBox(selected) || ['end', 'finish'].includes(selected.kind)) {
4315
+ previous?.remove();
4316
+ return;
4317
+ }
4318
+ const outgoing = this.projection.lines.filter(line => isFlowLine(line) && line.from === selected.id);
4319
+ const state = JSON.stringify([selected.id, selected.kind, this.locked, this.disableConnections, outgoing, this.projection.boxes.filter(box => box.parentId === selected.parentId).map(({ id, title, kind, role }) => ({ id, title, kind, role }))]);
4320
+ if (previous?.dataset.state === state)
4321
+ return;
4322
+ const leads = document.createElement('section');
4323
+ leads.setAttribute('part', 'leads-to');
4324
+ leads.dataset.state = state;
4325
+ const heading = document.createElement('h3');
4326
+ heading.textContent = 'Leads to';
4327
+ leads.append(heading);
4328
+ const session = this.layoutEditSession;
4329
+ const current = (control) => control.isConnected && this.selected?.id === selected.id && this.layoutEditSession === session && !this.locked && !this.disableConnections;
4330
+ const list = document.createElement('ul');
4331
+ list.setAttribute('part', 'lead-list');
4332
+ for (const line of outgoing) {
4333
+ const row = document.createElement('li');
4334
+ row.setAttribute('part', 'lead-row');
4335
+ if (selected.kind === 'decision' || selected.kind === 'choice') {
4336
+ const input = document.createElement('input');
4337
+ const weighted = selected.kind === 'choice';
4338
+ input.setAttribute('part', 'lead-control');
4339
+ input.dataset.lead = line.id;
4340
+ input.dataset.leadOwner = selected.id;
4341
+ input.type = weighted ? 'number' : 'text';
4342
+ input.setAttribute('aria-label', weighted ? 'Weight' : 'Path name');
4343
+ if (weighted)
4344
+ input.min = '0';
4345
+ input.value = weighted ? String(line.weight ?? 1) : line.label ?? '';
4346
+ input.disabled = this.locked || this.disableConnections;
4347
+ input.onchange = () => {
4348
+ if (!current(input) || !this.projection.lines.some(candidate => candidate.id === line.id && candidate.from === selected.id))
4349
+ return;
4350
+ if (weighted) {
4351
+ const weight = input.valueAsNumber;
4352
+ if (Number.isFinite(weight) && weight >= 0 && weight !== (line.weight ?? 1))
4353
+ this.requestEdit({ type: 'edit-line', lineId: line.id, weight });
4354
+ }
4355
+ else if (input.value !== (line.label ?? ''))
4356
+ this.requestEdit({ type: 'edit-line', lineId: line.id, label: input.value });
4357
+ };
4358
+ row.append(input);
4359
+ }
4360
+ const target = this.projection.boxes.find(box => box.id === line.to);
4361
+ const name = document.createElement('span');
4362
+ name.textContent = target?.title ?? line.to;
4363
+ row.append(name);
4364
+ if (!this.disableConnections) {
4365
+ const remove = document.createElement('button');
4366
+ remove.type = 'button';
4367
+ remove.setAttribute('part', 'lead-remove');
4368
+ remove.setAttribute('aria-label', `Remove the connection to ${target?.title ?? line.to}`);
4369
+ remove.append(variableGlyph());
4370
+ remove.disabled = this.locked;
4371
+ remove.onclick = () => { if (current(remove))
4372
+ this.requestEdit({ type: 'disconnect', lineId: line.id }); };
4373
+ row.append(remove);
4374
+ }
4375
+ list.append(row);
4376
+ }
4377
+ if (outgoing.length)
4378
+ leads.append(list);
4379
+ else {
4380
+ const empty = document.createElement('p');
4381
+ empty.setAttribute('part', 'lead-help');
4382
+ empty.textContent = 'Nothing yet. Drag from a dot on the step, or pick one below.';
4383
+ leads.append(empty);
4384
+ }
4385
+ const eligible = this.projection.boxes.filter(box => isFlowBox(box) && box.id !== selected.id && !['start', 'timer'].includes(box.kind) && box.parentId === selected.parentId && !outgoing.some(line => line.to === box.id));
4386
+ if (eligible.length) {
4387
+ const label = variableLabel('Connect to');
4388
+ label.setAttribute('part', 'lead-target');
4389
+ const select = document.createElement('select');
4390
+ select.setAttribute('aria-label', 'Connect to');
4391
+ select.disabled = this.locked || this.disableConnections;
4392
+ const placeholder = document.createElement('option');
4393
+ placeholder.value = '';
4394
+ placeholder.textContent = 'Connect to…';
4395
+ select.append(placeholder);
4396
+ for (const box of eligible) {
4397
+ const option = document.createElement('option');
4398
+ option.value = box.id;
4399
+ option.textContent = box.title;
4400
+ select.append(option);
4401
+ }
4402
+ select.onchange = () => { const to = select.value; if (to && current(select) && eligible.some(box => box.id === to))
4403
+ this.requestEdit({ type: 'connect', from: selected.id, to }); select.value = ''; };
4404
+ label.append(select);
4405
+ leads.append(label);
4406
+ }
4407
+ if (previous)
4408
+ previous.replaceWith(leads);
4409
+ else
4410
+ editor.append(leads);
4411
+ }
3870
4412
  renderSelection() {
3871
4413
  this.shadowRoot?.querySelectorAll('[part=note]').forEach(note => { note.dataset.selected = String(this.selectedNoteIds.has(note.dataset.noteId)); note.setAttribute('aria-pressed', note.dataset.selected); });
3872
4414
  if (!this.isRendered)
3873
4415
  return;
3874
4416
  const focusedControl = this.shadowRoot.activeElement;
4417
+ const focusedLead = focusedControl?.dataset.lead, focusedLeadOwner = focusedControl?.dataset.leadOwner;
4418
+ const focusedLeadSession = this.layoutEditSession;
4419
+ const summaryOwner = focusedControl?.tagName === 'SUMMARY' ? focusedControl.parentElement : null;
4420
+ const focusedDisclosure = summaryOwner?.matches('[part=field-disclosure]') ? summaryOwner : null;
4421
+ const disclosureOwner = this.inspectedId;
4422
+ const focusedDisclosureKey = focusedDisclosure?.dataset.disclosure;
4423
+ const disclosureSession = focusedDisclosure?.dataset.session;
3875
4424
  const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable ?? focusedControl?.dataset.localVariable;
3876
4425
  const variableKey = focusedControl?.dataset.variableKey;
3877
4426
  const fieldBox = focusedControl?.dataset.fieldBox;
@@ -3882,6 +4431,22 @@ export class ProcessModeler extends BaseElement {
3882
4431
  const direction = focusedControl && 'selectionDirection' in focusedControl ? focusedControl.selectionDirection : null;
3883
4432
  const scrollTop = focusedControl?.scrollTop, scrollLeft = focusedControl?.scrollLeft;
3884
4433
  const restoreControlFocus = () => {
4434
+ if (focusedLead !== undefined) {
4435
+ if (this.selected?.id !== focusedLeadOwner || focusedLeadSession !== this.layoutEditSession || this.locked || this.disableConnections)
4436
+ return;
4437
+ const replacement = Array.from(this.shadowRoot.querySelectorAll('[part=lead-control]')).find(input => input.dataset.lead === focusedLead && input.dataset.leadOwner === focusedLeadOwner);
4438
+ replacement?.focus({ preventScroll: true });
4439
+ if (replacement && caret !== null && replacement.selectionStart !== null)
4440
+ replacement.setSelectionRange(caret, selectionEnd ?? caret);
4441
+ return;
4442
+ }
4443
+ if (focusedDisclosureKey !== undefined) {
4444
+ if (this.selected?.id !== disclosureOwner || disclosureSession !== String(this.layoutEditSession))
4445
+ return;
4446
+ const replacement = Array.from(this.shadowRoot.querySelectorAll('[part=field-disclosure]')).find(item => item.dataset.disclosure === focusedDisclosureKey && item.dataset.session === disclosureSession);
4447
+ replacement?.querySelector('summary')?.focus({ preventScroll: true });
4448
+ return;
4449
+ }
3885
4450
  if (focusedKey === undefined || !focusedType)
3886
4451
  return;
3887
4452
  if (focusedType === 'field' && (this.selected?.id !== fieldBox || this.locked))
@@ -3918,8 +4483,23 @@ export class ProcessModeler extends BaseElement {
3918
4483
  }
3919
4484
  if (!control || control.disabled || control.closest('[inert]'))
3920
4485
  return;
3921
- control.focus({ preventScroll: true });
3922
- if (caret !== null && (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) && control.type !== 'number' && control.type !== 'checkbox')
4486
+ const closedDisclosure = control.closest('[part=field-disclosure]:not([open])');
4487
+ if (closedDisclosure) {
4488
+ closedDisclosure.querySelector('summary')?.focus({ preventScroll: true });
4489
+ return;
4490
+ }
4491
+ const choice = this.pendingActionChoice;
4492
+ // Hosts may echo the committed value repeatedly. Keep focus restoration
4493
+ // quiet until the user explicitly reopens or searches this picker.
4494
+ const quiet = Boolean(focusedType === 'field' && choice && choice.boxId === fieldBox && choice.key === focusedKey && choice.session === this.layoutEditSession && this.fieldsValue[fieldBox]?.some(field => field.key === focusedKey && field.value === choice.value));
4495
+ this.quietActionFocus = quiet;
4496
+ try {
4497
+ control.focus({ preventScroll: true });
4498
+ }
4499
+ finally {
4500
+ this.quietActionFocus = false;
4501
+ }
4502
+ if (caret !== null && (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) && control.selectionStart !== null)
3923
4503
  control.setSelectionRange(caret, selectionEnd ?? caret, direction ?? undefined);
3924
4504
  if (scrollTop !== undefined)
3925
4505
  control.scrollTop = scrollTop;
@@ -3927,7 +4507,8 @@ export class ProcessModeler extends BaseElement {
3927
4507
  control.scrollLeft = scrollLeft;
3928
4508
  };
3929
4509
  this.shadowRoot.querySelectorAll('[part=minimap] [data-map-box]').forEach(rect => rect.dataset.selected = String(this.selectedIds.has(rect.dataset.mapBox)));
3930
- this.shadowRoot.querySelectorAll('[part=line]').forEach(line => { const selected = this.selectedLineIds.has(line.dataset.lineId); line.dataset.selected = String(selected); line.setAttribute('marker-end', `url(#${selected ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`); });
4510
+ this.shadowRoot.querySelectorAll('[part=line]').forEach(line => { const selected = this.selectedLineIds.has(line.dataset.lineId); line.dataset.selected = String(selected); if (line.dataset.role !== 'association')
4511
+ line.setAttribute('marker-end', `url(#${selected ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`); });
3931
4512
  this.shadowRoot.querySelectorAll("[data-box-id]").forEach((element) => {
3932
4513
  element.setAttribute("aria-current", String(this.selectedIds.has(element.dataset.boxId)));
3933
4514
  const box = this.projection.boxes.find((box) => box.id === element.dataset.boxId);
@@ -3938,7 +4519,7 @@ export class ProcessModeler extends BaseElement {
3938
4519
  const editor = this.shadowRoot.querySelector("[part=editor]");
3939
4520
  const selected = this.selected;
3940
4521
  const fallbackScope = selected && (this.fieldsValue[selected.id] ?? []).some(field => field.kind === 'expression' && field.expression?.variables === undefined) ? this.variables.map(({ name, description }) => ({ name, description })) : undefined;
3941
- const controlsKey = `${JSON.stringify(fallbackScope)}|${[...this.selectedIds].join(',')}|${this.locked}|${this.disableConnections}|${this.showLastRunValue}|${selected?.runMetrics}|${JSON.stringify([selected?.localVariables, selected?.localVariablesEditable, selected?.frame])}|${JSON.stringify(selected ? this.fieldsValue[selected.id] ?? [] : [])}`;
4522
+ const controlsKey = `${JSON.stringify(fallbackScope)}|${[...this.selectedIds].join(',')}|${this.locked}|${this.disableConnections}|${this.showLastRunValue}|${selected?.runMetrics}|${selected?.role}|${JSON.stringify([selected?.localVariables, selected?.localVariablesEditable, selected?.frame])}|${JSON.stringify(selected ? this.fieldsValue[selected.id] ?? [] : [])}`;
3942
4523
  this.shadowRoot.querySelector("[part=palette]").hidden = false;
3943
4524
  this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selection.length === 0;
3944
4525
  this.renderSelectionToolbar();
@@ -3948,23 +4529,31 @@ export class ProcessModeler extends BaseElement {
3948
4529
  this.inspectedId === selected?.id &&
3949
4530
  this.inspectedControls === controlsKey &&
3950
4531
  editor.querySelector(`[part=inspector-heading]`)?.tagName === `H${this.headingLevel}`) {
3951
- editor.querySelector("[part=inspector-heading]").textContent = selected?.title ?? "";
4532
+ editor.querySelector("[part=inspector-heading]").textContent = this.selectedIds.size > 1 ? `${this.selectedIds.size} steps selected` : selected?.title ?? "";
3952
4533
  const locals = editor.querySelector('[part=local-variables]');
3953
- if (selected && locals && locals.dataset.localEchoRevision !== String(this.localEchoRevision)) {
4534
+ if (selected && this.selectedIds.size === 1 && locals && locals.dataset.localEchoRevision !== String(this.localEchoRevision)) {
3954
4535
  // Reconcile controlled local drafts without disposing the host's custom
3955
4536
  // inspector or unrelated focused controls on unchanged document echoes.
3956
4537
  locals.replaceWith(this.renderLocalVariables(selected));
3957
4538
  }
4539
+ if (selected && this.selectedIds.size === 1)
4540
+ this.renderLeadsTo(editor, selected);
3958
4541
  restoreControlFocus();
3959
4542
  return;
3960
4543
  }
3961
4544
  const expressionHelp = new Map();
4545
+ const disclosures = new Map();
3962
4546
  if (this.inspectedId === selected?.id && this.selectedIds.size === 1) {
4547
+ for (const disclosure of Array.from(editor.querySelectorAll('[part=field-disclosure]'))) {
4548
+ if (disclosure.dataset.session === String(this.layoutEditSession))
4549
+ disclosures.set(disclosure.dataset.disclosure, { open: disclosure.open, configuredOpen: disclosure.dataset.configuredOpen === 'true' });
4550
+ }
3963
4551
  for (const help of Array.from(editor.querySelectorAll('[part=expression-help]'))) {
3964
4552
  if (help.dataset.session === String(this.layoutEditSession))
3965
4553
  expressionHelp.set(help.dataset.helpField, help.open);
3966
4554
  }
3967
4555
  }
4556
+ this.cleanupInspectorFields();
3968
4557
  this.cleanupInspector?.();
3969
4558
  this.cleanupInspector = undefined;
3970
4559
  editor.replaceChildren();
@@ -4013,44 +4602,13 @@ export class ProcessModeler extends BaseElement {
4013
4602
  purpose.textContent = selected.description;
4014
4603
  editor.append(purpose);
4015
4604
  }
4016
- for (const field of this.fieldsValue[selected.id] ?? [])
4017
- editor.append(this.renderField(selected, field, expressionHelp.get(field.key)));
4605
+ this.renderFields(editor, selected, expressionHelp, disclosures);
4018
4606
  if (selected.localVariables !== undefined)
4019
4607
  editor.append(this.renderLocalVariables(selected));
4020
4608
  this.cleanupInspector =
4021
4609
  this.renderer?.(selected.node, editor) || undefined;
4022
- const outgoing = this.projection.lines.filter(line => line.from === selected.id);
4023
- const leads = document.createElement('section');
4024
- leads.setAttribute('part', 'leads-to');
4025
- const leadsHeading = document.createElement('h3');
4026
- leadsHeading.textContent = 'Leads to';
4027
- leads.append(leadsHeading);
4028
- for (const line of outgoing) {
4029
- const row = document.createElement('div');
4030
- row.setAttribute('part', 'lead-row');
4031
- const target = this.projection.boxes.find(box => box.id === line.to);
4032
- const name = document.createElement('span');
4033
- name.textContent = `${line.label ? `${line.label}: ` : ''}${target?.title ?? line.to}`;
4034
- row.append(name);
4035
- if (!this.disableConnections) {
4036
- const remove = document.createElement('button');
4037
- remove.type = 'button';
4038
- remove.textContent = 'Remove';
4039
- remove.setAttribute('aria-label', `Remove connection to ${target?.title ?? line.to}`);
4040
- remove.disabled = this.locked;
4041
- remove.onclick = () => this.requestEdit({ type: 'disconnect', lineId: line.id });
4042
- row.append(remove);
4043
- }
4044
- leads.append(row);
4045
- }
4046
- const connect = document.createElement('button');
4047
- connect.type = 'button';
4048
- connect.textContent = 'Connect to…';
4049
- connect.disabled = this.locked || this.disableConnections;
4050
- connect.onclick = () => { this.connecting = selected.id; this.setStatus(`Select the next box to connect from ${selected.title}`); };
4051
- leads.append(connect);
4052
- editor.append(leads);
4053
- const metrics = this.showLastRunValue && selected.runMetrics !== false ? this.lastRunValue?.steps[selected.id] : undefined;
4610
+ this.renderLeadsTo(editor, selected);
4611
+ const metrics = isFlowBox(selected) && this.showLastRunValue && selected.runMetrics !== false ? this.lastRunValue?.steps[selected.id] : undefined;
4054
4612
  if (metrics) {
4055
4613
  const report = document.createElement('section');
4056
4614
  report.setAttribute('part', 'inspector-metrics');
@@ -4084,22 +4642,25 @@ export class ProcessModeler extends BaseElement {
4084
4642
  }
4085
4643
  report.append(list);
4086
4644
  }
4087
- editor.insertBefore(report, leads);
4645
+ editor.insertBefore(report, editor.querySelector('[part=leads-to]'));
4088
4646
  }
4089
4647
  const actions = document.createElement('div');
4090
4648
  actions.setAttribute('part', 'inspector-actions');
4091
- const duplicate = document.createElement('button');
4092
- duplicate.type = 'button';
4093
- duplicate.textContent = 'Duplicate';
4094
- duplicate.disabled = this.locked;
4095
- duplicate.onclick = () => this.duplicateBoxes([selected.id], 48);
4096
- const remove = document.createElement('button');
4097
- remove.type = 'button';
4098
- remove.textContent = 'Delete';
4099
- remove.disabled = this.locked;
4100
- remove.onclick = () => this.requestEdit({ type: 'delete', boxId: selected.id });
4101
- actions.append(duplicate, remove);
4102
- editor.append(actions);
4649
+ if (!['start', 'timer'].includes(selected.kind)) {
4650
+ const duplicate = document.createElement('button');
4651
+ duplicate.type = 'button';
4652
+ duplicate.textContent = 'Duplicate';
4653
+ duplicate.disabled = this.locked;
4654
+ duplicate.onclick = () => this.duplicateBoxes([selected.id], 48);
4655
+ const remove = document.createElement('button');
4656
+ remove.type = 'button';
4657
+ remove.textContent = 'Delete';
4658
+ remove.disabled = this.locked;
4659
+ remove.onclick = () => this.requestEdit({ type: 'delete', boxId: selected.id });
4660
+ actions.append(duplicate, remove);
4661
+ }
4662
+ if (actions.childElementCount)
4663
+ editor.append(actions);
4103
4664
  }
4104
4665
  restoreControlFocus();
4105
4666
  }
@@ -4209,7 +4770,7 @@ export class ProcessModeler extends BaseElement {
4209
4770
  }
4210
4771
  renderSelectionToolbar() {
4211
4772
  const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
4212
- const key = `${JSON.stringify(this.selection)}|${this.graphSelection}|${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(',')}`;
4773
+ const key = `${JSON.stringify(this.selection)}|${this.graphSelection}|${this.selected?.kind}|${this.selected?.shape}|${this.selected?.role}|${this.selectedLine?.role}|${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(',')}`;
4213
4774
  if (key === this.selectionToolbarKey)
4214
4775
  return;
4215
4776
  this.selectionToolbarKey = key;
@@ -4224,10 +4785,10 @@ export class ProcessModeler extends BaseElement {
4224
4785
  button.disabled = this.locked;
4225
4786
  button.onclick = action;
4226
4787
  if (withPlus) {
4227
- const icon = document.createElement('span');
4788
+ const icon = variableGlyph(true);
4228
4789
  icon.setAttribute('part', 'selection-plus');
4229
4790
  icon.setAttribute('aria-hidden', 'true');
4230
- icon.textContent = '+';
4791
+ icon.setAttribute('focusable', 'false');
4231
4792
  button.append(icon);
4232
4793
  }
4233
4794
  const text = document.createElement('span');
@@ -4236,24 +4797,27 @@ export class ProcessModeler extends BaseElement {
4236
4797
  toolbar.append(button);
4237
4798
  };
4238
4799
  if (this.graphSelection && (this.selectedNoteIds.size || this.selectedLineIds.size > 1 || this.selectedLineIds.size && this.selectedIds.size)) {
4239
- if (this.selectedIds.size > 1)
4800
+ if (this.selectedIds.size) {
4240
4801
  add('Line up', 'align', () => this.selectionCommand('align'));
4802
+ add('Tidy these', 'space', () => this.selectionCommand('space'));
4803
+ }
4241
4804
  add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
4242
4805
  }
4243
4806
  else if (this.selectedLine) {
4244
4807
  const line = this.selectedLine;
4245
- add('Insert a step', 'insert', () => this.openLineChooser(line, toolbar.querySelector('[data-selection-command=insert]') ?? undefined), true);
4246
- if (this.layoutValue.lines?.[line.id] || line.fromSide || line.toSide || line.points?.length)
4808
+ if (isFlowLine(line))
4809
+ add('Insert a step', 'insert', () => this.openLineChooser(line, toolbar.querySelector('[data-selection-command=insert]') ?? undefined), true);
4810
+ if (isFlowLine(line) && (this.layoutValue.lines?.[line.id] || line.fromSide || line.toSide || line.points?.length))
4247
4811
  add('Reset line', 'reset-line', () => this.resetLine(line.id));
4248
4812
  if (!this.disableConnections)
4249
4813
  add('Delete', 'delete-line', () => this.requestEdit({ type: 'disconnect', lineId: line.id }));
4250
4814
  }
4251
4815
  else if (this.selectedIds.size === 1) {
4252
4816
  const box = this.selected;
4253
- const failurePath = box.kind === 'try' && !this.projection.lines.some(line => line.from === box.id && (line.dashed || line.label === 'If it fails'));
4817
+ const failurePath = isFlowBox(box) && box.kind === 'try' && !this.projection.lines.some(line => isFlowLine(line) && line.from === box.id && (line.dashed || line.label === 'If it fails'));
4254
4818
  if (this.narrowValue)
4255
4819
  add('Details', 'details', () => this.openDrawer('inspector'));
4256
- if (!['end', 'finish', 'section', 'note'].includes(box.kind))
4820
+ if (isFlowBox(box) && !['end', 'finish', 'section', 'note'].includes(box.kind))
4257
4821
  add(box.shape === 'gateway' ? 'Add a path' : 'Add next', 'add-next', () => this.openAnchoredChooser(this.nextEdit(box), this.nextChooserTitle(box), toolbar.querySelector('[data-selection-command=add-next]') ?? undefined), true);
4258
4822
  if (failurePath)
4259
4823
  add('If it fails', 'add-failure', () => this.openAnchoredChooser({ type: 'add', from: box.id, routeLabel: 'If it fails', dashed: true, placement: { source: 'next' } }, `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);
@@ -4378,9 +4942,7 @@ export class ProcessModeler extends BaseElement {
4378
4942
  positions.push(...(this.layoutValue.notes ?? []).filter(note => this.selectedNoteIds.has(note.id)).map(note => ({ ...note, width: 208, height: 80 })));
4379
4943
  if (!positions.length && this.selectedLine) {
4380
4944
  const point = lineMidpoint(this.routedLines.get(this.selectedLine.id) ?? []);
4381
- toolbar.style.left = `${Math.max(8, Math.min(point.x * this.viewport.zoom + this.viewport.x, Math.max(8, canvas.clientWidth - toolbar.offsetWidth - 8)))}px`;
4382
- toolbar.style.top = `${Math.max(8, point.y * this.viewport.zoom + this.viewport.y - 52)}px`;
4383
- return;
4945
+ positions.push({ x: point.x, y: point.y - 14 / this.viewport.zoom, width: 0, height: 0 });
4384
4946
  }
4385
4947
  if (!positions.length) {
4386
4948
  for (const id of this.selectedLineIds) {
@@ -4394,10 +4956,11 @@ export class ProcessModeler extends BaseElement {
4394
4956
  if (!positions.length)
4395
4957
  return;
4396
4958
  const left = Math.min(...positions.map(position => position.x * this.viewport.zoom + this.viewport.x));
4959
+ const right = Math.max(...positions.map(position => (position.x + (position.width ?? 224)) * this.viewport.zoom + this.viewport.x));
4397
4960
  const top = Math.min(...positions.map(position => position.y * this.viewport.zoom + this.viewport.y));
4398
4961
  const bottom = Math.max(...positions.map(position => (position.y + (position.height ?? 64)) * this.viewport.zoom + this.viewport.y));
4399
- toolbar.style.left = `${Math.max(8, Math.min(left, Math.max(8, canvas.clientWidth - toolbar.offsetWidth - 8)))}px`;
4400
- toolbar.style.top = `${top > 56 ? top - 48 : Math.min(bottom + 8, Math.max(8, canvas.clientHeight - toolbar.offsetHeight - 8))}px`;
4962
+ toolbar.style.left = `${Math.min(Math.max((left + right) / 2, 120), canvas.clientWidth - 120)}px`;
4963
+ toolbar.style.top = `${top - 12 < 56 ? bottom + 52 : top - 12}px`;
4401
4964
  }
4402
4965
  focusBox(id) {
4403
4966
  Array.from(this.shadowRoot.querySelectorAll("[data-box-id]"))