@unofficialbox/box-open-elements 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 = "";
@@ -132,6 +138,7 @@ export class ProcessModeler extends BaseElement {
132
138
  readableValue = null;
133
139
  lastDocument;
134
140
  connectionsValue = [];
141
+ connectionsHelpValue = "Steps sign in with the host’s connections. Connections are set up by the host.";
135
142
  variablesValue = [];
136
143
  variablesEditableValue = false;
137
144
  outlineValue = [];
@@ -159,6 +166,8 @@ export class ProcessModeler extends BaseElement {
159
166
  narrowValue = false;
160
167
  suppressClick = false;
161
168
  resizeObserver;
169
+ autoNoteHeights = new Set();
170
+ unplacedNotes = new Set();
162
171
  routedLines = new Map();
163
172
  computedChecks = [];
164
173
  get selectedBoxes() { return this.projection.boxes.filter(box => this.selectedIds.has(box.id)); }
@@ -174,6 +183,9 @@ export class ProcessModeler extends BaseElement {
174
183
  }
175
184
  get connections() { return this.connectionsValue; }
176
185
  set connections(value) { this.connectionsValue = value; this.refresh(); }
186
+ /** Plain-text help describing where this host owns connection setup. */
187
+ get connectionsHelp() { return this.connectionsHelpValue; }
188
+ set connectionsHelp(value) { this.connectionsHelpValue = value; this.refresh(); }
177
189
  get variables() { return this.variablesValue; }
178
190
  set variables(value) {
179
191
  this.variablesValue = value;
@@ -232,6 +244,9 @@ export class ProcessModeler extends BaseElement {
232
244
  this.lastProjection = "";
233
245
  this.lastReadableProjection = "";
234
246
  this.readableValue = null;
247
+ this.marquee = undefined;
248
+ this.pointers.clear();
249
+ this.autoNoteHeights.clear();
235
250
  this.layoutValue = restoreProcessPositions(projection, options.positions ?? []);
236
251
  this.clearMixedSelection();
237
252
  this.history.clear();
@@ -307,7 +322,10 @@ export class ProcessModeler extends BaseElement {
307
322
  set embedMode(value) { this.toggleAttribute('embed-mode', value); }
308
323
  arrangedLayout(layout) {
309
324
  const arranged = this.model.arrange?.(this.projection) ?? arrangeProcess(this.projection);
310
- 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 });
311
329
  }
312
330
  get document() {
313
331
  return this.documentValue;
@@ -342,6 +360,7 @@ export class ProcessModeler extends BaseElement {
342
360
  if (JSON.stringify(this.layoutValue) === JSON.stringify(value))
343
361
  return;
344
362
  this.layoutValue = structuredClone(value);
363
+ this.autoNoteHeights.clear();
345
364
  if (!this.history.replaying)
346
365
  this.history.clear();
347
366
  this.refresh();
@@ -387,6 +406,7 @@ export class ProcessModeler extends BaseElement {
387
406
  return this.renderer;
388
407
  }
389
408
  set renderInspector(value) {
409
+ this.cleanupInspectorFields();
390
410
  this.cleanupInspector?.();
391
411
  this.cleanupInspector = undefined;
392
412
  this.inspected = undefined;
@@ -400,6 +420,7 @@ export class ProcessModeler extends BaseElement {
400
420
  this.viewMenuDocument?.removeEventListener('pointerdown', this.dismissViewMenuOutside, true);
401
421
  this.viewMenuDocument = undefined;
402
422
  this.layoutEditSession++;
423
+ this.pendingActionChoice = undefined;
403
424
  this.resizeObserver?.disconnect();
404
425
  this.clearClipboard();
405
426
  this.cancelPalettePointer();
@@ -408,6 +429,7 @@ export class ProcessModeler extends BaseElement {
408
429
  if (chooser)
409
430
  dismissModal(chooser);
410
431
  this.closeKeyboardChooser();
432
+ this.cleanupInspectorFields();
411
433
  this.cleanupInspector?.();
412
434
  this.cleanupInspector = undefined;
413
435
  this.inspected = undefined;
@@ -481,7 +503,20 @@ export class ProcessModeler extends BaseElement {
481
503
  return this.model.connectionProblem(this.documentValue, { from, to, ...(line ? { ignoreLineId: line.id } : {}) }, this.projection) || null;
482
504
  }
483
505
  requestEdit(edit) {
484
- 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))))
485
520
  return;
486
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))) {
487
522
  this.setStatus('The selected graph items are invalid', true);
@@ -540,6 +575,7 @@ export class ProcessModeler extends BaseElement {
540
575
  }
541
576
  editAcceptance(edit) {
542
577
  const before = this.layout;
578
+ const beforeAutoHeights = new Set(this.autoNoteHeights);
543
579
  const beforeSelection = [...this.selectedIds];
544
580
  const beforeItems = this.selection;
545
581
  const previousIds = new Set(this.projection.boxes.map((box) => box.id));
@@ -553,8 +589,10 @@ export class ProcessModeler extends BaseElement {
553
589
  // replace the document. Publish only after the whole history entry exists.
554
590
  this.acceptanceNotifications++;
555
591
  try {
556
- if (command.layout)
592
+ if (command.layout) {
593
+ this.autoNoteHeights.clear();
557
594
  this.layoutValue = structuredClone(command.layout);
595
+ }
558
596
  if (edit.type === 'reset-line' && edit.lineId)
559
597
  delete this.layoutValue.lines?.[edit.lineId];
560
598
  this.refresh();
@@ -575,9 +613,11 @@ export class ProcessModeler extends BaseElement {
575
613
  if (session !== this.layoutEditSession)
576
614
  return;
577
615
  const after = this.layout;
616
+ const afterAutoHeights = new Set(this.autoNoteHeights);
578
617
  const acceptedItems = command.selection ? command.selection.map(item => ({ type: item.type, id: item.id })) : undefined;
579
618
  const acceptedSelection = command.selectionIds ? [...command.selectionIds] : undefined;
580
- const restore = (layout) => {
619
+ const restore = (layout, autoHeights) => {
620
+ this.autoNoteHeights = new Set(autoHeights);
581
621
  this.layoutValue = structuredClone(layout);
582
622
  this.refresh();
583
623
  this.emitPositions();
@@ -585,7 +625,7 @@ export class ProcessModeler extends BaseElement {
585
625
  this.history.record({
586
626
  undo: () => {
587
627
  command.undo();
588
- restore(before);
628
+ restore(before, beforeAutoHeights);
589
629
  if (acceptedItems)
590
630
  this.selectItems(beforeItems);
591
631
  else if (acceptedSelection)
@@ -593,7 +633,7 @@ export class ProcessModeler extends BaseElement {
593
633
  },
594
634
  redo: () => {
595
635
  command.redo();
596
- restore(after);
636
+ restore(after, afterAutoHeights);
597
637
  if (acceptedItems)
598
638
  this.selectItems(acceptedItems);
599
639
  else if (acceptedSelection)
@@ -677,17 +717,20 @@ export class ProcessModeler extends BaseElement {
677
717
  commitLayout(next) {
678
718
  const before = this.layout;
679
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));
680
722
  if (JSON.stringify(before) === JSON.stringify(after) || this.locked)
681
723
  return;
682
- const apply = (layout) => {
724
+ const apply = (layout, autoHeights) => {
725
+ this.autoNoteHeights = new Set(autoHeights);
683
726
  this.layoutValue = structuredClone(layout);
684
727
  this.refresh();
685
728
  this.emitPositions();
686
729
  };
687
- apply(after);
730
+ apply(after, afterAutoHeights);
688
731
  this.history.record({
689
- undo: () => apply(before),
690
- redo: () => apply(after),
732
+ undo: () => apply(before, beforeAutoHeights),
733
+ redo: () => apply(after, afterAutoHeights),
691
734
  });
692
735
  this.updateToolbar();
693
736
  }
@@ -802,7 +845,7 @@ export class ProcessModeler extends BaseElement {
802
845
  this.commitLayout(next);
803
846
  }
804
847
  routeLine(id, points) {
805
- if (!this.projection.lines.some((line) => line.id === id) ||
848
+ if (!this.projection.lines.some((line) => line.id === id && isFlowLine(line)) ||
806
849
  points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y)))
807
850
  return;
808
851
  this.commitLayout({
@@ -815,7 +858,7 @@ export class ProcessModeler extends BaseElement {
815
858
  }
816
859
  resetLine(id) {
817
860
  const line = this.projection.lines.find(line => line.id === id);
818
- if (this.locked || !line)
861
+ if (this.locked || !line || !isFlowLine(line))
819
862
  return;
820
863
  if (line.fromSide || line.toSide || line.points?.length) {
821
864
  this.requestEdit({ type: 'reset-line', lineId: id });
@@ -1341,7 +1384,10 @@ export class ProcessModeler extends BaseElement {
1341
1384
  const root = this.shadowRoot;
1342
1385
  const selected = this.selected;
1343
1386
  root.querySelector('[part=process-title]').textContent = this.selectedIds.size > 1 ? `${this.selectedIds.size} steps selected` : selected?.title ?? this.processTitleValue;
1344
- 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;
1345
1391
  root.querySelector('[part=pane-tabs]').hidden = Boolean(selected);
1346
1392
  root.querySelectorAll('[part=pane-tabs] button').forEach(button => {
1347
1393
  button.textContent = button.dataset.pane === "Checks" ? `Checks ${this.allChecks.length}` : button.dataset.pane ?? "";
@@ -1591,26 +1637,71 @@ export class ProcessModeler extends BaseElement {
1591
1637
  summary('saved-result-summary', 'Saved by steps', savedRows, 'No step saves its result yet. Set “Save the result as” on a step.');
1592
1638
  }
1593
1639
  else if (this.activePane === "Connections") {
1594
- if (!this.connections.length)
1595
- content.textContent = 'No connections supplied by the host.';
1596
- for (const connection of this.connections) {
1597
- const row = document.createElement('div');
1598
- row.setAttribute('part', 'connection-row');
1599
- const name = document.createElement('strong');
1600
- name.textContent = connection.name;
1601
- const kind = document.createElement('span');
1602
- kind.textContent = `(${connection.kind})${connection.signIn ? ` · ${connection.signIn}` : ''}`;
1603
- row.append(name, document.createTextNode(' '), kind);
1604
- content.append(row);
1640
+ const help = document.createElement('p');
1641
+ help.setAttribute('part', 'connections-help');
1642
+ help.textContent = this.connectionsHelp;
1643
+ content.append(help);
1644
+ if (this.connections.length) {
1645
+ const list = document.createElement('ul');
1646
+ list.setAttribute('part', 'connections-list');
1647
+ for (const connection of this.connections) {
1648
+ const row = document.createElement('li');
1649
+ row.setAttribute('part', 'connection-row');
1650
+ const text = document.createElement('span');
1651
+ const name = document.createElement('b');
1652
+ name.textContent = connection.name;
1653
+ const kind = document.createElement('em');
1654
+ kind.textContent = `${connection.kind}${connection.signIn ? ` · ${connection.signIn}` : ''}`;
1655
+ text.append(name, document.createTextNode(' '), kind);
1656
+ row.append(text);
1657
+ list.append(row);
1658
+ }
1659
+ content.append(list);
1660
+ }
1661
+ else {
1662
+ const empty = document.createElement('p');
1663
+ empty.setAttribute('part', 'connections-help');
1664
+ empty.textContent = 'None yet.';
1665
+ content.append(empty);
1605
1666
  }
1667
+ const actions = document.createElement('div');
1668
+ actions.setAttribute('part', 'connections-actions');
1606
1669
  const setup = document.createElement('button');
1607
1670
  setup.type = 'button';
1608
- setup.textContent = 'Manage connections';
1671
+ setup.textContent = 'Set up connections';
1609
1672
  setup.onclick = () => emit(this, 'connection-setup-request', {});
1610
- content.append(setup);
1673
+ actions.append(setup);
1674
+ content.append(actions);
1611
1675
  }
1612
1676
  else if (this.activePane === "Shortcuts") {
1613
- 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);
1614
1705
  }
1615
1706
  }
1616
1707
  requestVariableEdit(edit) {
@@ -1621,6 +1712,10 @@ export class ProcessModeler extends BaseElement {
1621
1712
  }
1622
1713
  }
1623
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
+ }
1624
1719
  const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
1625
1720
  const compact = shape === 'gateway' || shape === 'event';
1626
1721
  const position = this.layoutValue.boxes[box.id];
@@ -1653,12 +1748,13 @@ export class ProcessModeler extends BaseElement {
1653
1748
  this.arrangeSelection(width >= height ? 'middle' : 'center');
1654
1749
  }
1655
1750
  tidySelection() {
1656
- if (this.locked || this.selectedBoxes.length < 2)
1751
+ const flow = this.selectedBoxes.filter(isFlowBox);
1752
+ if (this.locked || flow.length < 2)
1657
1753
  return;
1658
- if (this.requestLayoutEdit('tidy', [...this.selectedIds]))
1754
+ if (this.requestLayoutEdit('tidy', flow.map(box => box.id)))
1659
1755
  return;
1660
1756
  const next = this.layout;
1661
- const roots = this.selectedBoxes.filter(box => {
1757
+ const roots = this.selectedBoxes.filter(isFlowBox).filter(box => {
1662
1758
  let parent = box.parentId;
1663
1759
  while (parent) {
1664
1760
  if (this.selectedIds.has(parent))
@@ -1710,7 +1806,9 @@ export class ProcessModeler extends BaseElement {
1710
1806
  arrangeSelection(action) {
1711
1807
  if (this.locked || this.selectedBoxes.length < 2)
1712
1808
  return;
1713
- const boxes = this.selectedBoxes;
1809
+ const boxes = this.selectedBoxes.filter(isFlowBox);
1810
+ if (boxes.length < 2)
1811
+ return;
1714
1812
  const next = this.layout;
1715
1813
  const sorted = [...boxes].filter(box => {
1716
1814
  let parent = box.parentId;
@@ -1795,13 +1893,14 @@ export class ProcessModeler extends BaseElement {
1795
1893
  this.commitLayout(next);
1796
1894
  }
1797
1895
  makeSection(title = 'New section') {
1798
- if (this.locked || !this.selectedBoxes.length)
1896
+ const flow = this.selectedBoxes.filter(isFlowBox);
1897
+ if (this.locked || !flow.length)
1799
1898
  return;
1800
- if (this.requestLayoutEdit('make-section', [...this.selectedIds], title))
1899
+ if (this.requestLayoutEdit('make-section', flow.map(box => box.id), title))
1801
1900
  return;
1802
- if (this.selectedBoxes.length < 2)
1901
+ if (flow.length < 2)
1803
1902
  return;
1804
- const positions = this.selectedBoxes.map(box => this.layoutValue.boxes[box.id]);
1903
+ const positions = flow.map(box => this.layoutValue.boxes[box.id]);
1805
1904
  const x = Math.min(...positions.map(position => position.x)) - 24;
1806
1905
  const y = Math.min(...positions.map(position => position.y)) - 48;
1807
1906
  const right = Math.max(...positions.map(position => position.x + (position.width ?? 224))) + 24;
@@ -2042,6 +2141,11 @@ export class ProcessModeler extends BaseElement {
2042
2141
  const target = event.target;
2043
2142
  if (target.closest("input,textarea,select,[contenteditable],button,summary"))
2044
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
+ }
2045
2149
  if (event.key === ' ') {
2046
2150
  event.preventDefault();
2047
2151
  this.spacePressed = true;
@@ -2116,7 +2220,7 @@ export class ProcessModeler extends BaseElement {
2116
2220
  this.requestEdit({ type: 'disconnect', lineId: this.selectedLine.id });
2117
2221
  return;
2118
2222
  }
2119
- 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)) {
2120
2224
  event.preventDefault();
2121
2225
  this.openKeyboardChooser(box);
2122
2226
  return;
@@ -2153,7 +2257,11 @@ export class ProcessModeler extends BaseElement {
2153
2257
  this.commitLayout(next);
2154
2258
  return;
2155
2259
  }
2156
- 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;
2157
2265
  if (direction && !canInsertDirection && (event.altKey || !box || this.locked)) {
2158
2266
  event.preventDefault();
2159
2267
  this.navigateSelection(direction);
@@ -2189,6 +2297,8 @@ export class ProcessModeler extends BaseElement {
2189
2297
  }
2190
2298
  if (direction) {
2191
2299
  event.preventDefault();
2300
+ if (event.ctrlKey && event.altKey && !isFlowBox(box))
2301
+ return;
2192
2302
  if (event.ctrlKey && event.altKey && this.catalog.length && !this.disableConnections) {
2193
2303
  const side = direction[0] < 0 ? "west" : direction[0] > 0 ? "east" : direction[1] < 0 ? "north" : "south";
2194
2304
  const pos = this.layoutValue.boxes[box.id];
@@ -2287,7 +2397,7 @@ export class ProcessModeler extends BaseElement {
2287
2397
  return;
2288
2398
  }
2289
2399
  if (event.button !== 0 ||
2290
- event.target.closest("[part=connection],button,input,select,textarea,[contenteditable]"))
2400
+ event.target.closest("[part=connection],[part=line-hit],button,input,select,textarea,[contenteditable]"))
2291
2401
  return;
2292
2402
  this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
2293
2403
  if (this.pointers.size === 2) {
@@ -2305,7 +2415,7 @@ export class ProcessModeler extends BaseElement {
2305
2415
  this.select(id);
2306
2416
  if (!id && event.shiftKey && !this.locked) {
2307
2417
  const point = this.canvasPoint(event);
2308
- this.marquee = { pointerId: event.pointerId, start: point, point };
2418
+ this.marquee = { pointerId: event.pointerId, start: point, point, add: this.selection };
2309
2419
  canvas.setPointerCapture?.(event.pointerId);
2310
2420
  event.preventDefault();
2311
2421
  return;
@@ -2319,6 +2429,23 @@ export class ProcessModeler extends BaseElement {
2319
2429
  panY: this.viewport.y,
2320
2430
  };
2321
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
+ }
2322
2449
  distance() {
2323
2450
  const [a, b] = [...this.pointers.values()];
2324
2451
  return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
@@ -2389,7 +2516,11 @@ export class ProcessModeler extends BaseElement {
2389
2516
  return;
2390
2517
  }
2391
2518
  if (this.marquee) {
2392
- 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;
2393
2524
  let region = this.shadowRoot.querySelector('[part=marquee]');
2394
2525
  if (!region) {
2395
2526
  region = document.createElement("div");
@@ -2481,6 +2612,11 @@ export class ProcessModeler extends BaseElement {
2481
2612
  const pos = this.layoutValue.boxes[port.id];
2482
2613
  const offsets = { east: [320, 0], west: [-320, 0], north: [0, -170], south: [0, 170] }[port.side];
2483
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
+ }
2484
2620
  const anchor = Array.from(this.shadowRoot.querySelectorAll('[part=port]'))
2485
2621
  .find(element => element.dataset.owner === port.id && element.dataset.side === port.side);
2486
2622
  this.openAnchoredChooser(this.nextEdit(source, port.side, { x: pos.x + offsets[0], y: pos.y + offsets[1] }), this.nextChooserTitle(source, port.side), anchor);
@@ -2491,6 +2627,10 @@ export class ProcessModeler extends BaseElement {
2491
2627
  this.requestEdit({ type: "connect", from: port.id, to: target.id, fromSide: port.side, toSide: this.sideAt(target, point) });
2492
2628
  else {
2493
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
+ }
2494
2634
  const frame = this.boxAt(point, undefined, true);
2495
2635
  if ((frame?.id ?? null) !== (source.parentId ?? null))
2496
2636
  this.setStatus('Drop inside the same frame to add a connected step');
@@ -2533,10 +2673,10 @@ export class ProcessModeler extends BaseElement {
2533
2673
  return;
2534
2674
  }
2535
2675
  if (this.marquee) {
2536
- const { start } = this.marquee;
2676
+ const { start, add } = this.marquee;
2537
2677
  this.marquee = undefined;
2538
2678
  if (commit)
2539
- 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);
2540
2680
  this.shadowRoot.querySelector('[part=marquee]')?.remove();
2541
2681
  this.pointers.delete(event.pointerId);
2542
2682
  return;
@@ -2554,7 +2694,8 @@ export class ProcessModeler extends BaseElement {
2554
2694
  this.suppressClick = true;
2555
2695
  const position = this.alignedPoint(drag.id, drag.position.x + dx / this.viewport.zoom, drag.position.y + dy / this.viewport.zoom);
2556
2696
  const line = this.lineAt(point, drag.id);
2557
- 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);
2558
2699
  if (line) {
2559
2700
  const box = this.projection.boxes.find(box => box.id === drag.id);
2560
2701
  const { width, height } = this.selectionDimensions(box);
@@ -2621,14 +2762,15 @@ export class ProcessModeler extends BaseElement {
2621
2762
  return sides.find(([, x, y]) => Math.hypot(point.x - x, point.y - y) <= 12 / this.viewport.zoom)?.[0];
2622
2763
  }
2623
2764
  lineAt(point, except) {
2624
- 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));
2625
2766
  }
2626
2767
  markDropLine(point, except) {
2627
2768
  this.dropLine = point ? this.lineAt(point, except)?.id : undefined;
2628
2769
  this.paintDropLine();
2629
2770
  }
2630
2771
  paintDropLine() {
2631
- 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'})`); });
2632
2774
  }
2633
2775
  alignedPoint(id, x, y) {
2634
2776
  const world = this.shadowRoot.querySelector('[part=world]');
@@ -2676,14 +2818,14 @@ export class ProcessModeler extends BaseElement {
2676
2818
  }
2677
2819
  chooser.addEventListener('close', () => this.shadowRoot?.querySelector('[part=ghost-box]')?.remove(), { once: true });
2678
2820
  const picker = chooser.querySelector('box-kind-picker');
2679
- picker.catalog = this.catalog.filter(kind => !kind.placement);
2821
+ picker.catalog = this.catalog.filter(kind => kind.addable !== false && !kind.placement);
2680
2822
  picker.refresh();
2681
2823
  promoteModal(chooser);
2682
2824
  picker.focus();
2683
2825
  }
2684
2826
  nextEdit(box, side, position) {
2685
2827
  const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
2686
- 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);
2687
2829
  const placement = side ? { source: 'direction', side } : { source: 'next' };
2688
2830
  if ((!side || side === 'east') && shape !== 'gateway' && outgoing.length === 1)
2689
2831
  return { type: 'insert', lineId: outgoing[0].id, from: box.id, to: outgoing[0].to, placement };
@@ -2710,6 +2852,8 @@ export class ProcessModeler extends BaseElement {
2710
2852
  this.openAnchoredChooser(edit, title, anchor, returnFocus ?? source ?? this.shadowRoot.querySelector('[part=canvas]'));
2711
2853
  }
2712
2854
  openLineChooser(line, returnFocus) {
2855
+ if (!isFlowLine(line))
2856
+ return;
2713
2857
  const source = this.projection.boxes.find(box => box.id === line.from);
2714
2858
  const target = this.projection.boxes.find(box => box.id === line.to);
2715
2859
  const points = this.routedLines.get(line.id) ?? routeProcessLine(line, this.layoutValue, this.projection);
@@ -2736,7 +2880,7 @@ export class ProcessModeler extends BaseElement {
2736
2880
  };
2737
2881
  openKeyboardChooser(box) {
2738
2882
  const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape ?? (box.frame ? 'frame' : 'task');
2739
- 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);
2740
2884
  const target = outgoing.length === 1 ? this.projection.boxes.find(candidate => candidate.id === outgoing[0].to) : undefined;
2741
2885
  const insert = shape !== 'gateway' && outgoing.length === 1 && target;
2742
2886
  const anchor = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
@@ -2753,7 +2897,7 @@ export class ProcessModeler extends BaseElement {
2753
2897
  this.keyboardInsertion = edit;
2754
2898
  chooser.querySelector('[part=chooser-title]').textContent = title;
2755
2899
  const picker = chooser.querySelector('box-kind-picker');
2756
- picker.catalog = this.catalog.filter(kind => !kind.placement);
2900
+ picker.catalog = this.catalog.filter(kind => kind.addable !== false && !kind.placement);
2757
2901
  picker.refresh();
2758
2902
  this.keyboardReturn = returnFocus ?? anchor;
2759
2903
  if (!anchor || !promotePopover(chooser)) {
@@ -2800,6 +2944,7 @@ export class ProcessModeler extends BaseElement {
2800
2944
  else
2801
2945
  this.projection = { boxes: [], lines: [] };
2802
2946
  this.layoutValue = this.arrangedLayout(this.layoutValue);
2947
+ this.measureGraphNotes();
2803
2948
  this.routedLines = new Map(this.projection.lines.map(line => [line.id, routeProcessLine(line, this.layoutValue, this.projection)]));
2804
2949
  this.computedChecks = this.collectChecks();
2805
2950
  if (this.selectedId && !this.selected)
@@ -2812,7 +2957,7 @@ export class ProcessModeler extends BaseElement {
2812
2957
  if (!this.selectedId)
2813
2958
  this.selectedId = this.selectedIds.values().next().value ?? null;
2814
2959
  const serialized = JSON.stringify({
2815
- 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 })),
2816
2961
  lines: this.projection.lines,
2817
2962
  });
2818
2963
  if (this.documentValue !== undefined && (serialized !== this.lastProjection || this.documentValue !== this.lastDocument)) {
@@ -2862,9 +3007,44 @@ export class ProcessModeler extends BaseElement {
2862
3007
  (note ?? this.shadowRoot.querySelector('[part=canvas]'))?.focus({ preventScroll: true });
2863
3008
  }
2864
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
+ }
2865
3045
  get allChecks() { return this.computedChecks; }
2866
3046
  collectChecks() {
2867
- 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)
2868
3048
  .map(line => ({ message: "A connection is blocked by overlapping steps. Move a step or adjust its route.", boxId: line.from }));
2869
3049
  const hostChecks = this.documentValue === undefined ? undefined : this.model.validate?.(this.documentValue, this.projection);
2870
3050
  return [...this.checks, ...(hostChecks ?? graphChecks(this.projection)), ...routes];
@@ -2900,28 +3080,38 @@ export class ProcessModeler extends BaseElement {
2900
3080
  element.setAttribute("part", box.frame ? "frame" : "box");
2901
3081
  element.dataset.boxId = box.id;
2902
3082
  const kind = this.catalog.find((entry) => entry.kind === box.kind);
2903
- const shape = box.shape ?? kind?.shape ?? (box.frame ? "frame" : "task");
3083
+ const shape = !isFlowBox(box) ? "note" : box.shape ?? kind?.shape ?? (box.frame ? "frame" : "task");
2904
3084
  element.dataset.shape = shape;
3085
+ if (!isFlowBox(box))
3086
+ element.setAttribute("aria-roledescription", "note");
2905
3087
  element.dataset.kind = box.kind;
2906
3088
  element.dataset.tone = kind?.tone === "accent" ? "accent" : "neutral";
2907
3089
  const problem = this.allChecks.find(check => check.boxId === box.id || (check.path && JSON.stringify(check.path) === JSON.stringify(box.path)));
2908
- const incoming = this.projection.lines.filter(line => line.to === box.id).length;
2909
- const outgoing = this.projection.lines.filter(line => line.from === box.id).length;
2910
- 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}` : ""]
2911
3093
  .filter(Boolean)
2912
3094
  .join(". "));
2913
3095
  element.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
2914
- 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
+ }
2915
3103
  const icon = document.createElement("span");
2916
3104
  icon.setAttribute("part", "icon");
2917
3105
  icon.setAttribute("aria-hidden", "true");
2918
3106
  if (kind?.icon) {
2919
3107
  icon.append(kind.icon());
2920
3108
  }
2921
- element.append(icon);
3109
+ if (isFlowBox(box))
3110
+ element.append(icon);
2922
3111
  const title = document.createElement("strong");
2923
3112
  title.textContent = box.title;
2924
- element.append(title);
3113
+ if (isFlowBox(box))
3114
+ element.append(title);
2925
3115
  let descriptionParent = element;
2926
3116
  if (shape === 'event' && this.detailValue === 'technical' && box.technicalDetails?.length) {
2927
3117
  const details = document.createElement('div');
@@ -2951,7 +3141,7 @@ export class ProcessModeler extends BaseElement {
2951
3141
  }
2952
3142
  if (shape === 'task' && box.technicalDetails)
2953
3143
  icon.style.gridRow = 'span 4';
2954
- if (this.detailValue === "technical" && box.technicalDetails) {
3144
+ if (isFlowBox(box) && this.detailValue === "technical" && box.technicalDetails) {
2955
3145
  for (const line of box.technicalDetails) {
2956
3146
  if (!line.text && !line.segments?.length)
2957
3147
  continue;
@@ -2974,7 +3164,7 @@ export class ProcessModeler extends BaseElement {
2974
3164
  descriptionParent.append(description);
2975
3165
  }
2976
3166
  }
2977
- else {
3167
+ else if (isFlowBox(box)) {
2978
3168
  const descriptionText = this.detailValue === "technical" ? box.technicalDescription ?? box.description : box.description;
2979
3169
  if (descriptionText) {
2980
3170
  const description = document.createElement("small");
@@ -3059,7 +3249,9 @@ export class ProcessModeler extends BaseElement {
3059
3249
  });
3060
3250
  this.connecting = null;
3061
3251
  }
3062
- 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)
3063
3255
  this.selectMany(this.selectedIds.has(box.id) ? [...this.selectedIds].filter(id => id !== box.id) : [...this.selectedIds, box.id]);
3064
3256
  else
3065
3257
  this.select(box.id);
@@ -3076,6 +3268,11 @@ export class ProcessModeler extends BaseElement {
3076
3268
  port.addEventListener("click", event => {
3077
3269
  event.stopPropagation();
3078
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
+ }
3079
3276
  const pos = this.layoutValue.boxes[box.id];
3080
3277
  const offsets = { east: [320, 0], west: [-320, 0], north: [0, -170], south: [0, 170] }[side];
3081
3278
  this.openAnchoredChooser(this.nextEdit(box, side, { x: pos.x + offsets[0], y: pos.y + offsets[1] }), this.nextChooserTitle(box, side), port);
@@ -3103,6 +3300,11 @@ export class ProcessModeler extends BaseElement {
3103
3300
  element.append(handle);
3104
3301
  }
3105
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
+ }
3106
3308
  }
3107
3309
  const lines = svgElement("svg");
3108
3310
  lines.setAttribute("part", "lines");
@@ -3134,17 +3336,33 @@ export class ProcessModeler extends BaseElement {
3134
3336
  path.setAttribute("part", "line");
3135
3337
  path.dataset.lineId = line.id;
3136
3338
  path.dataset.selected = String(this.selectedLineIds.has(line.id));
3137
- 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'})`);
3138
3341
  path.setAttribute("vector-effect", "non-scaling-stroke");
3139
3342
  path.setAttribute("d", roundedProcessPath(points));
3140
- 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)
3141
3347
  path.setAttribute("stroke-dasharray", "6 4");
3142
- 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) {
3143
3361
  path.style.strokeWidth = `${Math.round((1.5 + Math.max(0, Math.min(1, line.share)) * 2.5) * 100) / 100}px`;
3144
3362
  path.setAttribute('aria-label', `${Math.round(line.share * 100)}% of last-run traffic`);
3145
3363
  }
3146
3364
  lines.append(path);
3147
- 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]) {
3148
3366
  if (!point)
3149
3367
  continue;
3150
3368
  const pin = svgElement('circle');
@@ -3154,7 +3372,7 @@ export class ProcessModeler extends BaseElement {
3154
3372
  pin.setAttribute('r', '3');
3155
3373
  lines.append(pin);
3156
3374
  }
3157
- for (let i = 1; i < points.length - 2; i++) {
3375
+ for (let i = 1; isFlowLine(line) && i < points.length - 2; i++) {
3158
3376
  const hit = svgElement("polyline");
3159
3377
  hit.setAttribute("part", "line-hit");
3160
3378
  hit.dataset.lineId = line.id;
@@ -3165,6 +3383,8 @@ export class ProcessModeler extends BaseElement {
3165
3383
  }
3166
3384
  const label = document.createElement("div");
3167
3385
  label.setAttribute("part", "connection");
3386
+ label.dataset.lineId = line.id;
3387
+ label.dataset.role = line.role ?? "flow";
3168
3388
  label.setAttribute('role', 'group');
3169
3389
  label.tabIndex = 0;
3170
3390
  label.style.left = `${midpoint.x}px`;
@@ -3172,17 +3392,20 @@ export class ProcessModeler extends BaseElement {
3172
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}` : ""}`;
3173
3393
  label.setAttribute('aria-label', `Connection: ${names}${this.showLastRunValue && line.share !== undefined ? `. ${Math.round(line.share * 100)}% of last-run traffic` : ''}`);
3174
3394
  label.addEventListener('click', event => { if (event.target.closest('button'))
3175
- 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); });
3176
3399
  label.addEventListener('keydown', event => { if (event.target === label && (event.key === 'Enter' || event.key === ' ')) {
3177
3400
  event.preventDefault();
3178
3401
  this.selectLine(line.id);
3179
3402
  } });
3180
3403
  const text = document.createElement("span");
3181
3404
  const source = this.projection.boxes.find(box => box.id === line.from);
3182
- const branchIndex = this.projection.lines.filter(candidate => candidate.from === line.from).indexOf(line);
3183
- 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" : "");
3184
3407
  label.append(text);
3185
- if (this.showLastRunValue && line.share !== undefined) {
3408
+ if (isFlowLine(line) && this.showLastRunValue && line.share !== undefined) {
3186
3409
  const share = document.createElement('small');
3187
3410
  share.textContent = `${Math.round(line.share * 100)}%`;
3188
3411
  label.append(share);
@@ -3190,7 +3413,7 @@ export class ProcessModeler extends BaseElement {
3190
3413
  const actions = document.createElement("div");
3191
3414
  actions.setAttribute("part", "connection-actions");
3192
3415
  label.append(actions);
3193
- if (points.length >= 4 && !this.locked) {
3416
+ if (isFlowLine(line) && points.length >= 4 && !this.locked) {
3194
3417
  const adjust = document.createElement("button");
3195
3418
  adjust.type = "button";
3196
3419
  adjust.textContent = "Route";
@@ -3220,7 +3443,7 @@ export class ProcessModeler extends BaseElement {
3220
3443
  ["insert", "Insert a step here"],
3221
3444
  ["disconnect", "Remove connection"],
3222
3445
  ]) {
3223
- if (type === "disconnect" && this.disableConnections)
3446
+ if ((type === "disconnect" && this.disableConnections) || (type === "insert" && !isFlowLine(line)))
3224
3447
  continue;
3225
3448
  const button = document.createElement("button");
3226
3449
  button.type = "button";
@@ -3244,7 +3467,7 @@ export class ProcessModeler extends BaseElement {
3244
3467
  actions.append(button);
3245
3468
  }
3246
3469
  world.append(label);
3247
- if (this.selectedLineId === line.id && !this.locked && !this.disableConnections) {
3470
+ if (isFlowLine(line) && this.selectedLineId === line.id && !this.locked && !this.disableConnections) {
3248
3471
  for (const [end, point] of [['from', points[0]], ['to', points.at(-1)]]) {
3249
3472
  const grip = document.createElement('button');
3250
3473
  grip.type = 'button';
@@ -3299,7 +3522,7 @@ export class ProcessModeler extends BaseElement {
3299
3522
  return;
3300
3523
  }
3301
3524
  const selected = this.selectedIds.size === 1 ? this.selected : null;
3302
- 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;
3303
3526
  if (from && selected)
3304
3527
  this.requestEdit({ ...this.nextEdit(selected), kind });
3305
3528
  else {
@@ -3311,7 +3534,7 @@ export class ProcessModeler extends BaseElement {
3311
3534
  paletteLineAt(point) {
3312
3535
  let nearest;
3313
3536
  let best = 20 / this.viewport.zoom;
3314
- for (const line of this.projection.lines) {
3537
+ for (const line of this.projection.lines.filter(isFlowLine)) {
3315
3538
  const points = this.routedLines.get(line.id) ?? [];
3316
3539
  for (let i = 1; i < points.length; i++) {
3317
3540
  const a = points[i - 1], b = points[i], dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy;
@@ -3435,7 +3658,7 @@ export class ProcessModeler extends BaseElement {
3435
3658
  const query = this.shadowRoot.querySelector("[part=search]").value.toLowerCase();
3436
3659
  choices.replaceChildren();
3437
3660
  let group = "";
3438
- 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(" ")}`
3439
3662
  .toLowerCase()
3440
3663
  .includes(query))) {
3441
3664
  if (kind.group && kind.group !== group) {
@@ -3571,16 +3794,99 @@ export class ProcessModeler extends BaseElement {
3571
3794
  checks.append(button);
3572
3795
  });
3573
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
+ }
3574
3871
  renderField(box, field, helpOpen = false) {
3575
3872
  const row = document.createElement('div');
3576
3873
  row.setAttribute('part', 'field');
3577
3874
  const label = document.createElement('label');
3578
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
+ }
3579
3884
  let control;
3580
3885
  if (field.kind === 'multiline' || (field.kind === 'expression' && (field.expression?.rows ?? 1) > 1)) {
3581
3886
  control = document.createElement('textarea');
3582
- if (field.expression?.rows)
3583
- 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));
3584
3890
  }
3585
3891
  else if (field.kind === 'choice') {
3586
3892
  const select = document.createElement('select');
@@ -3606,7 +3912,7 @@ export class ProcessModeler extends BaseElement {
3606
3912
  }
3607
3913
  else {
3608
3914
  const input = document.createElement('input');
3609
- 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';
3610
3916
  control = input;
3611
3917
  }
3612
3918
  control.dataset.field = field.key;
@@ -3619,6 +3925,18 @@ export class ProcessModeler extends BaseElement {
3619
3925
  control.required = Boolean(field.required);
3620
3926
  if (field.placeholder && (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement))
3621
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
+ }
3622
3940
  if (field.kind === 'expression') {
3623
3941
  control.spellcheck = false;
3624
3942
  control.setAttribute('autocomplete', 'off');
@@ -3626,17 +3944,27 @@ export class ProcessModeler extends BaseElement {
3626
3944
  }
3627
3945
  if (field.problem)
3628
3946
  control.setAttribute('aria-invalid', 'true');
3629
- 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
+ };
3630
3953
  if (field.kind !== 'action')
3631
3954
  control.addEventListener(field.kind === 'expression' ? 'input' : 'change', submit);
3632
- label.append(control);
3955
+ if (field.kind === 'boolean') {
3956
+ row.dataset.boolean = '';
3957
+ label.prepend(control);
3958
+ }
3959
+ else
3960
+ label.append(control);
3633
3961
  row.append(label);
3634
3962
  if (field.kind === 'action' && control instanceof HTMLInputElement) {
3635
3963
  const input = control;
3636
3964
  const options = field.options ?? [];
3637
3965
  const list = document.createElement('div');
3638
3966
  list.setAttribute('part', 'action-options');
3639
- list.id = `process-action-${box.id}-${field.key}`.replace(/[^a-zA-Z0-9-]/g, '-');
3967
+ list.id = `process-action-${++actionControlSequence}`;
3640
3968
  list.hidden = true;
3641
3969
  const optionList = document.createElement('div');
3642
3970
  optionList.setAttribute('role', 'listbox');
@@ -3647,106 +3975,234 @@ export class ProcessModeler extends BaseElement {
3647
3975
  input.setAttribute('aria-controls', optionList.id);
3648
3976
  input.setAttribute('aria-expanded', 'false');
3649
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;
3650
3981
  let group = null;
3651
3982
  let active = 0;
3652
- let browse = false;
3653
- const close = () => { list.hidden = true; browse = false; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); };
3654
- 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
+ };
3655
4025
  const render = () => {
3656
4026
  list.replaceChildren();
3657
4027
  optionList.replaceChildren();
3658
4028
  const query = browse ? '' : input.value.trim().toLocaleLowerCase();
3659
- const matches = options.filter(option => (!group || option.group === group) && (!query || `${option.label} ${option.value} ${option.group ?? ''}`.toLocaleLowerCase().includes(query)));
3660
- if (!group && !query) {
3661
- const groups = [...new Set(options.map(option => option.group).filter((name) => Boolean(name)))];
3662
- for (const name of groups) {
3663
- const button = document.createElement('button');
3664
- button.type = 'button';
3665
- button.setAttribute('part', 'action-group');
3666
- button.textContent = name;
3667
- button.onclick = () => { input.focus(); group = name; browse = true; render(); };
3668
- 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(); });
3669
4075
  }
3670
4076
  }
3671
4077
  if (group) {
3672
- const back = document.createElement('button');
3673
- back.type = 'button';
3674
- back.setAttribute('part', 'action-back');
3675
- back.textContent = `All actions / ${group}`;
3676
- back.onclick = () => { input.focus(); group = null; browse = true; render(); };
3677
- 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);
3678
4080
  }
3679
- for (const [index, option] of matches.entries()) {
4081
+ const addAction = (option, custom = false) => {
3680
4082
  const item = document.createElement('button');
3681
4083
  item.type = 'button';
4084
+ item.tabIndex = -1;
3682
4085
  item.setAttribute('role', 'option');
3683
4086
  item.setAttribute('part', 'action-option');
3684
- item.id = `${list.id}-${index}`;
3685
- item.setAttribute('aria-selected', String(index === active));
3686
4087
  const title = document.createElement('strong');
3687
- 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;
3688
4089
  item.append(title);
3689
- 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) {
3690
4098
  const code = document.createElement('small');
3691
- code.textContent = option.value;
4099
+ code.textContent = detail;
4100
+ if (!custom)
4101
+ code.dataset.format = 'code';
3692
4102
  item.append(code);
3693
4103
  }
3694
4104
  item.onclick = () => choose(option);
3695
4105
  optionList.append(item);
3696
- }
3697
- 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]')) {
3698
4112
  const empty = document.createElement('p');
3699
- 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”.';
3700
4115
  list.append(empty);
3701
4116
  }
3702
4117
  list.append(optionList);
3703
- 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)); });
3704
4121
  list.hidden = false;
3705
4122
  input.setAttribute('aria-expanded', 'true');
3706
- const selected = list.querySelectorAll('[role=option]')[active];
3707
- if (selected)
3708
- input.setAttribute('aria-activedescendant', selected.id);
4123
+ if (items[active])
4124
+ input.setAttribute('aria-activedescendant', items[active].id);
3709
4125
  else
3710
4126
  input.removeAttribute('aria-activedescendant');
4127
+ place();
4128
+ items[active]?.scrollIntoView?.({ block: 'nearest' });
3711
4129
  };
3712
- input.addEventListener('focus', () => { group = null; browse = false; render(); });
3713
- 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(); });
3714
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
+ }
3715
4156
  if (event.key === 'Escape') {
3716
4157
  event.preventDefault();
4158
+ event.stopPropagation();
4159
+ close();
4160
+ return;
4161
+ }
4162
+ if (event.key === 'Tab') {
3717
4163
  close();
3718
4164
  return;
3719
4165
  }
3720
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
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')) {
3721
4170
  event.preventDefault();
3722
- if (list.hidden)
3723
- render();
3724
- const items = Array.from(list.querySelectorAll('[role=option]'));
3725
- if (!items.length)
3726
- return;
3727
- 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;
3728
4172
  items.forEach((item, index) => item.setAttribute('aria-selected', String(index === active)));
3729
4173
  input.setAttribute('aria-activedescendant', items[active].id);
3730
4174
  items[active].scrollIntoView?.({ block: 'nearest' });
3731
4175
  }
3732
- else if (event.key === 'Enter' && !list.hidden) {
3733
- const items = Array.from(list.querySelectorAll('[role=option]'));
4176
+ else if (event.key === 'Enter' || event.key === 'ArrowRight' && items[active]?.getAttribute('part') === 'action-group') {
3734
4177
  if (items[active]) {
3735
4178
  event.preventDefault();
3736
4179
  items[active].click();
3737
4180
  }
3738
4181
  }
4182
+ else if (event.key === 'ArrowLeft' && group && input.value === shown) {
4183
+ event.preventDefault();
4184
+ list.querySelector('[part=action-back]')?.click();
4185
+ }
3739
4186
  });
3740
4187
  const caret = document.createElement('button');
3741
4188
  caret.type = 'button';
3742
4189
  caret.setAttribute('part', 'action-caret');
3743
4190
  caret.setAttribute('aria-label', `Browse ${field.label.toLowerCase()} choices`);
3744
- 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);
3745
4195
  caret.disabled = input.disabled;
3746
- 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(); };
3747
4197
  row.addEventListener('focusout', event => { if (!row.contains(event.relatedTarget))
3748
4198
  close(); });
3749
- 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);
3750
4206
  }
3751
4207
  if (field.kind === 'search' && field.options?.length && control instanceof HTMLInputElement) {
3752
4208
  const options = document.createElement('datalist');
@@ -3801,10 +4257,10 @@ export class ProcessModeler extends BaseElement {
3801
4257
  describedBy.push(help.id);
3802
4258
  }
3803
4259
  const feedback = field.problem || (field.kind === 'expression' ? field.expression?.feedback?.message : undefined);
3804
- if (feedback) {
4260
+ if (feedback || (field.kind === 'expression' && field.expression?.feedback !== undefined)) {
3805
4261
  const message = document.createElement('small');
3806
4262
  message.id = `${messageId}-feedback`;
3807
- message.textContent = feedback;
4263
+ message.textContent = feedback ?? '';
3808
4264
  message.setAttribute('part', field.problem ? 'field-problem' : 'field-feedback');
3809
4265
  if (field.kind === 'expression')
3810
4266
  message.setAttribute('aria-live', 'polite');
@@ -3814,6 +4270,14 @@ export class ProcessModeler extends BaseElement {
3814
4270
  row.insertBefore(message, chips);
3815
4271
  describedBy.push(message.id);
3816
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
+ }
3817
4281
  if (describedBy.length)
3818
4282
  control.setAttribute('aria-describedby', describedBy.join(' '));
3819
4283
  if (field.kind === 'expression' && field.expression?.help) {
@@ -3845,11 +4309,118 @@ export class ProcessModeler extends BaseElement {
3845
4309
  }
3846
4310
  return row;
3847
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
+ }
3848
4412
  renderSelection() {
3849
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); });
3850
4414
  if (!this.isRendered)
3851
4415
  return;
3852
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;
3853
4424
  const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable ?? focusedControl?.dataset.localVariable;
3854
4425
  const variableKey = focusedControl?.dataset.variableKey;
3855
4426
  const fieldBox = focusedControl?.dataset.fieldBox;
@@ -3860,6 +4431,22 @@ export class ProcessModeler extends BaseElement {
3860
4431
  const direction = focusedControl && 'selectionDirection' in focusedControl ? focusedControl.selectionDirection : null;
3861
4432
  const scrollTop = focusedControl?.scrollTop, scrollLeft = focusedControl?.scrollLeft;
3862
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
+ }
3863
4450
  if (focusedKey === undefined || !focusedType)
3864
4451
  return;
3865
4452
  if (focusedType === 'field' && (this.selected?.id !== fieldBox || this.locked))
@@ -3896,8 +4483,23 @@ export class ProcessModeler extends BaseElement {
3896
4483
  }
3897
4484
  if (!control || control.disabled || control.closest('[inert]'))
3898
4485
  return;
3899
- control.focus({ preventScroll: true });
3900
- 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)
3901
4503
  control.setSelectionRange(caret, selectionEnd ?? caret, direction ?? undefined);
3902
4504
  if (scrollTop !== undefined)
3903
4505
  control.scrollTop = scrollTop;
@@ -3905,7 +4507,8 @@ export class ProcessModeler extends BaseElement {
3905
4507
  control.scrollLeft = scrollLeft;
3906
4508
  };
3907
4509
  this.shadowRoot.querySelectorAll('[part=minimap] [data-map-box]').forEach(rect => rect.dataset.selected = String(this.selectedIds.has(rect.dataset.mapBox)));
3908
- 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'})`); });
3909
4512
  this.shadowRoot.querySelectorAll("[data-box-id]").forEach((element) => {
3910
4513
  element.setAttribute("aria-current", String(this.selectedIds.has(element.dataset.boxId)));
3911
4514
  const box = this.projection.boxes.find((box) => box.id === element.dataset.boxId);
@@ -3916,7 +4519,7 @@ export class ProcessModeler extends BaseElement {
3916
4519
  const editor = this.shadowRoot.querySelector("[part=editor]");
3917
4520
  const selected = this.selected;
3918
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;
3919
- 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] ?? [] : [])}`;
3920
4523
  this.shadowRoot.querySelector("[part=palette]").hidden = false;
3921
4524
  this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selection.length === 0;
3922
4525
  this.renderSelectionToolbar();
@@ -3926,23 +4529,31 @@ export class ProcessModeler extends BaseElement {
3926
4529
  this.inspectedId === selected?.id &&
3927
4530
  this.inspectedControls === controlsKey &&
3928
4531
  editor.querySelector(`[part=inspector-heading]`)?.tagName === `H${this.headingLevel}`) {
3929
- 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 ?? "";
3930
4533
  const locals = editor.querySelector('[part=local-variables]');
3931
- if (selected && locals && locals.dataset.localEchoRevision !== String(this.localEchoRevision)) {
4534
+ if (selected && this.selectedIds.size === 1 && locals && locals.dataset.localEchoRevision !== String(this.localEchoRevision)) {
3932
4535
  // Reconcile controlled local drafts without disposing the host's custom
3933
4536
  // inspector or unrelated focused controls on unchanged document echoes.
3934
4537
  locals.replaceWith(this.renderLocalVariables(selected));
3935
4538
  }
4539
+ if (selected && this.selectedIds.size === 1)
4540
+ this.renderLeadsTo(editor, selected);
3936
4541
  restoreControlFocus();
3937
4542
  return;
3938
4543
  }
3939
4544
  const expressionHelp = new Map();
4545
+ const disclosures = new Map();
3940
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
+ }
3941
4551
  for (const help of Array.from(editor.querySelectorAll('[part=expression-help]'))) {
3942
4552
  if (help.dataset.session === String(this.layoutEditSession))
3943
4553
  expressionHelp.set(help.dataset.helpField, help.open);
3944
4554
  }
3945
4555
  }
4556
+ this.cleanupInspectorFields();
3946
4557
  this.cleanupInspector?.();
3947
4558
  this.cleanupInspector = undefined;
3948
4559
  editor.replaceChildren();
@@ -3991,44 +4602,13 @@ export class ProcessModeler extends BaseElement {
3991
4602
  purpose.textContent = selected.description;
3992
4603
  editor.append(purpose);
3993
4604
  }
3994
- for (const field of this.fieldsValue[selected.id] ?? [])
3995
- editor.append(this.renderField(selected, field, expressionHelp.get(field.key)));
4605
+ this.renderFields(editor, selected, expressionHelp, disclosures);
3996
4606
  if (selected.localVariables !== undefined)
3997
4607
  editor.append(this.renderLocalVariables(selected));
3998
4608
  this.cleanupInspector =
3999
4609
  this.renderer?.(selected.node, editor) || undefined;
4000
- const outgoing = this.projection.lines.filter(line => line.from === selected.id);
4001
- const leads = document.createElement('section');
4002
- leads.setAttribute('part', 'leads-to');
4003
- const leadsHeading = document.createElement('h3');
4004
- leadsHeading.textContent = 'Leads to';
4005
- leads.append(leadsHeading);
4006
- for (const line of outgoing) {
4007
- const row = document.createElement('div');
4008
- row.setAttribute('part', 'lead-row');
4009
- const target = this.projection.boxes.find(box => box.id === line.to);
4010
- const name = document.createElement('span');
4011
- name.textContent = `${line.label ? `${line.label}: ` : ''}${target?.title ?? line.to}`;
4012
- row.append(name);
4013
- if (!this.disableConnections) {
4014
- const remove = document.createElement('button');
4015
- remove.type = 'button';
4016
- remove.textContent = 'Remove';
4017
- remove.setAttribute('aria-label', `Remove connection to ${target?.title ?? line.to}`);
4018
- remove.disabled = this.locked;
4019
- remove.onclick = () => this.requestEdit({ type: 'disconnect', lineId: line.id });
4020
- row.append(remove);
4021
- }
4022
- leads.append(row);
4023
- }
4024
- const connect = document.createElement('button');
4025
- connect.type = 'button';
4026
- connect.textContent = 'Connect to…';
4027
- connect.disabled = this.locked || this.disableConnections;
4028
- connect.onclick = () => { this.connecting = selected.id; this.setStatus(`Select the next box to connect from ${selected.title}`); };
4029
- leads.append(connect);
4030
- editor.append(leads);
4031
- 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;
4032
4612
  if (metrics) {
4033
4613
  const report = document.createElement('section');
4034
4614
  report.setAttribute('part', 'inspector-metrics');
@@ -4062,22 +4642,25 @@ export class ProcessModeler extends BaseElement {
4062
4642
  }
4063
4643
  report.append(list);
4064
4644
  }
4065
- editor.insertBefore(report, leads);
4645
+ editor.insertBefore(report, editor.querySelector('[part=leads-to]'));
4066
4646
  }
4067
4647
  const actions = document.createElement('div');
4068
4648
  actions.setAttribute('part', 'inspector-actions');
4069
- const duplicate = document.createElement('button');
4070
- duplicate.type = 'button';
4071
- duplicate.textContent = 'Duplicate';
4072
- duplicate.disabled = this.locked;
4073
- duplicate.onclick = () => this.duplicateBoxes([selected.id], 48);
4074
- const remove = document.createElement('button');
4075
- remove.type = 'button';
4076
- remove.textContent = 'Delete';
4077
- remove.disabled = this.locked;
4078
- remove.onclick = () => this.requestEdit({ type: 'delete', boxId: selected.id });
4079
- actions.append(duplicate, remove);
4080
- 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);
4081
4664
  }
4082
4665
  restoreControlFocus();
4083
4666
  }
@@ -4187,7 +4770,7 @@ export class ProcessModeler extends BaseElement {
4187
4770
  }
4188
4771
  renderSelectionToolbar() {
4189
4772
  const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
4190
- 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(',')}`;
4191
4774
  if (key === this.selectionToolbarKey)
4192
4775
  return;
4193
4776
  this.selectionToolbarKey = key;
@@ -4202,10 +4785,10 @@ export class ProcessModeler extends BaseElement {
4202
4785
  button.disabled = this.locked;
4203
4786
  button.onclick = action;
4204
4787
  if (withPlus) {
4205
- const icon = document.createElement('span');
4788
+ const icon = variableGlyph(true);
4206
4789
  icon.setAttribute('part', 'selection-plus');
4207
4790
  icon.setAttribute('aria-hidden', 'true');
4208
- icon.textContent = '+';
4791
+ icon.setAttribute('focusable', 'false');
4209
4792
  button.append(icon);
4210
4793
  }
4211
4794
  const text = document.createElement('span');
@@ -4214,24 +4797,27 @@ export class ProcessModeler extends BaseElement {
4214
4797
  toolbar.append(button);
4215
4798
  };
4216
4799
  if (this.graphSelection && (this.selectedNoteIds.size || this.selectedLineIds.size > 1 || this.selectedLineIds.size && this.selectedIds.size)) {
4217
- if (this.selectedIds.size > 1)
4800
+ if (this.selectedIds.size) {
4218
4801
  add('Line up', 'align', () => this.selectionCommand('align'));
4802
+ add('Tidy these', 'space', () => this.selectionCommand('space'));
4803
+ }
4219
4804
  add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
4220
4805
  }
4221
4806
  else if (this.selectedLine) {
4222
4807
  const line = this.selectedLine;
4223
- add('Insert a step', 'insert', () => this.openLineChooser(line, toolbar.querySelector('[data-selection-command=insert]') ?? undefined), true);
4224
- 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))
4225
4811
  add('Reset line', 'reset-line', () => this.resetLine(line.id));
4226
4812
  if (!this.disableConnections)
4227
4813
  add('Delete', 'delete-line', () => this.requestEdit({ type: 'disconnect', lineId: line.id }));
4228
4814
  }
4229
4815
  else if (this.selectedIds.size === 1) {
4230
4816
  const box = this.selected;
4231
- 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'));
4232
4818
  if (this.narrowValue)
4233
4819
  add('Details', 'details', () => this.openDrawer('inspector'));
4234
- if (!['end', 'finish', 'section', 'note'].includes(box.kind))
4820
+ if (isFlowBox(box) && !['end', 'finish', 'section', 'note'].includes(box.kind))
4235
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);
4236
4822
  if (failurePath)
4237
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);
@@ -4356,9 +4942,7 @@ export class ProcessModeler extends BaseElement {
4356
4942
  positions.push(...(this.layoutValue.notes ?? []).filter(note => this.selectedNoteIds.has(note.id)).map(note => ({ ...note, width: 208, height: 80 })));
4357
4943
  if (!positions.length && this.selectedLine) {
4358
4944
  const point = lineMidpoint(this.routedLines.get(this.selectedLine.id) ?? []);
4359
- 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`;
4360
- toolbar.style.top = `${Math.max(8, point.y * this.viewport.zoom + this.viewport.y - 52)}px`;
4361
- return;
4945
+ positions.push({ x: point.x, y: point.y - 14 / this.viewport.zoom, width: 0, height: 0 });
4362
4946
  }
4363
4947
  if (!positions.length) {
4364
4948
  for (const id of this.selectedLineIds) {
@@ -4372,10 +4956,11 @@ export class ProcessModeler extends BaseElement {
4372
4956
  if (!positions.length)
4373
4957
  return;
4374
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));
4375
4960
  const top = Math.min(...positions.map(position => position.y * this.viewport.zoom + this.viewport.y));
4376
4961
  const bottom = Math.max(...positions.map(position => (position.y + (position.height ?? 64)) * this.viewport.zoom + this.viewport.y));
4377
- toolbar.style.left = `${Math.max(8, Math.min(left, Math.max(8, canvas.clientWidth - toolbar.offsetWidth - 8)))}px`;
4378
- 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`;
4379
4964
  }
4380
4965
  focusBox(id) {
4381
4966
  Array.from(this.shadowRoot.querySelectorAll("[data-box-id]"))