@unofficialbox/box-open-elements 0.25.0 → 0.27.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.
@@ -1,10 +1,10 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
2
  import { announce } from "../../foundations/a11y/index.js";
3
- import { boeFocusVisibleStyles } from "../../foundations/tokens/interaction.js";
4
3
  import { dismissModal, promoteModal } from "../../foundations/overlay/index.js";
5
4
  import { KindPicker } from "../flow-builder/primitives.js";
6
- import { arrangeProcess, routeProcessLine, lineMidpoint, nearProcessLine } from "./geometry.js";
5
+ import { arrangeProcess, routeProcessLine, lineMidpoint, nearProcessLine, roundedProcessPath } from "./geometry.js";
7
6
  import { restoreProcessPositions, snapshotProcessPositions, graphChecks, normalizeProcessSelectionPath } from "./bridge.js";
7
+ import { processModelerDesign } from "./design.js";
8
8
  import { completeLayout, ProcessHistory, validateProjection, } from "./model.js";
9
9
  const svgElement = (tag) => document.createElementNS("http://www.w3.org/2000/svg", tag);
10
10
  const emit = (element, name, detail) => element.dispatchEvent(new CustomEvent(name, {
@@ -17,7 +17,7 @@ const emit = (element, name, detail) => element.dispatchEvent(new CustomEvent(na
17
17
  export class ProcessModeler extends BaseElement {
18
18
  static tagName = "box-process-modeler";
19
19
  static get observedAttributes() {
20
- return ["locked", "snap-to-grid", "heading-level", "disable-connections"];
20
+ return ["locked", "snap-to-grid", "heading-level", "disable-connections", "embed-mode"];
21
21
  }
22
22
  documentValue;
23
23
  modelValue = {
@@ -26,13 +26,18 @@ export class ProcessModeler extends BaseElement {
26
26
  projection = { boxes: [], lines: [] };
27
27
  layoutValue = { boxes: {} };
28
28
  catalogValue = [];
29
+ detailValue = "business";
30
+ lastRunValue;
31
+ showLastRunValue = false;
29
32
  selectedId = null;
33
+ selectedLineId = null;
30
34
  connecting = null;
31
35
  checks = [];
32
36
  renderer;
33
37
  cleanupInspector;
34
38
  inspected;
35
39
  inspectedId;
40
+ inspectedControls = "";
36
41
  historyValue = new ProcessHistory();
37
42
  viewport = { x: 0, y: 0, zoom: 1 };
38
43
  pointers = new Map();
@@ -41,22 +46,37 @@ export class ProcessModeler extends BaseElement {
41
46
  gestureZoom = 1;
42
47
  insertion;
43
48
  selectedIds = new Set();
49
+ clipboardIds = [];
50
+ selectionToolbarKey = "";
51
+ sectionSequence = 0;
44
52
  versionValue = 0;
45
53
  lastProjection = "";
54
+ lastReadableProjection = "";
55
+ readableValue = null;
46
56
  lastDocument;
47
57
  connectionsValue = [];
48
58
  variablesValue = [];
59
+ outlineValue = [];
60
+ fieldsValue = {};
61
+ processTitleValue = "Process";
62
+ processSummaryValue = "";
49
63
  activePane = "Outline";
50
64
  portDrag;
65
+ endDrag;
66
+ pendingReattach;
51
67
  marquee;
52
68
  segmentDrag;
69
+ frameResize;
70
+ spacePressed = false;
53
71
  dropLine;
72
+ paletteDragKind = null;
54
73
  narrowValue = false;
55
74
  suppressClick = false;
56
75
  resizeObserver;
57
76
  routedLines = new Map();
58
77
  computedChecks = [];
59
78
  get selectedBoxes() { return this.projection.boxes.filter(box => this.selectedIds.has(box.id)); }
79
+ get selectedLine() { return this.projection.lines.find(line => line.id === this.selectedLineId) ?? null; }
60
80
  get selectedPath() { return this.selected?.path ? [...this.selected.path] : null; }
61
81
  set selectedPath(path) {
62
82
  if (!this.isRendered && this.documentValue !== undefined) {
@@ -70,9 +90,29 @@ export class ProcessModeler extends BaseElement {
70
90
  set connections(value) { this.connectionsValue = value; this.refresh(); }
71
91
  get variables() { return this.variablesValue; }
72
92
  set variables(value) { this.variablesValue = value; this.refresh(); }
93
+ get outline() { return this.outlineValue; }
94
+ set outline(value) { this.outlineValue = value; this.refresh(); }
95
+ get fields() { return this.fieldsValue; }
96
+ set fields(value) { this.fieldsValue = value; this.inspected = undefined; this.refresh(); }
97
+ get processTitle() { return this.processTitleValue; }
98
+ set processTitle(value) { this.processTitleValue = value; this.refresh(); }
99
+ get processSummary() { return this.processSummaryValue; }
100
+ set processSummary(value) { this.processSummaryValue = value; this.refresh(); }
101
+ get view() { return { ...this.viewport }; }
102
+ setView(view) {
103
+ if (![view.x, view.y, view.zoom].every(Number.isFinite) || view.zoom < 0.25 || view.zoom > 2)
104
+ return;
105
+ this.viewport = { ...view };
106
+ if (this.isRendered)
107
+ this.paintViewport();
108
+ }
73
109
  get version() { return this.versionValue; }
74
110
  get narrow() { return this.narrowValue; }
75
111
  get positions() { return snapshotProcessPositions(this.projection, this.layoutValue); }
112
+ /** Current drawing and last valid drawing; hosts own conversion and persistence. */
113
+ get readback() {
114
+ return { current: this.allChecks.length ? null : this.projection, lastReadable: this.readableValue, checks: [...this.allChecks], version: this.versionValue };
115
+ }
76
116
  load(document, options = {}) {
77
117
  const projection = this.model.project(document);
78
118
  validateProjection(projection);
@@ -80,10 +120,14 @@ export class ProcessModeler extends BaseElement {
80
120
  this.projection = projection;
81
121
  this.versionValue = options.version ?? 0;
82
122
  this.lastProjection = "";
123
+ this.lastReadableProjection = "";
124
+ this.readableValue = null;
83
125
  this.layoutValue = restoreProcessPositions(projection, options.positions ?? []);
84
126
  this.history.clear();
85
127
  this.selectedId = null;
128
+ this.selectedLineId = null;
86
129
  this.selectedIds.clear();
130
+ this.clipboardIds = [];
87
131
  this.refresh();
88
132
  if (options.selectedPath)
89
133
  this.selectedPath = options.selectedPath;
@@ -98,6 +142,7 @@ export class ProcessModeler extends BaseElement {
98
142
  return;
99
143
  this.selectedIds = next;
100
144
  this.selectedId = this.selectedIds.values().next().value ?? null;
145
+ this.selectedLineId = null;
101
146
  this.renderSelection();
102
147
  if (this.isRendered)
103
148
  this.updateToolbar();
@@ -107,6 +152,9 @@ export class ProcessModeler extends BaseElement {
107
152
  set headingLevel(value) { this.setAttribute("heading-level", String(value)); }
108
153
  get disableConnections() { return this.hasAttribute("disable-connections"); }
109
154
  set disableConnections(value) { this.toggleAttribute("disable-connections", value); }
155
+ /** Hide duplicate process heading/view controls when the host supplies them. */
156
+ get embedMode() { return this.hasAttribute('embed-mode'); }
157
+ set embedMode(value) { this.toggleAttribute('embed-mode', value); }
110
158
  arrangedLayout(layout) {
111
159
  const arranged = this.model.arrange?.(this.projection) ?? arrangeProcess(this.projection);
112
160
  return completeLayout(this.projection, { ...layout, boxes: { ...arranged?.boxes, ...layout.boxes } });
@@ -150,6 +198,18 @@ export class ProcessModeler extends BaseElement {
150
198
  this.catalogValue = value;
151
199
  this.refresh();
152
200
  }
201
+ get detail() { return this.detailValue; }
202
+ set detail(value) {
203
+ if (value === this.detailValue)
204
+ return;
205
+ this.detailValue = value;
206
+ this.refresh();
207
+ emit(this, "detail-changed", { detail: value });
208
+ }
209
+ get lastRun() { return this.lastRunValue; }
210
+ set lastRun(value) { this.lastRunValue = value; this.inspected = undefined; this.refresh(); }
211
+ get showLastRun() { return this.showLastRunValue; }
212
+ set showLastRun(value) { this.showLastRunValue = Boolean(value); this.inspected = undefined; this.refresh(); }
153
213
  get locked() {
154
214
  return this.hasAttribute("locked");
155
215
  }
@@ -187,6 +247,8 @@ export class ProcessModeler extends BaseElement {
187
247
  this.pointers.clear();
188
248
  this.drag = undefined;
189
249
  this.portDrag = undefined;
250
+ this.endDrag = undefined;
251
+ this.pendingReattach = undefined;
190
252
  this.marquee = undefined;
191
253
  this.segmentDrag = undefined;
192
254
  }
@@ -202,15 +264,27 @@ export class ProcessModeler extends BaseElement {
202
264
  }
203
265
  if (id !== null && !this.projection.boxes.some((box) => box.id === id))
204
266
  return;
205
- if (id === this.selectedId && this.selectedIds.size === (id ? 1 : 0))
267
+ if (id === this.selectedId && this.selectedIds.size === (id ? 1 : 0) && !this.selectedLineId)
206
268
  return;
207
269
  this.selectedId = id;
208
270
  this.selectedIds = new Set(id ? [id] : []);
271
+ this.selectedLineId = null;
209
272
  this.renderSelection();
210
273
  if (this.isRendered)
211
274
  this.updateToolbar();
212
275
  emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
213
276
  }
277
+ selectLine(id) {
278
+ if (id !== null && !this.projection.lines.some(line => line.id === id))
279
+ return;
280
+ if (this.selectedLineId === id && !this.selectedIds.size)
281
+ return;
282
+ this.selectedLineId = id;
283
+ this.selectedId = null;
284
+ this.selectedIds.clear();
285
+ this.refresh();
286
+ emit(this, 'selection-changed', { box: null, boxes: [], line: this.selectedLine, path: null });
287
+ }
214
288
  setValidation(checks) {
215
289
  this.checks = checks;
216
290
  this.refresh();
@@ -219,7 +293,7 @@ export class ProcessModeler extends BaseElement {
219
293
  this.setValidation(message ? [{ message, path }] : []);
220
294
  }
221
295
  requestEdit(edit) {
222
- if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect")))
296
+ if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect" || edit.type === 'reattach')))
223
297
  return;
224
298
  const before = this.layout;
225
299
  const previousIds = new Set(this.projection.boxes.map((box) => box.id));
@@ -309,7 +383,7 @@ export class ProcessModeler extends BaseElement {
309
383
  emit(this, "positions-changed", { positions: this.positions, version: this.versionValue });
310
384
  }
311
385
  translatedLayout(id, x, y) {
312
- const snap = (n) => (this.snapToGrid ? Math.round(n / 20) * 20 : n);
386
+ const snap = (n) => (this.snapToGrid ? Math.round(n / 16) * 16 : n);
313
387
  const next = this.layout;
314
388
  next.boxes[id] = { ...next.boxes[id], x: snap(x), y: snap(y) };
315
389
  const dx = next.boxes[id].x - this.layoutValue.boxes[id].x;
@@ -336,7 +410,20 @@ export class ProcessModeler extends BaseElement {
336
410
  tidy() {
337
411
  if (this.locked)
338
412
  return;
413
+ const before = this.layout.boxes;
339
414
  this.commitLayout(this.arrangedLayout({ ...this.layout, boxes: {} }));
415
+ if (!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
416
+ for (const element of Array.from(this.shadowRoot?.querySelectorAll('[data-box-id]') ?? [])) {
417
+ const previous = before[element.dataset.boxId];
418
+ const next = this.layoutValue.boxes[element.dataset.boxId];
419
+ if (!previous || !next || !element.animate)
420
+ continue;
421
+ const dx = previous.x - next.x;
422
+ const dy = previous.y - next.y;
423
+ if (dx || dy)
424
+ element.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'translate(0, 0)' }], { duration: 320, easing: 'ease-out' });
425
+ }
426
+ }
340
427
  this.fit();
341
428
  }
342
429
  resize(id, width, height) {
@@ -364,6 +451,13 @@ export class ProcessModeler extends BaseElement {
364
451
  },
365
452
  });
366
453
  }
454
+ resetLine(id) {
455
+ if (!this.layoutValue.lines?.[id])
456
+ return;
457
+ const next = this.layout;
458
+ delete next.lines?.[id];
459
+ this.commitLayout(next);
460
+ }
367
461
  setNote(note, id = note?.id) {
368
462
  if (!id || (note && (!Number.isFinite(note.x) || !Number.isFinite(note.y))))
369
463
  return;
@@ -395,7 +489,7 @@ export class ProcessModeler extends BaseElement {
395
489
  const centerX = viewport.clientWidth / 2;
396
490
  const centerY = viewport.clientHeight / 2;
397
491
  const previous = this.viewport.zoom;
398
- const next = Math.max(0.25, Math.min(3, previous * factor));
492
+ const next = Math.max(0.25, Math.min(2, previous * factor));
399
493
  this.viewport.x = centerX - ((centerX - this.viewport.x) / previous) * next;
400
494
  this.viewport.y = centerY - ((centerY - this.viewport.y) / previous) * next;
401
495
  this.viewport.zoom = next;
@@ -416,51 +510,20 @@ export class ProcessModeler extends BaseElement {
416
510
  return {
417
511
  x,
418
512
  y,
419
- width: Math.max(100, ...positions.map((pos) => pos.x + (pos.width ?? 220))) -
513
+ width: Math.max(100, ...positions.map((pos) => pos.x + (pos.width ?? 224))) -
420
514
  x,
421
- height: Math.max(100, ...positions.map((pos) => pos.y + (pos.height ?? 96))) -
515
+ height: Math.max(100, ...positions.map((pos) => pos.y + (pos.height ?? 64))) -
422
516
  y,
423
517
  };
424
518
  }
425
519
  renderTemplate() {
426
- this.shadowRoot.innerHTML = `<style>
427
- :host{display:block;font:inherit;color:var(--boe-token-text-text,#222);min-width:0}:host([hidden]){display:none!important}
428
- *{box-sizing:border-box}button,input{font:inherit;color:inherit}button{background:var(--boe-token-surface-surface,#fff);border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:8px;padding:8px;min-height:36px;cursor:pointer}button:disabled{opacity:.5;cursor:default}
429
- ${boeFocusVisibleStyles(":is(button,input,[part=canvas],[part=minimap])")}
430
- [part=toolbar]{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
431
- [part=layout]{display:grid;grid-template-columns:minmax(0,1fr) minmax(var(--boe-process-inspector-min-width,240px),var(--boe-process-inspector-width,320px));gap:16px}
432
- [part=canvas]{position:relative;overflow:hidden;height:var(--boe-process-height,560px);background:var(--boe-token-surface-surface-secondary,#fbfbfb);border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:12px;touch-action:none}
433
- [part=world]{position:absolute;inset:0;transform-origin:0 0}
434
- [part=lines]{position:absolute;inset:0;width:1px;height:1px;overflow:visible;pointer-events:none}
435
- [part=line]{stroke:var(--boe-token-text-text-secondary,#666);stroke-width:2;fill:none}
436
- [part=box],[part=frame]{position:absolute;padding:12px;display:flex;flex-direction:column;gap:6px;text-align:start;background:var(--boe-token-surface-surface,#fff);border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:8px;touch-action:none}
437
- [part=box] strong,[part=frame] strong{overflow-wrap:anywhere}[part=box] small{color:var(--boe-token-text-text-secondary,#666)}
438
- [part=frame]{background:color-mix(in srgb,var(--boe-token-surface-surface-brand,#0061d5) 4%,var(--boe-token-surface-surface,#fff));justify-content:flex-start}
439
- [aria-pressed=true]{border-color:var(--boe-token-surface-surface-brand,#0061d5)}[data-invalid=true]{border-color:var(--boe-token-text-status-text-error,#b92340);border-width:2px}
440
- [part=connection]{position:absolute;display:flex;gap:4px;align-items:center;background:var(--boe-token-surface-surface,#fff);border-radius:8px;padding:4px;font-size:12px;white-space:nowrap}
441
- [part=connection] button{font-size:12px;min-height:32px;padding:4px}
442
- [part=connection] button{opacity:0;pointer-events:none}
443
- [part=connection]:hover button,[part=connection]:focus-within button{opacity:1;pointer-events:auto}
444
- [part=connection]{min-width:40px;min-height:32px}
445
- [part=insert-chooser]{padding:12px;background:var(--boe-token-surface-surface,#fff);border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:12px;max-width:calc(100vw - 32px);max-height:65vh;overflow:auto}
446
- [part=inspector]{min-width:0;padding:12px;border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:12px;background:var(--boe-token-surface-surface,#fff)}
447
- [hidden]{display:none!important}
448
- [part=palette]{display:grid;gap:8px}[part=palette] input{width:100%;min-width:0;padding:8px;background:var(--boe-token-surface-surface,#fff);border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:8px}
449
- [part=choices]{display:grid;gap:6px;max-height:300px;overflow:auto}[part=group]{font-weight:600;margin:8px 0 0}
450
- [part=checks]{display:grid;gap:6px;margin-top:12px}[part=checks] button{text-align:start;color:var(--boe-token-text-status-text-error,#b92340)}
451
- [part=minimap]{position:absolute;right:12px;bottom:12px;width:150px;height:90px;background:var(--boe-token-surface-surface,#fff);border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:8px;touch-action:none}
452
- [part=minimap] rect{fill:var(--boe-token-surface-surface-brand,#0061d5)}[part=minimap] [data-viewport]{fill:none;stroke:var(--boe-token-text-text,#222);stroke-width:3}
453
- [part=icon]{width:20px;height:20px;color:var(--boe-token-surface-surface-brand,#0061d5)}[part=icon] svg{width:20px;height:20px}
454
- [part=note],[part=section]{position:absolute;border:1px dashed var(--boe-token-stroke-stroke,#ddd);background:var(--boe-token-surface-surface,#fff);padding:8px;pointer-events:none}
455
- [part=help]{font-size:13px;color:var(--boe-token-text-text-secondary,#666)}
456
- @media(max-width:800px){[part=layout]{grid-template-columns:minmax(0,1fr)}[part=canvas]{height:420px}}
457
- @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important;scroll-behavior:auto!important}}
458
- </style><div part="toolbar" role="toolbar" aria-label="Diagram controls"><button data-command="zoom-out">Zoom out</button><button data-command="reset">100%</button><button data-command="zoom-in">Zoom in</button><button data-command="fit">Fit the whole process</button><button data-command="tidy">Tidy up</button><button data-command="undo">Undo</button><button data-command="redo">Redo</button><button data-command="snap" aria-pressed="false">Snap to grid</button><button data-command="lock" aria-pressed="false">Lock diagram</button></div><div part="layout"><div part="canvas" tabindex="0" role="region" aria-label="Process diagram" aria-describedby="process-help"><div part="world"></div><svg part="minimap" role="img" aria-label="Diagram overview. Click to jump"></svg></div><div part="inspector" role="region" aria-label="Process details"><div part="palette"><label>Find a building block<input type="search" part="search"></label><div part="choices"></div></div><div part="editor"></div><div part="checks" role="region" aria-label="Checks"></div></div></div><p part="help" id="process-help">Select a box. Arrow keys move it; C then another box connects it; Delete removes it. Drag the background to pan. Use zoom controls or pinch to zoom. Escape cancels connecting.</p><div part="status" role="status" aria-live="polite"></div>`;
520
+ this.shadowRoot.innerHTML = `<style>${processModelerDesign}</style><div part="toolbar" role="toolbar" aria-label="Diagram controls"><button data-command="zoom-out">Zoom out</button><button data-command="reset">100%</button><button data-command="zoom-in">Zoom in</button><button data-command="fit">Fit the whole process</button><button data-command="tidy">Tidy up</button><button data-command="undo">Undo</button><button data-command="redo">Redo</button><button data-command="snap" aria-pressed="false">Snap to grid</button><button data-command="lock" aria-pressed="false">Lock diagram</button></div><div part="layout"><div part="canvas" tabindex="0" role="region" aria-label="Process diagram" aria-describedby="process-help"><div part="world"></div><svg part="minimap" role="img" aria-label="Diagram overview. Click to jump"></svg></div><div part="inspector" role="region" aria-label="Process details"><div part="palette"><label>Find a building block<input type="search" part="search"></label><div part="choices"></div></div><div part="editor"></div><div part="checks" role="region" aria-label="Checks"></div></div></div><p part="help" id="process-help">Tab moves between steps. Alt plus an arrow selects the next step that way. Arrows move the selected step; Shift moves it four grid squares. N opens the building-block chooser. Enter edits. Delete removes. Shift plus 1 fits the process. Drag the background to pan; pinch or use the controls to zoom. Escape cancels connecting.</p><div part="status" role="status" aria-live="polite"></div>`;
459
521
  const chooser = document.createElement("dialog");
460
522
  chooser.setAttribute("part", "insert-chooser");
461
523
  chooser.setAttribute("aria-label", "Insert a building block");
462
524
  const picker = new KindPicker();
463
525
  picker.variant = "menu";
526
+ picker.searchable = true;
464
527
  chooser.append(picker);
465
528
  this.shadowRoot.append(chooser);
466
529
  picker.addEventListener("kind-pick", event => {
@@ -475,40 +538,96 @@ export class ProcessModeler extends BaseElement {
475
538
  }
476
539
  setupPanes() {
477
540
  const root = this.shadowRoot;
478
- const style = document.createElement("style");
479
- style.textContent = `
480
- [part=layout]{grid-template-columns:180px minmax(0,1fr) minmax(var(--boe-process-inspector-min-width,220px),var(--boe-process-inspector-width,280px))}
481
- [part=pane-drawer]{display:contents;color:inherit}[part=pane-drawer]::backdrop{background:rgb(0 0 0 / .45)}
482
- [part=pane-close],[data-command=palette],[data-command=details]{display:none}
483
- [part=palette]{align-content:start;border:1px solid var(--boe-control-edge,#6f6f6f);border-radius:12px;padding:12px;background:var(--boe-token-surface-surface,#fff)}
484
- [part=pane-tabs]{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:12px}[part=pane-tabs] button{font-size:12px;min-height:28px}
485
- [part=pane-content]{display:grid;gap:8px}[part=pane-content] button{min-height:28px;text-align:start}
486
- [part=port]{position:absolute;width:24px;height:24px;min-height:24px;border-radius:50%;padding:0;background:var(--boe-token-surface-surface-brand,#0061d5);border:2px solid var(--boe-token-surface-surface,#fff);opacity:0;z-index:3}
487
- [part=box]:hover [part=port],[part=box]:focus-within [part=port],[part=box][aria-current=true] [part=port],[part=frame]:hover [part=port]{opacity:1}
488
- [data-side=north]{left:calc(50% - 12px);top:-12px}[data-side=south]{left:calc(50% - 12px);bottom:-12px}[data-side=east]{right:-12px;top:calc(50% - 12px)}[data-side=west]{left:-12px;top:calc(50% - 12px)}
489
- [part=box][aria-current=true],[part=frame][aria-current=true]{border:2px solid var(--boe-token-surface-surface-brand,#0061d5)}
490
- [part=line-hit]{fill:none;stroke:transparent;stroke-width:24;pointer-events:stroke}[data-drop=true]{stroke:var(--boe-token-surface-surface-brand,#0061d5);stroke-width:4}
491
- [part=connection]{transform:translate(-50%,-50%);font-size:12px}
492
- [part=connection-actions]{position:absolute;top:100%;left:50%;transform:translateX(-50%);display:flex;gap:4px;width:max-content;background:var(--boe-token-surface-surface,#fff);border-radius:8px}
493
- [part=marquee]{position:absolute;border:2px solid var(--boe-token-surface-surface-brand,#0061d5);background:color-mix(in srgb,var(--boe-token-surface-surface-brand,#0061d5) 12%,transparent);pointer-events:none}
494
- [part=guide]{position:absolute;background:var(--boe-token-surface-surface-brand,#0061d5);pointer-events:none}
495
- [part=measure]{position:absolute;background:var(--boe-token-surface-surface,#fff);font-size:12px;padding:4px;pointer-events:none}
496
- [part=selection-toolbar]{position:absolute;left:12px;bottom:12px;display:flex;gap:4px;flex-wrap:wrap;background:var(--boe-token-surface-surface,#fff);padding:6px;border:1px solid var(--boe-control-edge,#6f6f6f);border-radius:8px;z-index:5}
497
- :host([data-narrow]) [part=layout]{grid-template-columns:minmax(0,1fr)}
498
- :host([data-narrow]) [part=pane-drawer]{display:none;max-width:calc(100vw - 24px);width:320px;max-height:85vh;overflow:auto;border:1px solid var(--boe-control-edge,#6f6f6f);border-radius:12px;padding:12px;background:var(--boe-token-surface-surface,#fff)}
499
- :host([data-narrow]) [part=pane-drawer][open]{display:block}
500
- :host([data-narrow]) [part=pane-close],:host([data-narrow]) [data-command=palette],:host([data-narrow]) [data-command=details]{display:inline-block}
501
- `;
502
- root.append(style);
541
+ const toolbar = root.querySelector('[part=toolbar]');
542
+ const viewSwitch = document.createElement("div");
543
+ viewSwitch.setAttribute("part", "view-switch");
544
+ viewSwitch.setAttribute("role", "group");
545
+ viewSwitch.setAttribute("aria-label", "Diagram detail");
546
+ for (const [value, label] of [["business", "Business view"], ["technical", "Technical view"]]) {
547
+ const button = document.createElement("button");
548
+ button.type = "button";
549
+ button.dataset.detail = value;
550
+ button.textContent = label;
551
+ button.onclick = () => { this.detail = value; };
552
+ viewSwitch.append(button);
553
+ }
554
+ const runToggle = document.createElement("label");
555
+ runToggle.setAttribute("part", "run-toggle");
556
+ const checkbox = document.createElement("input");
557
+ checkbox.type = "checkbox";
558
+ checkbox.setAttribute("aria-label", "Show last run");
559
+ checkbox.onchange = () => { this.showLastRun = checkbox.checked; };
560
+ runToggle.append(checkbox, document.createTextNode("Last run"));
561
+ const viewMenu = document.createElement("details");
562
+ viewMenu.setAttribute("part", "view-menu");
563
+ const summary = document.createElement("summary");
564
+ summary.textContent = "View";
565
+ viewMenu.append(summary);
566
+ const viewOptions = document.createElement('div');
567
+ viewOptions.setAttribute('part', 'view-menu-options');
568
+ viewMenu.append(viewOptions);
569
+ for (const [value, label] of [["business", "Business view"], ["technical", "Technical view"]]) {
570
+ const button = document.createElement("button");
571
+ button.type = "button";
572
+ button.dataset.detail = value;
573
+ button.textContent = label;
574
+ button.onclick = () => { this.detail = value; viewMenu.open = false; };
575
+ viewOptions.append(button);
576
+ }
577
+ const mobileRun = document.createElement('button');
578
+ mobileRun.type = 'button';
579
+ mobileRun.dataset.viewOption = 'last-run';
580
+ mobileRun.textContent = 'Last run';
581
+ mobileRun.onclick = () => { this.showLastRun = !this.showLastRun; viewMenu.open = false; };
582
+ viewOptions.append(mobileRun);
583
+ const mobileChecks = document.createElement('button');
584
+ mobileChecks.type = 'button';
585
+ mobileChecks.textContent = 'Checks';
586
+ mobileChecks.onclick = () => { this.select(null); this.activePane = 'Checks'; this.renderPane(); viewMenu.open = false; promoteModal(root.querySelector('[part=pane-drawer][data-pane=inspector]')); };
587
+ viewOptions.append(mobileChecks);
588
+ toolbar.prepend(viewSwitch, runToggle, viewMenu);
503
589
  const layout = root.querySelector('[part=layout]');
590
+ const palette = root.querySelector('[part=palette]');
591
+ const searchLabel = palette.querySelector('label');
592
+ searchLabel.querySelector('input')?.setAttribute('placeholder', 'Find a building block');
593
+ const searchIcon = svgElement('svg');
594
+ searchIcon.setAttribute('part', 'search-icon');
595
+ searchIcon.setAttribute('viewBox', '0 0 24 24');
596
+ searchIcon.setAttribute('fill', 'none');
597
+ searchIcon.setAttribute('stroke', 'currentColor');
598
+ searchIcon.setAttribute('stroke-width', '1.8');
599
+ searchIcon.setAttribute('aria-hidden', 'true');
600
+ const searchCircle = svgElement('circle');
601
+ searchCircle.setAttribute('cx', '10.5');
602
+ searchCircle.setAttribute('cy', '10.5');
603
+ searchCircle.setAttribute('r', '6.5');
604
+ const searchHandle = svgElement('path');
605
+ searchHandle.setAttribute('d', 'm16 16 5 5');
606
+ searchIcon.append(searchCircle, searchHandle);
607
+ searchLabel.append(searchIcon);
608
+ const skip = document.createElement("button");
609
+ skip.type = "button";
610
+ skip.setAttribute("part", "skip-link");
611
+ skip.textContent = "Skip to the diagram";
612
+ skip.onclick = () => { const target = this.selectedId ?? this.projection.boxes.find(box => box.kind === "start")?.id; if (target)
613
+ this.focusBox(target);
614
+ else
615
+ root.querySelector('[part=canvas]')?.focus(); };
616
+ palette.prepend(skip);
617
+ const hint = document.createElement("p");
618
+ hint.setAttribute("part", "palette-hint");
619
+ hint.textContent = "Drag onto the canvas, a connection, or a frame. Select a step first to add after it.";
620
+ palette.append(hint);
504
621
  for (const [part, label] of [["palette", "Building blocks"], ["inspector", "Process details"]]) {
505
622
  const pane = root.querySelector(`[part=${part}]`);
506
623
  const dialog = document.createElement("dialog");
507
624
  dialog.setAttribute("part", "pane-drawer");
508
625
  dialog.setAttribute("aria-label", label);
626
+ dialog.dataset.pane = part;
509
627
  const close = document.createElement("button");
510
628
  close.setAttribute("part", "pane-close");
511
- close.textContent = `Close ${label.toLowerCase()}`;
629
+ close.textContent = 'Close';
630
+ close.setAttribute('aria-label', `Close ${label.toLowerCase()}`);
512
631
  close.onclick = () => dismissModal(dialog);
513
632
  dialog.append(close, pane);
514
633
  if (part === "palette")
@@ -517,11 +636,20 @@ export class ProcessModeler extends BaseElement {
517
636
  layout.append(dialog);
518
637
  const trigger = document.createElement("button");
519
638
  trigger.dataset.command = part === "palette" ? "palette" : "details";
520
- trigger.textContent = label;
639
+ trigger.textContent = part === "palette" ? "Add" : "Details";
640
+ trigger.setAttribute("aria-label", `Open ${label.toLowerCase()}`);
521
641
  trigger.onclick = () => promoteModal(dialog);
522
- root.querySelector('[part=toolbar]').append(trigger);
642
+ toolbar.append(trigger);
523
643
  }
524
644
  const inspector = root.querySelector('[part=inspector]');
645
+ const heading = document.createElement("div");
646
+ heading.setAttribute("part", "process-heading");
647
+ const title = document.createElement("strong");
648
+ title.setAttribute("part", "process-title");
649
+ const summaryText = document.createElement("span");
650
+ summaryText.setAttribute("part", "process-summary");
651
+ heading.append(title, summaryText);
652
+ inspector.prepend(heading);
525
653
  const tabs = document.createElement("div");
526
654
  tabs.setAttribute("part", "pane-tabs");
527
655
  tabs.setAttribute("role", "tablist");
@@ -529,6 +657,7 @@ export class ProcessModeler extends BaseElement {
529
657
  for (const name of ["Outline", "Checks", "Variables", "Connections", "Shortcuts"]) {
530
658
  const button = document.createElement("button");
531
659
  button.textContent = name;
660
+ button.dataset.pane = name;
532
661
  button.setAttribute("role", "tab");
533
662
  button.id = `process-tab-${name.toLowerCase()}`;
534
663
  button.setAttribute("aria-controls", "process-pane-content");
@@ -549,26 +678,62 @@ export class ProcessModeler extends BaseElement {
549
678
  content.setAttribute("part", "pane-content");
550
679
  content.setAttribute("role", "tabpanel");
551
680
  content.id = "process-pane-content";
552
- inspector.prepend(tabs);
681
+ heading.after(tabs);
553
682
  inspector.append(content);
554
683
  root.querySelector('[part=checks]').removeAttribute("role");
555
684
  const canvas = root.querySelector('[part=canvas]');
685
+ const hold = document.createElement('div');
686
+ hold.setAttribute('part', 'hold');
687
+ hold.setAttribute('role', 'status');
688
+ hold.hidden = true;
689
+ const holdText = document.createElement('span');
690
+ holdText.setAttribute('part', 'hold-text');
691
+ const showChecks = document.createElement('button');
692
+ showChecks.type = 'button';
693
+ showChecks.textContent = 'Show checks';
694
+ showChecks.onclick = () => { this.select(null); this.activePane = 'Checks'; this.renderPane(); if (this.narrowValue)
695
+ promoteModal(root.querySelector('[part=pane-drawer][data-pane=inspector]')); root.querySelector('#process-tab-checks')?.focus(); };
696
+ hold.append(holdText, showChecks);
697
+ canvas.append(hold);
698
+ const controls = document.createElement("div");
699
+ controls.setAttribute("part", "controls");
700
+ controls.setAttribute("role", "toolbar");
701
+ controls.setAttribute("aria-label", "Canvas view controls");
702
+ for (const command of ["zoom-out", "reset", "zoom-in", "fit", "snap", "lock"]) {
703
+ const button = root.querySelector(`[data-command=${command}]`);
704
+ if (button)
705
+ controls.append(button);
706
+ }
707
+ canvas.append(controls);
708
+ const commandIcons = {
709
+ "zoom-out": '<circle cx="12" cy="12" r="9"/><path d="M8 12h8"/>',
710
+ "zoom-in": '<circle cx="12" cy="12" r="9"/><path d="M8 12h8m-4-4v8"/>',
711
+ fit: '<path d="M4 9V4h5m6 0h5v5m0 6v5h-5m-6 0H4v-5"/>',
712
+ snap: '<circle cx="5" cy="5" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="19" cy="5" r="1"/><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="19" r="1"/><circle cx="12" cy="19" r="1"/><circle cx="19" cy="19" r="1"/>',
713
+ lock: '<rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
714
+ undo: '<path d="M9 7 4 12l5 5M4 12h10a6 6 0 0 1 6 6"/>',
715
+ redo: '<path d="m15 7 5 5-5 5m5-5H10a6 6 0 0 0-6 6"/>',
716
+ };
717
+ for (const [command, path] of Object.entries(commandIcons)) {
718
+ const button = root.querySelector(`[data-command=${command}]`);
719
+ const label = button.textContent ?? command;
720
+ button.setAttribute("aria-label", label);
721
+ button.title = label;
722
+ button.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${path}</svg><span part="sr-only">${label}</span>`;
723
+ }
556
724
  const floating = document.createElement("div");
557
725
  floating.setAttribute("part", "selection-toolbar");
558
726
  floating.setAttribute("role", "toolbar");
559
727
  floating.setAttribute("aria-label", "Selected steps");
560
- for (const [command, label] of [["align", "Align tops"], ["space", "Space evenly"], ["delete-many", "Delete selected"]]) {
561
- const button = document.createElement("button");
562
- button.textContent = label;
563
- button.onclick = () => this.selectionCommand(command);
564
- floating.append(button);
565
- }
566
728
  canvas.append(floating);
567
729
  if (typeof ResizeObserver !== "undefined") {
568
730
  this.resizeObserver = new ResizeObserver(entries => {
569
- const narrow = entries[0].contentRect.width < 1000;
731
+ const narrow = entries[0].contentRect.width < 900;
570
732
  this.narrowValue = narrow;
571
733
  this.toggleAttribute("data-narrow", narrow);
734
+ this.toggleAttribute('data-phone', entries[0].contentRect.width < 560);
735
+ this.renderSelectionToolbar();
736
+ this.positionSelectionToolbar();
572
737
  if (!narrow)
573
738
  root.querySelectorAll('[part=pane-drawer]').forEach(dialog => dismissModal(dialog));
574
739
  });
@@ -579,27 +744,71 @@ export class ProcessModeler extends BaseElement {
579
744
  if (!this.isRendered)
580
745
  return;
581
746
  const root = this.shadowRoot;
747
+ const selected = this.selected;
748
+ root.querySelector('[part=process-title]').textContent = this.selectedIds.size > 1 ? `${this.selectedIds.size} steps selected` : selected?.title ?? this.processTitleValue;
749
+ 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;
750
+ root.querySelector('[part=pane-tabs]').hidden = Boolean(selected);
582
751
  root.querySelectorAll('[part=pane-tabs] button').forEach(button => {
583
- const selected = button.textContent === this.activePane;
752
+ button.textContent = button.dataset.pane === "Checks" ? `Checks ${this.allChecks.length}` : button.dataset.pane ?? "";
753
+ const selected = button.dataset.pane === this.activePane;
584
754
  button.setAttribute("aria-selected", String(selected));
585
755
  button.tabIndex = selected ? 0 : -1;
586
756
  });
587
757
  const content = root.querySelector('[part=pane-content]');
758
+ content.hidden = Boolean(selected);
588
759
  content.setAttribute("aria-labelledby", `process-tab-${this.activePane.toLowerCase()}`);
589
760
  const checks = root.querySelector('[part=checks]');
590
761
  checks.hidden = this.activePane !== "Checks";
591
762
  // Move the checks node out before replacing its parent; keep its handlers.
592
763
  root.querySelector('[part=inspector]').append(checks);
593
764
  content.replaceChildren();
594
- root.querySelector('[part=editor]').hidden = this.activePane !== "Outline";
765
+ root.querySelector('[part=editor]').hidden = !selected;
595
766
  if (this.activePane === "Outline") {
596
- this.projection.boxes.forEach(box => {
597
- const button = document.createElement("button");
598
- button.textContent = box.title;
599
- button.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
600
- button.onclick = () => { this.select(box.id); this.focusBox(box.id); };
601
- content.append(button);
602
- });
767
+ const intro = document.createElement("p");
768
+ intro.setAttribute("part", "outline-intro");
769
+ intro.textContent = "The process as numbered steps, written from the diagram. Share it as documentation or read it aloud.";
770
+ content.append(intro);
771
+ const fallback = this.projection.boxes.map(box => ({ title: box.title, description: box.description, boxId: box.id }));
772
+ const outline = this.outlineValue.length ? this.outlineValue : fallback;
773
+ const copy = document.createElement('button');
774
+ copy.type = 'button';
775
+ copy.setAttribute('part', 'copy-outline');
776
+ copy.textContent = 'Copy as text';
777
+ copy.onclick = () => {
778
+ const lines = [];
779
+ const walk = (items, depth) => items.forEach((item, index) => { lines.push(`${' '.repeat(depth)}${index + 1}. ${item.title}${item.description ? ` — ${item.description}` : ''}`); if (item.children)
780
+ walk(item.children, depth + 1); });
781
+ walk(outline, 0);
782
+ const value = lines.join('\n');
783
+ emit(this, 'outline-copy-request', { text: value });
784
+ void navigator.clipboard?.writeText(value).then(() => this.setStatus('Outline copied')).catch(() => this.setStatus('Use your host copy action to copy the outline'));
785
+ };
786
+ content.append(copy);
787
+ const appendOutline = (items, parent) => {
788
+ const list = document.createElement("ol");
789
+ list.setAttribute("part", "outline-list");
790
+ for (const item of items) {
791
+ const row = document.createElement("li");
792
+ const label = document.createElement(item.boxId ? "button" : "strong");
793
+ label.textContent = item.title;
794
+ if (item.boxId && label instanceof HTMLButtonElement) {
795
+ label.type = "button";
796
+ label.setAttribute("aria-current", String(this.selectedIds.has(item.boxId)));
797
+ label.onclick = () => { this.select(item.boxId); this.focusBox(item.boxId); };
798
+ }
799
+ row.append(label);
800
+ if (item.description) {
801
+ const detail = document.createElement("p");
802
+ detail.textContent = item.description;
803
+ row.append(detail);
804
+ }
805
+ if (item.children?.length)
806
+ appendOutline(item.children, row);
807
+ list.append(row);
808
+ }
809
+ parent.append(list);
810
+ };
811
+ appendOutline(outline, content);
603
812
  }
604
813
  else if (this.activePane === "Checks") {
605
814
  content.append(checks);
@@ -609,18 +818,69 @@ export class ProcessModeler extends BaseElement {
609
818
  content.prepend(message);
610
819
  }
611
820
  }
612
- else if (this.activePane === "Connections" || this.activePane === "Variables") {
613
- const items = this.activePane === "Connections" ? this.connections : this.variables;
614
- for (const item of items) {
615
- const row = document.createElement("p");
616
- row.textContent = "kind" in item ? `${item.name} (${item.kind})` : `${item.name}${item.description ? `: ${item.description}` : ""}`;
821
+ else if (this.activePane === "Variables") {
822
+ if (!this.variables.length)
823
+ content.textContent = 'No variables supplied by the host.';
824
+ for (const variable of this.variables) {
825
+ const row = document.createElement('div');
826
+ row.setAttribute('part', 'variable-row');
827
+ const heading = document.createElement('strong');
828
+ heading.textContent = variable.name;
829
+ row.append(heading);
830
+ if (variable.scope) {
831
+ const scope = document.createElement('span');
832
+ scope.textContent = variable.scope;
833
+ scope.setAttribute('part', 'variable-scope');
834
+ row.append(scope);
835
+ }
836
+ if (variable.description) {
837
+ const description = document.createElement('p');
838
+ description.textContent = variable.description;
839
+ row.append(description);
840
+ }
841
+ if (variable.startingValue !== undefined) {
842
+ const label = document.createElement('label');
843
+ label.textContent = 'Starting value';
844
+ const input = document.createElement('input');
845
+ input.type = 'text';
846
+ input.dataset.variable = variable.name;
847
+ input.value = variable.startingValue;
848
+ input.disabled = this.locked;
849
+ input.setAttribute('aria-invalid', String(Boolean(variable.problem)));
850
+ input.addEventListener('input', () => emit(this, 'process-variable-change-request', { name: variable.name, value: input.value }));
851
+ label.append(input);
852
+ row.append(label);
853
+ }
854
+ if (variable.problem) {
855
+ const problem = document.createElement('p');
856
+ problem.setAttribute('part', 'field-problem');
857
+ problem.textContent = variable.problem;
858
+ row.append(problem);
859
+ }
860
+ content.append(row);
861
+ }
862
+ }
863
+ else if (this.activePane === "Connections") {
864
+ if (!this.connections.length)
865
+ content.textContent = 'No connections supplied by the host.';
866
+ for (const connection of this.connections) {
867
+ const row = document.createElement('div');
868
+ row.setAttribute('part', 'connection-row');
869
+ const name = document.createElement('strong');
870
+ name.textContent = connection.name;
871
+ const kind = document.createElement('span');
872
+ kind.textContent = `(${connection.kind})${connection.signIn ? ` · ${connection.signIn}` : ''}`;
873
+ row.append(name, document.createTextNode(' '), kind);
617
874
  content.append(row);
618
875
  }
619
- if (!items.length)
620
- content.textContent = "None supplied by the host.";
876
+ const setup = document.createElement('button');
877
+ setup.type = 'button';
878
+ setup.textContent = 'Manage connections';
879
+ setup.onclick = () => emit(this, 'connection-setup-request', {});
880
+ content.append(setup);
621
881
  }
622
882
  else if (this.activePane === "Shortcuts") {
623
- content.textContent = "Arrow keys: move selected step. Shift+arrows: move farther. C: connect selected step. Shift+drag background: select steps. Delete: remove selected. Escape: cancel. Ctrl/Command+Z: undo. Shift+Ctrl/Command+Z: redo.";
883
+ 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.";
624
884
  }
625
885
  }
626
886
  selectionCommand(command) {
@@ -632,8 +892,12 @@ export class ProcessModeler extends BaseElement {
632
892
  this.requestEdit({ type: "delete", boxId: box.id });
633
893
  return;
634
894
  }
635
- if (boxes.length < 2)
895
+ this.arrangeSelection(command === 'align' ? 'top' : 'distribute-horizontal');
896
+ }
897
+ arrangeSelection(action) {
898
+ if (this.locked || this.selectedBoxes.length < 2)
636
899
  return;
900
+ const boxes = this.selectedBoxes;
637
901
  const next = this.layout;
638
902
  const sorted = [...boxes].filter(box => {
639
903
  let parent = box.parentId;
@@ -643,34 +907,51 @@ export class ProcessModeler extends BaseElement {
643
907
  parent = this.projection.boxes.find(box => box.id === parent)?.parentId;
644
908
  }
645
909
  return true;
646
- }).sort((a, b) => next.boxes[a.id].x - next.boxes[b.id].x);
910
+ });
647
911
  if (sorted.length < 2)
648
912
  return;
649
- const first = next.boxes[sorted[0].id];
650
- const last = next.boxes[sorted.at(-1).id];
651
- sorted.forEach((box, i) => {
652
- const current = next.boxes[box.id];
653
- const moved = this.translatedLayout(box.id, command === "align" ? current.x : first.x + i * (last.x - first.x) / (sorted.length - 1), command === "align" ? first.y : current.y);
654
- for (const candidate of this.projection.boxes)
655
- if (moved.boxes[candidate.id].x !== this.layoutValue.boxes[candidate.id].x || moved.boxes[candidate.id].y !== this.layoutValue.boxes[candidate.id].y)
656
- next.boxes[candidate.id] = moved.boxes[candidate.id];
657
- });
913
+ const width = (box) => next.boxes[box.id].width ?? (box.shape === 'gateway' || box.shape === 'event' ? 56 : 224);
914
+ const height = (box) => next.boxes[box.id].height ?? (box.shape === 'gateway' || box.shape === 'event' ? 56 : 64);
915
+ const minX = Math.min(...sorted.map(box => next.boxes[box.id].x));
916
+ const maxX = Math.max(...sorted.map(box => next.boxes[box.id].x + width(box)));
917
+ const minY = Math.min(...sorted.map(box => next.boxes[box.id].y));
918
+ const maxY = Math.max(...sorted.map(box => next.boxes[box.id].y + height(box)));
919
+ const ordered = [...sorted].sort((a, b) => action === 'distribute-vertical' ? next.boxes[a.id].y - next.boxes[b.id].y : next.boxes[a.id].x - next.boxes[b.id].x);
920
+ const starts = ordered.map(box => action === 'distribute-vertical' ? next.boxes[box.id].y : next.boxes[box.id].x);
921
+ for (const box of sorted) {
922
+ const position = next.boxes[box.id];
923
+ const index = ordered.indexOf(box);
924
+ const x = action === 'left' ? minX : action === 'center' ? (minX + maxX - width(box)) / 2 : action === 'right' ? maxX - width(box) : action === 'distribute-horizontal' ? starts[0] + index * (starts.at(-1) - starts[0]) / (ordered.length - 1) : position.x;
925
+ const y = action === 'top' ? minY : action === 'middle' ? (minY + maxY - height(box)) / 2 : action === 'bottom' ? maxY - height(box) : action === 'distribute-vertical' ? starts[0] + index * (starts.at(-1) - starts[0]) / (ordered.length - 1) : position.y;
926
+ const dx = x - position.x, dy = y - position.y;
927
+ const group = new Set([box.id]);
928
+ for (let changed = true; changed;) {
929
+ changed = false;
930
+ for (const candidate of this.projection.boxes)
931
+ if (candidate.parentId && group.has(candidate.parentId) && !group.has(candidate.id)) {
932
+ group.add(candidate.id);
933
+ changed = true;
934
+ }
935
+ }
936
+ for (const id of group)
937
+ next.boxes[id] = { ...next.boxes[id], x: next.boxes[id].x + dx, y: next.boxes[id].y + dy };
938
+ }
658
939
  if (emit(this, "move-request", { boxId: sorted[0].id, position: next.boxes[sorted[0].id], boxIds: sorted.map(box => box.id), positions: next.boxes }))
659
940
  this.commitLayout(next);
660
941
  }
942
+ makeSection(title = 'New section') {
943
+ if (this.locked || this.selectedBoxes.length < 2)
944
+ return;
945
+ const positions = this.selectedBoxes.map(box => this.layoutValue.boxes[box.id]);
946
+ const x = Math.min(...positions.map(position => position.x)) - 24;
947
+ const y = Math.min(...positions.map(position => position.y)) - 48;
948
+ const right = Math.max(...positions.map(position => position.x + (position.width ?? 224))) + 24;
949
+ const bottom = Math.max(...positions.map(position => position.y + (position.height ?? 64))) + 24;
950
+ const id = `section-${++this.sectionSequence}`;
951
+ this.setSection({ id, title, x, y, width: right - x, height: bottom - y });
952
+ }
661
953
  setupListeners() {
662
- const toolbar = this.shadowRoot.querySelector("[part=toolbar]");
663
- for (const [command, label] of [
664
- ["connect", "Connect selected"],
665
- ["delete", "Delete selected"],
666
- ]) {
667
- const button = document.createElement("button");
668
- button.type = "button";
669
- button.dataset.command = command;
670
- button.textContent = label;
671
- toolbar.append(button);
672
- }
673
- this.shadowRoot.querySelector("[part=toolbar]").addEventListener("click", (event) => {
954
+ this.shadowRoot.addEventListener("click", (event) => {
674
955
  const command = event.target.closest("button")?.dataset.command;
675
956
  if (command === "zoom-in")
676
957
  this.zoomBy(1.2);
@@ -700,6 +981,9 @@ export class ProcessModeler extends BaseElement {
700
981
  this.shadowRoot.querySelector("[part=search]").addEventListener("input", () => this.renderPalette());
701
982
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
702
983
  canvas.addEventListener("keydown", (event) => this.onKey(event));
984
+ canvas.addEventListener('keyup', event => { if (event.key === ' ')
985
+ this.spacePressed = false; });
986
+ canvas.addEventListener('blur', () => { this.spacePressed = false; }, true);
703
987
  canvas.addEventListener("wheel", (event) => {
704
988
  event.preventDefault();
705
989
  if (event.ctrlKey || event.metaKey)
@@ -773,8 +1057,16 @@ export class ProcessModeler extends BaseElement {
773
1057
  });
774
1058
  }
775
1059
  onKey(event) {
776
- if (event.target.closest("input,textarea,select,[contenteditable]"))
1060
+ if (event.defaultPrevented)
1061
+ return;
1062
+ const target = event.target;
1063
+ if (target.closest("input,textarea,select,[contenteditable],button,summary"))
1064
+ return;
1065
+ if (event.key === ' ') {
1066
+ event.preventDefault();
1067
+ this.spacePressed = true;
777
1068
  return;
1069
+ }
778
1070
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
779
1071
  if (this.locked || !(event.shiftKey ? this.history.canRedo : this.history.canUndo))
780
1072
  return;
@@ -785,8 +1077,34 @@ export class ProcessModeler extends BaseElement {
785
1077
  this.undo();
786
1078
  return;
787
1079
  }
1080
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
1081
+ event.preventDefault();
1082
+ this.selectMany(this.projection.boxes.map(box => box.id));
1083
+ this.setStatus(`${this.projection.boxes.length} steps selected`);
1084
+ return;
1085
+ }
1086
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && this.selectedIds.size) {
1087
+ event.preventDefault();
1088
+ this.clipboardIds = [...this.selectedIds];
1089
+ this.setStatus(`${this.clipboardIds.length} steps copied`);
1090
+ return;
1091
+ }
1092
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v' && this.clipboardIds.length && !this.locked) {
1093
+ event.preventDefault();
1094
+ this.clipboardIds.forEach((id, index) => { const position = this.layoutValue.boxes[id]; if (position)
1095
+ this.requestEdit({ type: 'duplicate', sourceId: id, position: { x: position.x + 32 * (index + 1), y: position.y + 32 * (index + 1) } }); });
1096
+ return;
1097
+ }
1098
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd' && this.selectedIds.size && !this.locked) {
1099
+ event.preventDefault();
1100
+ [...this.selectedIds].forEach(id => { const position = this.layoutValue.boxes[id]; this.requestEdit({ type: 'duplicate', sourceId: id, position: { x: position.x + 32, y: position.y + 32 } }); });
1101
+ return;
1102
+ }
788
1103
  if (event.key === "Escape") {
789
1104
  this.connecting = null;
1105
+ this.pendingReattach = undefined;
1106
+ if (this.selectedLineId)
1107
+ this.selectLine(null);
790
1108
  this.portDrag = undefined;
791
1109
  this.marquee = undefined;
792
1110
  this.segmentDrag = undefined;
@@ -796,11 +1114,42 @@ export class ProcessModeler extends BaseElement {
796
1114
  this.setStatus("Connecting cancelled");
797
1115
  return;
798
1116
  }
1117
+ if (event.shiftKey && (event.key === "1" || event.key === "!" || event.code === "Digit1")) {
1118
+ event.preventDefault();
1119
+ this.fit();
1120
+ this.setStatus("Whole process fitted to the canvas");
1121
+ return;
1122
+ }
799
1123
  const box = this.selected;
1124
+ if (this.selectedLine && (event.key === 'Delete' || event.key === 'Backspace') && !this.locked && !this.disableConnections) {
1125
+ event.preventDefault();
1126
+ this.requestEdit({ type: 'disconnect', lineId: this.selectedLine.id });
1127
+ return;
1128
+ }
1129
+ if ((event.key === "n" || event.key === "N") && !this.locked && this.catalog.length) {
1130
+ event.preventDefault();
1131
+ this.openChooser({ type: "add", ...(box ? { from: box.id } : {}) });
1132
+ return;
1133
+ }
800
1134
  if (!box || this.locked)
801
1135
  return;
802
- if (event.key === "Enter" || event.key === " ")
1136
+ if (event.key === "Enter") {
1137
+ event.preventDefault();
1138
+ this.activePane = "Outline";
1139
+ this.renderPane();
1140
+ const editor = this.shadowRoot.querySelector('[part=editor]');
1141
+ if (this.narrowValue)
1142
+ promoteModal(this.shadowRoot.querySelector('[part=pane-drawer][data-pane=inspector]'));
1143
+ const field = editor.querySelector('input,select,textarea,button,[contenteditable]');
1144
+ if (field)
1145
+ field.focus();
1146
+ else {
1147
+ const heading = editor.querySelector('[part=inspector-heading]');
1148
+ heading?.setAttribute('tabindex', '-1');
1149
+ heading?.focus();
1150
+ }
803
1151
  return;
1152
+ }
804
1153
  if (event.key.toLowerCase() === "c" && !this.disableConnections) {
805
1154
  event.preventDefault();
806
1155
  this.connecting = box.id;
@@ -818,8 +1167,33 @@ export class ProcessModeler extends BaseElement {
818
1167
  }[event.key];
819
1168
  if (direction) {
820
1169
  event.preventDefault();
1170
+ if (event.ctrlKey && event.altKey && this.catalog.length && !this.disableConnections) {
1171
+ const side = direction[0] < 0 ? "west" : direction[0] > 0 ? "east" : direction[1] < 0 ? "north" : "south";
1172
+ const pos = this.layoutValue.boxes[box.id];
1173
+ this.openChooser({ type: "add", from: box.id, fromSide: side, position: { x: pos.x + direction[0] * 320, y: pos.y + direction[1] * 170 } });
1174
+ return;
1175
+ }
1176
+ if (event.altKey) {
1177
+ const origin = this.layoutValue.boxes[box.id];
1178
+ const center = { x: origin.x + (origin.width ?? 224) / 2, y: origin.y + (origin.height ?? 64) / 2 };
1179
+ const candidates = this.projection.boxes.filter(candidate => candidate.id !== box.id).map(candidate => {
1180
+ const position = this.layoutValue.boxes[candidate.id];
1181
+ const dx = position.x + (position.width ?? 224) / 2 - center.x;
1182
+ const dy = position.y + (position.height ?? 64) / 2 - center.y;
1183
+ const forward = dx * direction[0] + dy * direction[1];
1184
+ const sideways = Math.abs(dx * direction[1] - dy * direction[0]);
1185
+ return { candidate, forward, score: forward + sideways * 2 };
1186
+ }).filter(item => item.forward > 0).sort((a, b) => a.score - b.score || a.candidate.id.localeCompare(b.candidate.id));
1187
+ const next = candidates[0]?.candidate;
1188
+ if (next) {
1189
+ this.select(next.id);
1190
+ this.focusBox(next.id);
1191
+ this.setStatus(`${next.title} selected`);
1192
+ }
1193
+ return;
1194
+ }
821
1195
  const pos = this.layoutValue.boxes[box.id];
822
- const distance = event.shiftKey ? 100 : this.snapToGrid ? 20 : 10;
1196
+ const distance = event.shiftKey ? 64 : 16;
823
1197
  this.move(box.id, pos.x + direction[0] * distance, pos.y + direction[1] * distance);
824
1198
  this.focusBox(box.id);
825
1199
  }
@@ -828,8 +1202,26 @@ export class ProcessModeler extends BaseElement {
828
1202
  if (event.button !== 0)
829
1203
  return;
830
1204
  this.suppressClick = false;
831
- if (this.portDrag || this.segmentDrag || this.marquee)
1205
+ if (this.portDrag || this.endDrag || this.segmentDrag || this.marquee || this.frameResize)
1206
+ return;
1207
+ const resize = event.target.closest('[part=frame-resize]');
1208
+ if (resize && !this.locked) {
1209
+ event.preventDefault();
1210
+ event.stopPropagation();
1211
+ const id = resize.dataset.owner;
1212
+ const position = this.layoutValue.boxes[id];
1213
+ this.frameResize = { pointerId: event.pointerId, id, start: this.canvasPoint(event), width: position.width ?? 224, height: position.height ?? 64 };
1214
+ this.shadowRoot.querySelector('[part=canvas]').setPointerCapture?.(event.pointerId);
1215
+ return;
1216
+ }
1217
+ const grip = event.target.closest('[part=end-grip]');
1218
+ if (grip && !this.locked && !this.disableConnections) {
1219
+ event.preventDefault();
1220
+ const point = this.canvasPoint(event);
1221
+ this.endDrag = { pointerId: event.pointerId, lineId: grip.dataset.lineId, end: grip.dataset.end, start: point, point };
1222
+ this.shadowRoot.querySelector('[part=canvas]').setPointerCapture?.(event.pointerId);
832
1223
  return;
1224
+ }
833
1225
  const control = event.target.closest("[part=port]");
834
1226
  if (control && !this.locked && !this.disableConnections) {
835
1227
  event.preventDefault();
@@ -841,6 +1233,7 @@ export class ProcessModeler extends BaseElement {
841
1233
  const segment = event.target.closest("[data-segment]");
842
1234
  if (segment && !this.locked) {
843
1235
  const line = this.projection.lines.find(line => line.id === segment.dataset.lineId);
1236
+ this.selectLine(line.id);
844
1237
  this.segmentDrag = { pointerId: event.pointerId, id: line.id, index: Number(segment.dataset.segment), points: routeProcessLine(line, this.layoutValue, this.projection) };
845
1238
  this.shadowRoot.querySelector('[part=canvas]').setPointerCapture?.(event.pointerId);
846
1239
  event.preventDefault();
@@ -859,7 +1252,7 @@ export class ProcessModeler extends BaseElement {
859
1252
  const target = event.target.closest("[data-box-id]");
860
1253
  const id = target?.dataset.boxId;
861
1254
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
862
- if (id && !this.selectedIds.has(id) && !event.shiftKey)
1255
+ if (id && !this.spacePressed && !this.selectedIds.has(id) && !event.shiftKey)
863
1256
  this.select(id);
864
1257
  if (!id && event.shiftKey && !this.locked) {
865
1258
  const point = this.canvasPoint(event);
@@ -869,7 +1262,7 @@ export class ProcessModeler extends BaseElement {
869
1262
  return;
870
1263
  }
871
1264
  this.drag = {
872
- id: this.locked ? undefined : id,
1265
+ id: this.locked || this.spacePressed ? undefined : id,
873
1266
  x: event.clientX,
874
1267
  y: event.clientY,
875
1268
  position: id ? { ...this.layoutValue.boxes[id] } : undefined,
@@ -882,10 +1275,19 @@ export class ProcessModeler extends BaseElement {
882
1275
  return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
883
1276
  }
884
1277
  pointerMove(event) {
885
- const gesture = this.portDrag ?? this.segmentDrag ?? this.marquee;
1278
+ const gesture = this.portDrag ?? this.endDrag ?? this.segmentDrag ?? this.marquee ?? this.frameResize;
886
1279
  if (gesture && gesture.pointerId !== event.pointerId)
887
1280
  return;
888
1281
  const point = this.canvasPoint(event);
1282
+ if (this.frameResize) {
1283
+ const resize = this.frameResize;
1284
+ const element = Array.from(this.shadowRoot.querySelectorAll('[part=frame]')).find(frame => frame.dataset.boxId === resize.id);
1285
+ if (element) {
1286
+ element.style.width = `${Math.max(120, resize.width + point.x - resize.start.x)}px`;
1287
+ element.style.height = `${Math.max(64, resize.height + point.y - resize.start.y)}px`;
1288
+ }
1289
+ return;
1290
+ }
889
1291
  if (this.portDrag) {
890
1292
  this.portDrag.point = point;
891
1293
  let ghost = this.shadowRoot.querySelector('[part=connection-preview]');
@@ -897,6 +1299,31 @@ export class ProcessModeler extends BaseElement {
897
1299
  }
898
1300
  const start = this.portDrag.start;
899
1301
  ghost.setAttribute("points", `${start.x},${start.y} ${point.x},${start.y} ${point.x},${point.y}`);
1302
+ const target = this.boxAt(point, this.portDrag.id);
1303
+ const pinned = target ? this.sideAt(target, point) : undefined;
1304
+ this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); });
1305
+ this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { port.dataset.hot = String(port.dataset.owner === target?.id && port.dataset.side === pinned); });
1306
+ this.showConnectTooltip(point, target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Connect to ${target.title}` : 'Drop on a step to connect', !target);
1307
+ return;
1308
+ }
1309
+ if (this.endDrag) {
1310
+ this.endDrag.point = point;
1311
+ let ghost = this.shadowRoot.querySelector('[part=connection-preview]');
1312
+ if (!ghost) {
1313
+ ghost = svgElement('polyline');
1314
+ ghost.setAttribute('part', 'connection-preview');
1315
+ this.shadowRoot.querySelector('[part=lines]').append(ghost);
1316
+ }
1317
+ const points = this.routedLines.get(this.endDrag.lineId);
1318
+ const fixed = this.endDrag.end === 'from' ? points?.at(-1) : points?.[0];
1319
+ if (fixed)
1320
+ ghost.setAttribute('points', `${fixed.x},${fixed.y} ${point.x},${fixed.y} ${point.x},${point.y}`);
1321
+ const target = this.boxAt(point);
1322
+ const pinned = target ? this.sideAt(target, point) : undefined;
1323
+ this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); });
1324
+ this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { port.dataset.hot = String(port.dataset.owner === target?.id && port.dataset.side === pinned); });
1325
+ this.showConnectTooltip(point, target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Attach to ${target.title}` : 'Drop on a step to reattach', !target);
1326
+ this.setStatus(target ? `Release to attach the connection to ${target.title}` : 'Move over a step to attach this connection');
900
1327
  return;
901
1328
  }
902
1329
  if (this.segmentDrag) {
@@ -906,7 +1333,7 @@ export class ProcessModeler extends BaseElement {
906
1333
  else
907
1334
  points[index].y = points[index + 1].y = point.y;
908
1335
  const path = Array.from(this.shadowRoot.querySelectorAll('[part=line]')).find(path => path.dataset.lineId === this.segmentDrag.id);
909
- path?.setAttribute("points", points.map(p => `${p.x},${p.y}`).join(" "));
1336
+ path?.setAttribute("d", roundedProcessPath(points));
910
1337
  return;
911
1338
  }
912
1339
  if (this.marquee) {
@@ -953,14 +1380,26 @@ export class ProcessModeler extends BaseElement {
953
1380
  }
954
1381
  }
955
1382
  pointerEnd(event, commit) {
956
- const gesture = this.portDrag ?? this.segmentDrag ?? this.marquee;
1383
+ const gesture = this.portDrag ?? this.endDrag ?? this.segmentDrag ?? this.marquee ?? this.frameResize;
957
1384
  if (gesture && gesture.pointerId !== event.pointerId)
958
1385
  return;
959
1386
  const point = this.canvasPoint(event);
1387
+ if (this.frameResize) {
1388
+ const resize = this.frameResize;
1389
+ this.frameResize = undefined;
1390
+ if (commit)
1391
+ this.resize(resize.id, resize.width + point.x - resize.start.x, resize.height + point.y - resize.start.y);
1392
+ else
1393
+ this.refresh();
1394
+ return;
1395
+ }
960
1396
  if (this.portDrag) {
961
1397
  const port = this.portDrag;
962
1398
  this.portDrag = undefined;
963
1399
  this.shadowRoot.querySelector('[part=connection-preview]')?.remove();
1400
+ this.shadowRoot.querySelector('[part=connect-tooltip]')?.remove();
1401
+ this.shadowRoot.querySelectorAll('[data-connect-target]').forEach(box => { delete box.dataset.connectTarget; });
1402
+ this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { delete port.dataset.hot; });
964
1403
  if (commit) {
965
1404
  if (Math.hypot(point.x - port.start.x, point.y - port.start.y) < 8) {
966
1405
  const pos = this.layoutValue.boxes[port.id];
@@ -970,7 +1409,29 @@ export class ProcessModeler extends BaseElement {
970
1409
  else {
971
1410
  const target = this.boxAt(point, port.id);
972
1411
  if (target)
973
- this.requestEdit({ type: "connect", from: port.id, to: target.id, fromSide: port.side });
1412
+ this.requestEdit({ type: "connect", from: port.id, to: target.id, fromSide: port.side, toSide: this.sideAt(target, point) });
1413
+ }
1414
+ }
1415
+ return;
1416
+ }
1417
+ if (this.endDrag) {
1418
+ const drag = this.endDrag;
1419
+ this.endDrag = undefined;
1420
+ this.shadowRoot.querySelector('[part=connection-preview]')?.remove();
1421
+ this.shadowRoot.querySelector('[part=connect-tooltip]')?.remove();
1422
+ this.shadowRoot.querySelectorAll('[data-connect-target]').forEach(box => { delete box.dataset.connectTarget; });
1423
+ this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { delete port.dataset.hot; });
1424
+ if (commit) {
1425
+ if (Math.hypot(point.x - drag.start.x, point.y - drag.start.y) < 8) {
1426
+ this.pendingReattach = { lineId: drag.lineId, end: drag.end };
1427
+ this.setStatus('Select a step to reattach the connection');
1428
+ }
1429
+ else {
1430
+ const target = this.boxAt(point);
1431
+ if (target)
1432
+ this.requestEdit({ type: 'reattach', lineId: drag.lineId, [drag.end]: target.id, [drag.end === 'from' ? 'fromSide' : 'toSide']: this.sideAt(target, point) });
1433
+ else
1434
+ this.setStatus('Connection unchanged. Drop on a step to reattach.');
974
1435
  }
975
1436
  }
976
1437
  return;
@@ -988,7 +1449,7 @@ export class ProcessModeler extends BaseElement {
988
1449
  const { start } = this.marquee;
989
1450
  this.marquee = undefined;
990
1451
  if (commit)
991
- 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 ?? 220) > Math.min(start.x, point.x) && p.y < Math.max(start.y, point.y) && p.y + (p.height ?? 96) > Math.min(start.y, point.y); }).map(box => box.id));
1452
+ 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));
992
1453
  this.shadowRoot.querySelector('[part=marquee]')?.remove();
993
1454
  this.pointers.delete(event.pointerId);
994
1455
  return;
@@ -1025,8 +1486,8 @@ export class ProcessModeler extends BaseElement {
1025
1486
  }
1026
1487
  }
1027
1488
  const primary = next.boxes[drag.id];
1028
- const shiftX = this.snapToGrid ? Math.round((primary.x + dx) / 20) * 20 - primary.x : dx;
1029
- const shiftY = this.snapToGrid ? Math.round((primary.y + dy) / 20) * 20 - primary.y : dy;
1489
+ const shiftX = this.snapToGrid ? Math.round((primary.x + dx) / 16) * 16 - primary.x : dx;
1490
+ const shiftY = this.snapToGrid ? Math.round((primary.y + dy) / 16) * 16 - primary.y : dy;
1030
1491
  for (const id of moved)
1031
1492
  next.boxes[id] = { ...next.boxes[id], x: next.boxes[id].x + shiftX, y: next.boxes[id].y + shiftY };
1032
1493
  if (emit(this, "move-request", { boxId: drag.id, position: next.boxes[drag.id], boxIds: [...moved], positions: next.boxes }))
@@ -1059,15 +1520,22 @@ export class ProcessModeler extends BaseElement {
1059
1520
  parent = this.projection.boxes.find(b => b.id === parent)?.parentId;
1060
1521
  }
1061
1522
  const p = this.layoutValue.boxes[box.id];
1062
- return point.x >= p.x - 12 && point.x <= p.x + (p.width ?? 220) + 12 && point.y >= p.y - 12 && point.y <= p.y + (p.height ?? 96) + 12;
1523
+ return point.x >= p.x - 12 && point.x <= p.x + (p.width ?? 224) + 12 && point.y >= p.y - 12 && point.y <= p.y + (p.height ?? 64) + 12;
1063
1524
  });
1064
1525
  }
1526
+ sideAt(box, point) {
1527
+ const p = this.layoutValue.boxes[box.id];
1528
+ const width = p.width ?? 224;
1529
+ const height = p.height ?? 64;
1530
+ const sides = [['north', p.x + width / 2, p.y], ['east', p.x + width, p.y + height / 2], ['south', p.x + width / 2, p.y + height], ['west', p.x, p.y + height / 2]];
1531
+ return sides.find(([, x, y]) => Math.hypot(point.x - x, point.y - y) <= 12 / this.viewport.zoom)?.[0];
1532
+ }
1065
1533
  lineAt(point, except) {
1066
1534
  return this.projection.lines.find(line => line.from !== except && line.to !== except && nearProcessLine(point, this.routedLines.get(line.id) ?? [], 16 / this.viewport.zoom));
1067
1535
  }
1068
1536
  markDropLine(point, except) {
1069
1537
  this.dropLine = point ? this.lineAt(point, except)?.id : undefined;
1070
- this.shadowRoot.querySelectorAll('[part=line]').forEach(path => path.dataset.drop = String(path.dataset.lineId === this.dropLine));
1538
+ 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'})`); });
1071
1539
  }
1072
1540
  alignedPoint(id, x, y) {
1073
1541
  const world = this.shadowRoot.querySelector('[part=world]');
@@ -1085,17 +1553,17 @@ export class ProcessModeler extends BaseElement {
1085
1553
  y = p.y;
1086
1554
  const guide = document.createElement("div");
1087
1555
  guide.setAttribute("part", "guide");
1088
- guide.style.cssText = axis === "x" ? `left:${x}px;top:${Math.min(y, p.y)}px;width:1px;height:${Math.abs(y - p.y) + 96}px` : `left:${Math.min(x, p.x)}px;top:${y}px;height:1px;width:${Math.abs(x - p.x) + 220}px`;
1556
+ guide.style.cssText = axis === "x" ? `left:${x}px;top:${Math.min(y, p.y)}px;width:1px;height:${Math.abs(y - p.y) + 64}px` : `left:${Math.min(x, p.x)}px;top:${y}px;height:1px;width:${Math.abs(x - p.x) + 224}px`;
1089
1557
  world.append(guide);
1090
1558
  }
1091
1559
  }
1092
1560
  if (Math.abs(y - p.y) < 8) {
1093
- const distance = x - p.x - (p.width ?? 220);
1561
+ const distance = x - p.x - (p.width ?? 224);
1094
1562
  if (distance > 0) {
1095
1563
  const measure = document.createElement("span");
1096
1564
  measure.setAttribute("part", "measure");
1097
1565
  measure.textContent = `${Math.round(distance)}px`;
1098
- measure.style.cssText = `left:${p.x + (p.width ?? 220)}px;top:${y + 40}px`;
1566
+ measure.style.cssText = `left:${p.x + (p.width ?? 224)}px;top:${y + 40}px`;
1099
1567
  world.append(measure);
1100
1568
  }
1101
1569
  }
@@ -1105,12 +1573,43 @@ export class ProcessModeler extends BaseElement {
1105
1573
  openChooser(edit) {
1106
1574
  this.insertion = edit;
1107
1575
  const chooser = this.shadowRoot.querySelector('[part=insert-chooser]');
1576
+ this.shadowRoot.querySelector('[part=ghost-box]')?.remove();
1577
+ if ('position' in edit && edit.position) {
1578
+ const ghost = document.createElement('div');
1579
+ ghost.setAttribute('part', 'ghost-box');
1580
+ ghost.textContent = edit.kind?.label ?? 'New step';
1581
+ this.place(ghost, { ...edit.position, width: 224, height: 64 });
1582
+ this.shadowRoot.querySelector('[part=world]')?.append(ghost);
1583
+ }
1584
+ chooser.addEventListener('close', () => this.shadowRoot?.querySelector('[part=ghost-box]')?.remove(), { once: true });
1108
1585
  const picker = chooser.querySelector('box-kind-picker');
1109
- picker.catalog = this.catalog;
1586
+ picker.catalog = this.catalog.filter(kind => !kind.placement);
1110
1587
  picker.refresh();
1111
1588
  promoteModal(chooser);
1112
1589
  picker.focus();
1113
1590
  }
1591
+ addLayoutKind(kind, point) {
1592
+ const canvas = this.shadowRoot.querySelector('[part=canvas]');
1593
+ const position = point ?? { x: (canvas.clientWidth / 2 - this.viewport.x) / this.viewport.zoom, y: (canvas.clientHeight / 2 - this.viewport.y) / this.viewport.zoom };
1594
+ const id = `${kind.placement}-${++this.sectionSequence}`;
1595
+ if (kind.placement === 'note')
1596
+ this.setNote({ id, text: kind.label, x: position.x, y: position.y });
1597
+ if (kind.placement === 'section')
1598
+ this.setSection({ id, title: kind.label, description: '', x: position.x, y: position.y, width: 320, height: 240 });
1599
+ }
1600
+ showConnectTooltip(point, message, invalid = false) {
1601
+ const world = this.shadowRoot.querySelector('[part=world]');
1602
+ let tooltip = world.querySelector('[part=connect-tooltip]');
1603
+ if (!tooltip) {
1604
+ tooltip = document.createElement('div');
1605
+ tooltip.setAttribute('part', 'connect-tooltip');
1606
+ world.append(tooltip);
1607
+ }
1608
+ tooltip.textContent = message;
1609
+ tooltip.dataset.invalid = String(invalid);
1610
+ tooltip.style.left = `${point.x + 16}px`;
1611
+ tooltip.style.top = `${point.y + 16}px`;
1612
+ }
1114
1613
  update() {
1115
1614
  const selectionBefore = JSON.stringify({ ids: [...this.selectedIds], path: this.selectedPath });
1116
1615
  if (this.documentValue !== undefined) {
@@ -1124,11 +1623,13 @@ export class ProcessModeler extends BaseElement {
1124
1623
  this.computedChecks = this.collectChecks();
1125
1624
  if (this.selectedId && !this.selected)
1126
1625
  this.selectedId = null;
1626
+ if (this.selectedLineId && !this.selectedLine)
1627
+ this.selectedLineId = null;
1127
1628
  this.selectedIds = new Set([...this.selectedIds].filter(id => this.projection.boxes.some(box => box.id === id)));
1128
1629
  if (!this.selectedId)
1129
1630
  this.selectedId = this.selectedIds.values().next().value ?? null;
1130
1631
  const serialized = JSON.stringify({
1131
- 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, parentId: box.parentId })),
1632
+ 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 })),
1132
1633
  lines: this.projection.lines,
1133
1634
  });
1134
1635
  if (serialized !== this.lastProjection || this.documentValue !== this.lastDocument) {
@@ -1138,6 +1639,15 @@ export class ProcessModeler extends BaseElement {
1138
1639
  this.lastDocument = this.documentValue;
1139
1640
  emit(this, "projection-changed", { projection: this.projection, version: this.versionValue, checks: this.allChecks });
1140
1641
  }
1642
+ // Riptide or another host owns conversion. It supplies validate() and
1643
+ // receives this event only when that conversion can read the drawing.
1644
+ if (this.allChecks.length)
1645
+ this.lastReadableProjection = "";
1646
+ if (!this.allChecks.length && serialized !== this.lastReadableProjection) {
1647
+ this.lastReadableProjection = serialized;
1648
+ this.readableValue = this.projection;
1649
+ emit(this, "readable-projection-changed", { projection: this.projection, version: this.versionValue });
1650
+ }
1141
1651
  const focused = this.shadowRoot.activeElement
1142
1652
  ?.dataset.boxId;
1143
1653
  this.renderWorld();
@@ -1155,7 +1665,8 @@ export class ProcessModeler extends BaseElement {
1155
1665
  collectChecks() {
1156
1666
  const routes = this.projection.lines.filter(line => !this.routedLines.get(line.id)?.length)
1157
1667
  .map(line => ({ message: "A connection is blocked by overlapping steps. Move a step or adjust its route.", boxId: line.from }));
1158
- return [...this.checks, ...graphChecks(this.projection), ...routes, ...(this.documentValue === undefined ? [] : this.model.validate?.(this.documentValue, this.projection) ?? [])];
1668
+ const hostChecks = this.documentValue === undefined ? undefined : this.model.validate?.(this.documentValue, this.projection);
1669
+ return [...this.checks, ...(hostChecks ?? graphChecks(this.projection)), ...routes];
1159
1670
  }
1160
1671
  renderWorld() {
1161
1672
  const world = this.shadowRoot.querySelector("[part=world]");
@@ -1163,7 +1674,14 @@ export class ProcessModeler extends BaseElement {
1163
1674
  for (const section of this.layoutValue.sections ?? []) {
1164
1675
  const element = document.createElement("div");
1165
1676
  element.setAttribute("part", "section");
1166
- element.textContent = section.title;
1677
+ const title = document.createElement('strong');
1678
+ title.textContent = section.title;
1679
+ element.append(title);
1680
+ if (section.description) {
1681
+ const description = document.createElement('small');
1682
+ description.textContent = section.description;
1683
+ element.append(description);
1684
+ }
1167
1685
  this.place(element, section);
1168
1686
  world.append(element);
1169
1687
  }
@@ -1173,27 +1691,66 @@ export class ProcessModeler extends BaseElement {
1173
1691
  element.setAttribute("role", "group");
1174
1692
  element.setAttribute("part", box.frame ? "frame" : "box");
1175
1693
  element.dataset.boxId = box.id;
1176
- element.setAttribute("aria-label", [box.title, box.description, box.frame ? "Frame" : ""]
1694
+ const kind = this.catalog.find((entry) => entry.kind === box.kind);
1695
+ const shape = box.shape ?? kind?.shape ?? (box.frame ? "frame" : "task");
1696
+ element.dataset.shape = shape;
1697
+ element.dataset.kind = box.kind;
1698
+ element.dataset.tone = kind?.tone === "accent" ? "accent" : "neutral";
1699
+ const problem = this.allChecks.find(check => check.boxId === box.id || (check.path && JSON.stringify(check.path) === JSON.stringify(box.path)));
1700
+ const incoming = this.projection.lines.filter(line => line.to === box.id).length;
1701
+ const outgoing = this.projection.lines.filter(line => line.from === box.id).length;
1702
+ element.setAttribute("aria-label", [`${kind?.label ?? box.kind}: ${box.title}. ${incoming} in, ${outgoing} out`, problem ? `Needs attention: ${problem.message}` : ""]
1177
1703
  .filter(Boolean)
1178
1704
  .join(". "));
1179
1705
  element.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
1180
1706
  this.place(element, this.layoutValue.boxes[box.id]);
1181
- const kind = this.catalog.find((kind) => kind.kind === box.kind);
1707
+ const icon = document.createElement("span");
1708
+ icon.setAttribute("part", "icon");
1709
+ icon.setAttribute("aria-hidden", "true");
1182
1710
  if (kind?.icon) {
1183
- const icon = document.createElement("span");
1184
- icon.setAttribute("part", "icon");
1185
- icon.setAttribute("aria-hidden", "true");
1186
1711
  icon.append(kind.icon());
1187
- element.append(icon);
1188
1712
  }
1713
+ element.append(icon);
1189
1714
  const title = document.createElement("strong");
1190
1715
  title.textContent = box.title;
1191
1716
  element.append(title);
1192
- if (box.description) {
1717
+ const descriptionText = this.detailValue === "technical" ? box.technicalDescription ?? box.description : box.description;
1718
+ if (descriptionText) {
1193
1719
  const description = document.createElement("small");
1194
- description.textContent = box.description;
1720
+ description.textContent = descriptionText;
1721
+ if (this.detailValue === "technical")
1722
+ description.setAttribute("part", "technical-description");
1195
1723
  element.append(description);
1196
1724
  }
1725
+ if (box.frame && box.loopMark) {
1726
+ const loop = document.createElement('span');
1727
+ loop.setAttribute('part', 'loop-mark');
1728
+ loop.setAttribute('aria-hidden', 'true');
1729
+ loop.textContent = '↻';
1730
+ element.append(loop);
1731
+ }
1732
+ const metrics = this.showLastRunValue ? this.lastRunValue?.steps[box.id] : undefined;
1733
+ if (metrics) {
1734
+ const line = document.createElement("span");
1735
+ line.setAttribute("part", "metrics");
1736
+ line.textContent = metrics.notInRun ? "Not in this run" : [
1737
+ metrics.callsPerSecond === undefined ? "" : `${metrics.callsPerSecond.toLocaleString()}/s`,
1738
+ metrics.p95Ms === undefined ? "" : `p95 ${metrics.p95Ms.toLocaleString()} ms`,
1739
+ ].filter(Boolean).join(" · ");
1740
+ if (metrics.failedShare !== undefined && metrics.failedShare > 0) {
1741
+ const failed = document.createElement('span');
1742
+ failed.setAttribute('part', 'metric-warning');
1743
+ failed.textContent = `${(metrics.failedShare * 100).toFixed(1)}% failed`;
1744
+ line.append(document.createTextNode(' · '), failed);
1745
+ }
1746
+ element.append(line);
1747
+ }
1748
+ if (problem) {
1749
+ const message = document.createElement("span");
1750
+ message.setAttribute("part", "problem");
1751
+ message.textContent = problem.message;
1752
+ element.append(message);
1753
+ }
1197
1754
  element.addEventListener("keydown", event => {
1198
1755
  if (event.target === element && (event.key === "Enter" || event.key === " ")) {
1199
1756
  event.preventDefault();
@@ -1205,6 +1762,12 @@ export class ProcessModeler extends BaseElement {
1205
1762
  this.suppressClick = false;
1206
1763
  return;
1207
1764
  }
1765
+ if (this.pendingReattach) {
1766
+ const pending = this.pendingReattach;
1767
+ this.pendingReattach = undefined;
1768
+ this.requestEdit({ type: 'reattach', lineId: pending.lineId, [pending.end]: box.id });
1769
+ return;
1770
+ }
1208
1771
  if (this.connecting && this.connecting !== box.id) {
1209
1772
  this.requestEdit({
1210
1773
  type: "connect",
@@ -1237,27 +1800,76 @@ export class ProcessModeler extends BaseElement {
1237
1800
  element.append(port);
1238
1801
  }
1239
1802
  }
1803
+ if (box.frame && !this.locked) {
1804
+ const handle = document.createElement('button');
1805
+ handle.type = 'button';
1806
+ handle.setAttribute('part', 'frame-resize');
1807
+ handle.dataset.owner = box.id;
1808
+ handle.setAttribute('aria-label', `Resize ${box.title}`);
1809
+ handle.addEventListener('keydown', event => {
1810
+ const delta = { ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16] }[event.key];
1811
+ if (!delta)
1812
+ return;
1813
+ event.preventDefault();
1814
+ event.stopPropagation();
1815
+ const current = this.layoutValue.boxes[box.id];
1816
+ this.resize(box.id, (current.width ?? 224) + delta[0], (current.height ?? 64) + delta[1]);
1817
+ Array.from(this.shadowRoot.querySelectorAll('[part=frame-resize]')).find(handle => handle.dataset.owner === box.id)?.focus();
1818
+ });
1819
+ element.append(handle);
1820
+ }
1240
1821
  world.append(element);
1241
1822
  }
1242
1823
  const lines = svgElement("svg");
1243
1824
  lines.setAttribute("part", "lines");
1244
1825
  lines.setAttribute("aria-hidden", "true");
1826
+ const defs = svgElement('defs');
1827
+ for (const [id, fill] of [['boe-process-arrow', 'var(--boe-token-text-text-secondary, #6f6f6f)'], ['boe-process-arrow-brand', 'var(--boe-token-surface-surface-brand, #0061d5)']]) {
1828
+ const marker = svgElement('marker');
1829
+ marker.id = id;
1830
+ marker.setAttribute('viewBox', '0 0 8 8');
1831
+ marker.setAttribute('refX', '7');
1832
+ marker.setAttribute('refY', '4');
1833
+ marker.setAttribute('markerWidth', '8');
1834
+ marker.setAttribute('markerHeight', '8');
1835
+ marker.setAttribute('orient', 'auto');
1836
+ const arrow = svgElement('path');
1837
+ arrow.setAttribute('d', 'M 0 0 L 8 4 L 0 8 Z');
1838
+ arrow.setAttribute('fill', fill);
1839
+ marker.append(arrow);
1840
+ defs.append(marker);
1841
+ }
1842
+ lines.append(defs);
1245
1843
  world.append(lines);
1246
1844
  for (const line of this.projection.lines) {
1247
1845
  const points = this.routedLines.get(line.id);
1248
1846
  if (!points.length)
1249
1847
  continue;
1250
1848
  const midpoint = lineMidpoint(points);
1251
- const path = svgElement("polyline");
1849
+ const path = svgElement("path");
1252
1850
  path.setAttribute("part", "line");
1253
1851
  path.dataset.lineId = line.id;
1852
+ path.dataset.selected = String(this.selectedLineId === line.id);
1853
+ path.setAttribute('marker-end', `url(#${this.selectedLineId === line.id ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`);
1254
1854
  path.setAttribute("vector-effect", "non-scaling-stroke");
1255
- path.setAttribute("points", points
1256
- .map((point) => `${point.x},${point.y}`)
1257
- .join(" "));
1855
+ path.setAttribute("d", roundedProcessPath(points));
1258
1856
  if (line.dashed)
1259
1857
  path.setAttribute("stroke-dasharray", "6 4");
1858
+ if (this.showLastRunValue && line.share !== undefined) {
1859
+ path.style.strokeWidth = `${1.5 + Math.max(0, Math.min(1, line.share)) * 2}px`;
1860
+ path.setAttribute('aria-label', `${Math.round(line.share * 100)}% of last-run traffic`);
1861
+ }
1260
1862
  lines.append(path);
1863
+ for (const point of [line.fromSide ? points[0] : null, line.toSide ? points.at(-1) : null]) {
1864
+ if (!point)
1865
+ continue;
1866
+ const pin = svgElement('circle');
1867
+ pin.setAttribute('part', 'pinned-end');
1868
+ pin.setAttribute('cx', String(point.x));
1869
+ pin.setAttribute('cy', String(point.y));
1870
+ pin.setAttribute('r', '3');
1871
+ lines.append(pin);
1872
+ }
1261
1873
  for (let i = 1; i < points.length - 2; i++) {
1262
1874
  const hit = svgElement("polyline");
1263
1875
  hit.setAttribute("part", "line-hit");
@@ -1269,9 +1881,18 @@ export class ProcessModeler extends BaseElement {
1269
1881
  }
1270
1882
  const label = document.createElement("div");
1271
1883
  label.setAttribute("part", "connection");
1884
+ label.setAttribute('role', 'group');
1885
+ label.tabIndex = 0;
1272
1886
  label.style.left = `${midpoint.x}px`;
1273
1887
  label.style.top = `${midpoint.y}px`;
1274
1888
  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}` : ""}`;
1889
+ label.setAttribute('aria-label', `Connection: ${names}${this.showLastRunValue && line.share !== undefined ? `. ${Math.round(line.share * 100)}% of last-run traffic` : ''}`);
1890
+ label.addEventListener('click', event => { if (event.target.closest('button'))
1891
+ return; this.selectLine(line.id); });
1892
+ label.addEventListener('keydown', event => { if (event.target === label && (event.key === 'Enter' || event.key === ' ')) {
1893
+ event.preventDefault();
1894
+ this.selectLine(line.id);
1895
+ } });
1275
1896
  const text = document.createElement("span");
1276
1897
  const source = this.projection.boxes.find(box => box.id === line.from);
1277
1898
  const branchIndex = this.projection.lines.filter(candidate => candidate.from === line.from).indexOf(line);
@@ -1333,12 +1954,26 @@ export class ProcessModeler extends BaseElement {
1333
1954
  actions.append(button);
1334
1955
  }
1335
1956
  world.append(label);
1957
+ if (this.selectedLineId === line.id && !this.locked && !this.disableConnections) {
1958
+ for (const [end, point] of [['from', points[0]], ['to', points.at(-1)]]) {
1959
+ const grip = document.createElement('button');
1960
+ grip.type = 'button';
1961
+ grip.setAttribute('part', 'end-grip');
1962
+ grip.dataset.lineId = line.id;
1963
+ grip.dataset.end = end;
1964
+ grip.style.left = `${point.x - 12}px`;
1965
+ grip.style.top = `${point.y - 12}px`;
1966
+ grip.setAttribute('aria-label', `Reattach ${end === 'from' ? 'start' : 'end'} of connection: ${names}`);
1967
+ grip.addEventListener('click', event => { event.stopPropagation(); this.pendingReattach = { lineId: line.id, end }; this.setStatus('Select a step to reattach the connection'); });
1968
+ world.append(grip);
1969
+ }
1970
+ }
1336
1971
  }
1337
1972
  for (const note of this.layoutValue.notes ?? []) {
1338
1973
  const element = document.createElement("div");
1339
1974
  element.setAttribute("part", "note");
1340
1975
  element.textContent = note.text;
1341
- this.place(element, { ...note, width: 180, height: 80 });
1976
+ this.place(element, { ...note, width: 208, height: 80 });
1342
1977
  world.append(element);
1343
1978
  }
1344
1979
  }
@@ -1346,8 +1981,8 @@ export class ProcessModeler extends BaseElement {
1346
1981
  Object.assign(element.style, {
1347
1982
  left: `${position.x}px`,
1348
1983
  top: `${position.y}px`,
1349
- width: `${position.width ?? 220}px`,
1350
- height: `${position.height ?? 96}px`,
1984
+ width: `${position.width ?? 224}px`,
1985
+ height: `${position.height ?? 64}px`,
1351
1986
  });
1352
1987
  }
1353
1988
  renderPalette() {
@@ -1355,7 +1990,7 @@ export class ProcessModeler extends BaseElement {
1355
1990
  const query = this.shadowRoot.querySelector("[part=search]").value.toLowerCase();
1356
1991
  choices.replaceChildren();
1357
1992
  let group = "";
1358
- for (const kind of this.catalog.filter((kind) => `${kind.label} ${kind.description} ${kind.group ?? ""}`
1993
+ for (const kind of this.catalog.filter((kind) => `${kind.label} ${kind.description} ${kind.group ?? ""} ${(kind.aliases ?? []).join(" ")}`
1359
1994
  .toLowerCase()
1360
1995
  .includes(query))) {
1361
1996
  if (kind.group && kind.group !== group) {
@@ -1367,28 +2002,69 @@ export class ProcessModeler extends BaseElement {
1367
2002
  }
1368
2003
  const button = document.createElement("button");
1369
2004
  button.type = "button";
1370
- button.textContent = kind.label;
2005
+ button.setAttribute("part", "choice");
2006
+ button.dataset.tone = kind.tone === "accent" ? "accent" : "neutral";
2007
+ const icon = document.createElement("span");
2008
+ icon.setAttribute("part", "choice-icon");
2009
+ icon.setAttribute("aria-hidden", "true");
2010
+ if (kind.icon)
2011
+ icon.append(kind.icon());
2012
+ const name = document.createElement("span");
2013
+ name.setAttribute("part", "choice-name");
2014
+ name.textContent = kind.label;
2015
+ const description = document.createElement("span");
2016
+ description.setAttribute("part", "choice-description");
2017
+ description.textContent = kind.description;
2018
+ button.append(icon, name, description);
1371
2019
  button.disabled = this.locked;
1372
2020
  button.draggable = !this.locked;
1373
- button.addEventListener("click", () => this.requestEdit({ type: "add", kind }));
1374
- button.addEventListener("dragstart", (event) => event.dataTransfer?.setData("application/boe-process-kind", kind.kind));
2021
+ button.addEventListener("click", () => {
2022
+ if (kind.placement) {
2023
+ this.addLayoutKind(kind);
2024
+ return;
2025
+ }
2026
+ const from = this.selectedId;
2027
+ const outgoing = from ? this.projection.lines.filter(line => line.from === from) : [];
2028
+ const line = outgoing.length === 1 ? outgoing[0] : undefined;
2029
+ this.requestEdit(line ? { type: "insert", kind, lineId: line.id, from: line.from, to: line.to } : { type: "add", kind, ...(from ? { from } : {}) });
2030
+ });
2031
+ button.addEventListener("dragstart", event => { this.paletteDragKind = kind; event.dataTransfer?.setData("application/boe-process-kind", kind.kind); });
2032
+ button.addEventListener("dragend", () => { this.paletteDragKind = null; this.shadowRoot?.querySelector('[part=palette-ghost]')?.remove(); this.markDropLine(undefined); });
1375
2033
  choices.append(button);
1376
2034
  }
1377
2035
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
1378
2036
  canvas.ondragover = (event) => {
1379
2037
  if (!this.locked) {
1380
2038
  event.preventDefault();
1381
- this.markDropLine(this.canvasPoint(event));
2039
+ const point = this.canvasPoint(event);
2040
+ this.markDropLine(point);
2041
+ if (this.paletteDragKind) {
2042
+ let ghost = this.shadowRoot.querySelector('[part=palette-ghost]');
2043
+ if (!ghost) {
2044
+ ghost = document.createElement('div');
2045
+ ghost.setAttribute('part', 'palette-ghost');
2046
+ this.shadowRoot.querySelector('[part=world]').append(ghost);
2047
+ }
2048
+ ghost.textContent = this.paletteDragKind.label;
2049
+ this.place(ghost, { x: point.x - 112, y: point.y - 32, width: 224, height: 64 });
2050
+ }
1382
2051
  }
1383
2052
  };
1384
- canvas.ondragleave = () => this.markDropLine(undefined);
2053
+ canvas.ondragleave = () => { this.markDropLine(undefined); this.shadowRoot.querySelector('[part=palette-ghost]')?.remove(); };
1385
2054
  canvas.ondrop = (event) => {
1386
2055
  event.preventDefault();
2056
+ this.shadowRoot.querySelector('[part=palette-ghost]')?.remove();
2057
+ this.paletteDragKind = null;
1387
2058
  const kind = this.catalog.find((kind) => kind.kind ===
1388
2059
  event.dataTransfer?.getData("application/boe-process-kind"));
1389
2060
  if (!kind || this.locked)
1390
2061
  return;
1391
2062
  const position = this.canvasPoint(event);
2063
+ if (kind.placement) {
2064
+ this.addLayoutKind(kind, position);
2065
+ this.markDropLine(undefined);
2066
+ return;
2067
+ }
1392
2068
  const line = this.lineAt(position);
1393
2069
  const frame = this.boxAt(position, undefined, true);
1394
2070
  this.requestEdit({
@@ -1403,6 +2079,9 @@ export class ProcessModeler extends BaseElement {
1403
2079
  renderChecks() {
1404
2080
  const checks = this.shadowRoot.querySelector("[part=checks]");
1405
2081
  checks.replaceChildren();
2082
+ const hold = this.shadowRoot.querySelector('[part=hold]');
2083
+ hold.hidden = this.allChecks.length === 0;
2084
+ hold.querySelector('[part=hold-text]').textContent = `${this.allChecks.length} ${this.allChecks.length === 1 ? 'problem needs' : 'problems need'} fixing before this drawing can be read back.`;
1406
2085
  this.allChecks.forEach((check) => {
1407
2086
  const button = document.createElement("button");
1408
2087
  button.type = "button";
@@ -1420,9 +2099,231 @@ export class ProcessModeler extends BaseElement {
1420
2099
  checks.append(button);
1421
2100
  });
1422
2101
  }
2102
+ renderField(box, field) {
2103
+ const row = document.createElement('div');
2104
+ row.setAttribute('part', 'field');
2105
+ const label = document.createElement('label');
2106
+ label.textContent = field.label;
2107
+ let control;
2108
+ if (field.kind === 'multiline')
2109
+ control = document.createElement('textarea');
2110
+ else if (field.kind === 'choice') {
2111
+ const select = document.createElement('select');
2112
+ const groups = new Map();
2113
+ for (const option of field.options ?? []) {
2114
+ const item = document.createElement('option');
2115
+ item.value = option.value;
2116
+ item.textContent = option.label;
2117
+ if (option.group) {
2118
+ let group = groups.get(option.group);
2119
+ if (!group) {
2120
+ group = document.createElement('optgroup');
2121
+ group.label = option.group;
2122
+ groups.set(option.group, group);
2123
+ select.append(group);
2124
+ }
2125
+ group.append(item);
2126
+ }
2127
+ else
2128
+ select.append(item);
2129
+ }
2130
+ control = select;
2131
+ }
2132
+ else {
2133
+ const input = document.createElement('input');
2134
+ input.type = field.kind === 'boolean' ? 'checkbox' : field.kind === 'number' ? 'number' : field.kind === 'search' ? 'search' : 'text';
2135
+ control = input;
2136
+ }
2137
+ control.dataset.field = field.key;
2138
+ if (control instanceof HTMLInputElement && control.type === 'checkbox')
2139
+ control.checked = Boolean(field.value);
2140
+ else
2141
+ control.value = field.kind === 'action' ? field.options?.find(option => option.value === field.value)?.label ?? String(field.value) : String(field.value);
2142
+ control.disabled = Boolean(field.disabled || this.locked);
2143
+ control.required = Boolean(field.required);
2144
+ if (field.placeholder && control instanceof HTMLInputElement)
2145
+ control.placeholder = field.placeholder;
2146
+ if (field.problem)
2147
+ control.setAttribute('aria-invalid', 'true');
2148
+ 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 });
2149
+ if (field.kind !== 'action')
2150
+ control.addEventListener(field.kind === 'expression' ? 'input' : 'change', submit);
2151
+ label.append(control);
2152
+ row.append(label);
2153
+ if (field.kind === 'action' && control instanceof HTMLInputElement) {
2154
+ const input = control;
2155
+ const options = field.options ?? [];
2156
+ const list = document.createElement('div');
2157
+ list.setAttribute('part', 'action-options');
2158
+ list.id = `process-action-${box.id}-${field.key}`.replace(/[^a-zA-Z0-9-]/g, '-');
2159
+ list.hidden = true;
2160
+ const optionList = document.createElement('div');
2161
+ optionList.setAttribute('role', 'listbox');
2162
+ optionList.id = `${list.id}-choices`;
2163
+ optionList.setAttribute('aria-label', `${field.label} choices`);
2164
+ input.setAttribute('role', 'combobox');
2165
+ input.setAttribute('aria-autocomplete', 'list');
2166
+ input.setAttribute('aria-controls', optionList.id);
2167
+ input.setAttribute('aria-expanded', 'false');
2168
+ input.autocomplete = 'off';
2169
+ let group = null;
2170
+ let active = 0;
2171
+ let browse = false;
2172
+ const close = () => { list.hidden = true; browse = false; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); };
2173
+ const choose = (option) => { input.focus(); input.value = option.value; close(); submit(); };
2174
+ const render = () => {
2175
+ list.replaceChildren();
2176
+ optionList.replaceChildren();
2177
+ const query = browse ? '' : input.value.trim().toLocaleLowerCase();
2178
+ const matches = options.filter(option => (!group || option.group === group) && (!query || `${option.label} ${option.value} ${option.group ?? ''}`.toLocaleLowerCase().includes(query)));
2179
+ if (!group && !query) {
2180
+ const groups = [...new Set(options.map(option => option.group).filter((name) => Boolean(name)))];
2181
+ for (const name of groups) {
2182
+ const button = document.createElement('button');
2183
+ button.type = 'button';
2184
+ button.setAttribute('part', 'action-group');
2185
+ button.textContent = name;
2186
+ button.onclick = () => { input.focus(); group = name; browse = true; render(); };
2187
+ list.append(button);
2188
+ }
2189
+ }
2190
+ if (group) {
2191
+ const back = document.createElement('button');
2192
+ back.type = 'button';
2193
+ back.setAttribute('part', 'action-back');
2194
+ back.textContent = `All actions / ${group}`;
2195
+ back.onclick = () => { input.focus(); group = null; browse = true; render(); };
2196
+ list.prepend(back);
2197
+ }
2198
+ for (const [index, option] of matches.entries()) {
2199
+ const item = document.createElement('button');
2200
+ item.type = 'button';
2201
+ item.setAttribute('role', 'option');
2202
+ item.setAttribute('part', 'action-option');
2203
+ item.id = `${list.id}-${index}`;
2204
+ item.setAttribute('aria-selected', String(index === active));
2205
+ const title = document.createElement('strong');
2206
+ title.textContent = option.label;
2207
+ item.append(title);
2208
+ if (option.value !== option.label) {
2209
+ const code = document.createElement('small');
2210
+ code.textContent = option.value;
2211
+ item.append(code);
2212
+ }
2213
+ item.onclick = () => choose(option);
2214
+ optionList.append(item);
2215
+ }
2216
+ if (!matches.length) {
2217
+ const empty = document.createElement('p');
2218
+ empty.textContent = 'No matching actions';
2219
+ list.append(empty);
2220
+ }
2221
+ list.append(optionList);
2222
+ active = Math.min(active, Math.max(0, matches.length - 1));
2223
+ list.hidden = false;
2224
+ input.setAttribute('aria-expanded', 'true');
2225
+ const selected = list.querySelectorAll('[role=option]')[active];
2226
+ if (selected)
2227
+ input.setAttribute('aria-activedescendant', selected.id);
2228
+ else
2229
+ input.removeAttribute('aria-activedescendant');
2230
+ };
2231
+ input.addEventListener('focus', () => { group = null; browse = false; render(); });
2232
+ input.addEventListener('input', () => { group = null; browse = false; active = 0; render(); });
2233
+ input.addEventListener('keydown', event => {
2234
+ if (event.key === 'Escape') {
2235
+ event.preventDefault();
2236
+ close();
2237
+ return;
2238
+ }
2239
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
2240
+ event.preventDefault();
2241
+ if (list.hidden)
2242
+ render();
2243
+ const items = Array.from(list.querySelectorAll('[role=option]'));
2244
+ if (!items.length)
2245
+ return;
2246
+ active = (active + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
2247
+ items.forEach((item, index) => item.setAttribute('aria-selected', String(index === active)));
2248
+ input.setAttribute('aria-activedescendant', items[active].id);
2249
+ items[active].scrollIntoView?.({ block: 'nearest' });
2250
+ }
2251
+ else if (event.key === 'Enter' && !list.hidden) {
2252
+ const items = Array.from(list.querySelectorAll('[role=option]'));
2253
+ if (items[active]) {
2254
+ event.preventDefault();
2255
+ items[active].click();
2256
+ }
2257
+ }
2258
+ });
2259
+ const caret = document.createElement('button');
2260
+ caret.type = 'button';
2261
+ caret.setAttribute('part', 'action-caret');
2262
+ caret.setAttribute('aria-label', `Browse ${field.label.toLowerCase()} choices`);
2263
+ caret.textContent = '⌄';
2264
+ caret.disabled = input.disabled;
2265
+ caret.onclick = () => { input.focus(); group = null; browse = true; active = 0; render(); };
2266
+ row.addEventListener('focusout', event => { if (!row.contains(event.relatedTarget))
2267
+ close(); });
2268
+ row.append(caret, list);
2269
+ }
2270
+ if (field.kind === 'search' && field.options?.length && control instanceof HTMLInputElement) {
2271
+ const options = document.createElement('datalist');
2272
+ options.id = `process-field-${box.id}-${field.key}`.replace(/[^a-zA-Z0-9-]/g, '-');
2273
+ for (const option of field.options) {
2274
+ const item = document.createElement('option');
2275
+ item.value = option.value;
2276
+ item.label = option.label;
2277
+ options.append(item);
2278
+ }
2279
+ control.setAttribute('list', options.id);
2280
+ row.append(options);
2281
+ }
2282
+ if (field.kind === 'expression' && this.variables.length) {
2283
+ const chips = document.createElement('div');
2284
+ chips.setAttribute('part', 'variable-chips');
2285
+ chips.setAttribute('aria-label', 'Variables in scope');
2286
+ for (const variable of this.variables) {
2287
+ const chip = document.createElement('button');
2288
+ chip.type = 'button';
2289
+ chip.textContent = variable.name;
2290
+ chip.disabled = control.disabled;
2291
+ chip.onclick = () => { control.value += variable.name; submit(); control.focus(); };
2292
+ chips.append(chip);
2293
+ }
2294
+ row.append(chips);
2295
+ }
2296
+ if (field.description) {
2297
+ const help = document.createElement('small');
2298
+ help.textContent = field.description;
2299
+ row.append(help);
2300
+ }
2301
+ if (field.problem) {
2302
+ const problem = document.createElement('small');
2303
+ problem.setAttribute('part', 'field-problem');
2304
+ problem.textContent = field.problem;
2305
+ row.append(problem);
2306
+ }
2307
+ return row;
2308
+ }
1423
2309
  renderSelection() {
1424
2310
  if (!this.isRendered)
1425
2311
  return;
2312
+ const focusedControl = this.shadowRoot.activeElement;
2313
+ const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable;
2314
+ const focusedType = focusedControl?.dataset.field ? 'field' : focusedControl?.dataset.variable ? 'variable' : null;
2315
+ const caret = focusedControl && 'selectionStart' in focusedControl ? focusedControl.selectionStart : null;
2316
+ const restoreControlFocus = () => {
2317
+ if (!focusedKey || !focusedType)
2318
+ return;
2319
+ const control = Array.from(this.shadowRoot.querySelectorAll('input,textarea')).find(item => item.dataset[focusedType] === focusedKey);
2320
+ if (!control)
2321
+ return;
2322
+ control.focus();
2323
+ if (caret !== null && control.type !== 'number' && control.type !== 'checkbox')
2324
+ control.setSelectionRange(caret, caret);
2325
+ };
2326
+ this.shadowRoot.querySelectorAll('[part=line]').forEach(line => { const selected = line.dataset.lineId === this.selectedLineId; line.dataset.selected = String(selected); line.setAttribute('marker-end', `url(#${selected ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`); });
1426
2327
  this.shadowRoot.querySelectorAll("[data-box-id]").forEach((element) => {
1427
2328
  element.setAttribute("aria-current", String(this.selectedIds.has(element.dataset.boxId)));
1428
2329
  const box = this.projection.boxes.find((box) => box.id === element.dataset.boxId);
@@ -1432,14 +2333,18 @@ export class ProcessModeler extends BaseElement {
1432
2333
  });
1433
2334
  const editor = this.shadowRoot.querySelector("[part=editor]");
1434
2335
  const selected = this.selected;
2336
+ const controlsKey = `${[...this.selectedIds].join(',')}|${this.locked}|${this.disableConnections}|${this.showLastRunValue}|${JSON.stringify(selected ? this.fieldsValue[selected.id] ?? [] : [])}`;
1435
2337
  this.shadowRoot.querySelector("[part=palette]").hidden = false;
1436
- this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selectedIds.size === 0;
1437
- this.shadowRoot.querySelectorAll('[part=selection-toolbar] button').forEach((button, index) => button.disabled = this.locked || (index < 2 && this.selectedIds.size < 2));
2338
+ this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selectedIds.size === 0 && !this.selectedLineId;
2339
+ this.renderSelectionToolbar();
1438
2340
  this.renderPane();
2341
+ this.positionSelectionToolbar();
1439
2342
  if (this.inspected === selected?.node &&
1440
2343
  this.inspectedId === selected?.id &&
2344
+ this.inspectedControls === controlsKey &&
1441
2345
  editor.querySelector(`[part=inspector-heading]`)?.tagName === `H${this.headingLevel}`) {
1442
2346
  editor.querySelector("[part=inspector-heading]").textContent = selected?.title ?? "";
2347
+ restoreControlFocus();
1443
2348
  return;
1444
2349
  }
1445
2350
  this.cleanupInspector?.();
@@ -1447,13 +2352,170 @@ export class ProcessModeler extends BaseElement {
1447
2352
  editor.replaceChildren();
1448
2353
  this.inspected = selected?.node;
1449
2354
  this.inspectedId = selected?.id;
2355
+ this.inspectedControls = controlsKey;
2356
+ if (this.selectedIds.size > 1) {
2357
+ const heading = document.createElement(`h${this.headingLevel}`);
2358
+ heading.setAttribute('part', 'inspector-heading');
2359
+ heading.textContent = `${this.selectedIds.size} steps selected`;
2360
+ editor.append(heading);
2361
+ const actions = document.createElement('div');
2362
+ actions.setAttribute('part', 'arrange-actions');
2363
+ for (const [label, action] of [['Align left', 'left'], ['Align centre', 'center'], ['Align right', 'right'], ['Align top', 'top'], ['Align middle', 'middle'], ['Align bottom', 'bottom'], ['Distribute horizontally', 'distribute-horizontal'], ['Distribute vertically', 'distribute-vertical']]) {
2364
+ const button = document.createElement('button');
2365
+ button.type = 'button';
2366
+ button.textContent = label;
2367
+ button.disabled = this.locked;
2368
+ button.onclick = () => this.arrangeSelection(action);
2369
+ actions.append(button);
2370
+ }
2371
+ const section = document.createElement('button');
2372
+ section.type = 'button';
2373
+ section.textContent = 'Make a section';
2374
+ section.disabled = this.locked;
2375
+ section.onclick = () => this.makeSection();
2376
+ actions.append(section);
2377
+ editor.append(actions);
2378
+ restoreControlFocus();
2379
+ return;
2380
+ }
1450
2381
  if (selected) {
1451
2382
  const heading = document.createElement(`h${this.headingLevel}`);
1452
2383
  heading.setAttribute("part", "inspector-heading");
2384
+ heading.dataset.single = 'true';
1453
2385
  heading.textContent = selected.title;
1454
2386
  editor.append(heading);
2387
+ const kind = this.catalog.find(entry => entry.kind === selected.kind);
2388
+ const kindLabel = document.createElement('p');
2389
+ kindLabel.setAttribute('part', 'inspector-kind');
2390
+ kindLabel.textContent = kind?.label ?? selected.kind;
2391
+ editor.append(kindLabel);
2392
+ if (selected.description) {
2393
+ const purpose = document.createElement('p');
2394
+ purpose.setAttribute('part', 'inspector-purpose');
2395
+ purpose.textContent = selected.description;
2396
+ editor.append(purpose);
2397
+ }
2398
+ for (const field of this.fieldsValue[selected.id] ?? [])
2399
+ editor.append(this.renderField(selected, field));
1455
2400
  this.cleanupInspector =
1456
2401
  this.renderer?.(selected.node, editor) || undefined;
2402
+ const outgoing = this.projection.lines.filter(line => line.from === selected.id);
2403
+ const leads = document.createElement('section');
2404
+ leads.setAttribute('part', 'leads-to');
2405
+ const leadsHeading = document.createElement('h3');
2406
+ leadsHeading.textContent = 'Leads to';
2407
+ leads.append(leadsHeading);
2408
+ for (const line of outgoing) {
2409
+ const row = document.createElement('div');
2410
+ row.setAttribute('part', 'lead-row');
2411
+ const target = this.projection.boxes.find(box => box.id === line.to);
2412
+ const name = document.createElement('span');
2413
+ name.textContent = `${line.label ? `${line.label}: ` : ''}${target?.title ?? line.to}`;
2414
+ row.append(name);
2415
+ if (!this.disableConnections) {
2416
+ const remove = document.createElement('button');
2417
+ remove.type = 'button';
2418
+ remove.textContent = 'Remove';
2419
+ remove.setAttribute('aria-label', `Remove connection to ${target?.title ?? line.to}`);
2420
+ remove.disabled = this.locked;
2421
+ remove.onclick = () => this.requestEdit({ type: 'disconnect', lineId: line.id });
2422
+ row.append(remove);
2423
+ }
2424
+ leads.append(row);
2425
+ }
2426
+ const connect = document.createElement('button');
2427
+ connect.type = 'button';
2428
+ connect.textContent = 'Connect to…';
2429
+ connect.disabled = this.locked || this.disableConnections;
2430
+ connect.onclick = () => { this.connecting = selected.id; this.setStatus(`Select the next box to connect from ${selected.title}`); };
2431
+ leads.append(connect);
2432
+ editor.append(leads);
2433
+ const metrics = this.showLastRunValue ? this.lastRunValue?.steps[selected.id] : undefined;
2434
+ if (metrics) {
2435
+ const report = document.createElement('section');
2436
+ report.setAttribute('part', 'inspector-metrics');
2437
+ const title = document.createElement('h3');
2438
+ title.textContent = this.lastRunValue?.label ?? 'Last run';
2439
+ report.append(title);
2440
+ if (metrics.notInRun) {
2441
+ const note = document.createElement('p');
2442
+ note.textContent = 'Not in this run';
2443
+ report.append(note);
2444
+ }
2445
+ else {
2446
+ const list = document.createElement('dl');
2447
+ for (const [label, value] of [['Per second', metrics.callsPerSecond?.toLocaleString()], ['95% within', metrics.p95Ms === undefined ? undefined : `${metrics.p95Ms.toLocaleString()} ms`], ['Failed', metrics.failedShare === undefined ? undefined : `${(metrics.failedShare * 100).toFixed(1)}%`]]) {
2448
+ if (value === undefined)
2449
+ continue;
2450
+ const term = document.createElement('dt');
2451
+ term.textContent = label;
2452
+ const detail = document.createElement('dd');
2453
+ detail.textContent = value;
2454
+ if (label === 'Failed' && metrics.failedShare)
2455
+ detail.setAttribute('part', 'metric-warning');
2456
+ list.append(term, detail);
2457
+ }
2458
+ report.append(list);
2459
+ }
2460
+ editor.append(report);
2461
+ }
2462
+ const actions = document.createElement('div');
2463
+ actions.setAttribute('part', 'inspector-actions');
2464
+ const duplicate = document.createElement('button');
2465
+ duplicate.type = 'button';
2466
+ duplicate.textContent = 'Duplicate';
2467
+ duplicate.disabled = this.locked;
2468
+ duplicate.onclick = () => this.requestEdit({ type: 'duplicate', sourceId: selected.id, position: { x: this.layoutValue.boxes[selected.id].x + 32, y: this.layoutValue.boxes[selected.id].y + 32 } });
2469
+ const remove = document.createElement('button');
2470
+ remove.type = 'button';
2471
+ remove.textContent = 'Delete';
2472
+ remove.disabled = this.locked;
2473
+ remove.onclick = () => this.requestEdit({ type: 'delete', boxId: selected.id });
2474
+ actions.append(duplicate, remove);
2475
+ editor.append(actions);
2476
+ }
2477
+ restoreControlFocus();
2478
+ }
2479
+ renderSelectionToolbar() {
2480
+ const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
2481
+ const key = `${[...this.selectedIds].join(',')}|${this.selectedLineId}|${Boolean(this.selectedLineId && this.layoutValue.lines?.[this.selectedLineId])}|${this.locked}|${this.narrowValue}|${this.projection.lines.filter(line => line.from === this.selectedId).map(line => `${line.id}:${line.label}:${line.dashed}`).join(',')}`;
2482
+ if (key === this.selectionToolbarKey)
2483
+ return;
2484
+ this.selectionToolbarKey = key;
2485
+ toolbar.replaceChildren();
2486
+ if (!this.selectedIds.size && !this.selectedLineId)
2487
+ return;
2488
+ const add = (label, command, action) => {
2489
+ const button = document.createElement('button');
2490
+ button.type = 'button';
2491
+ button.dataset.selectionCommand = command;
2492
+ button.textContent = label;
2493
+ button.setAttribute('aria-label', label);
2494
+ button.disabled = this.locked;
2495
+ button.onclick = action;
2496
+ toolbar.append(button);
2497
+ };
2498
+ if (this.selectedLine) {
2499
+ const line = this.selectedLine;
2500
+ add('Insert a step', 'insert', () => this.openChooser({ type: 'insert', lineId: line.id, from: line.from, to: line.to }));
2501
+ if (this.layoutValue.lines?.[line.id])
2502
+ add('Reset line', 'reset-line', () => this.resetLine(line.id));
2503
+ if (!this.disableConnections)
2504
+ add('Delete', 'delete-line', () => this.requestEdit({ type: 'disconnect', lineId: line.id }));
2505
+ }
2506
+ else if (this.selectedIds.size === 1) {
2507
+ const box = this.selected;
2508
+ const failurePath = box.kind === 'try' && !this.projection.lines.some(line => line.from === box.id && (line.dashed || line.label === 'If it fails'));
2509
+ add(box.shape === 'gateway' ? 'Add a path' : failurePath ? 'If it fails' : 'Add next', 'add-next', () => this.openChooser({ type: 'add', from: box.id, ...(failurePath ? { routeLabel: 'If it fails', dashed: true } : {}) }));
2510
+ add('Duplicate', 'duplicate', () => { const position = this.layoutValue.boxes[box.id]; this.requestEdit({ type: 'duplicate', sourceId: box.id, position: { x: position.x + 32, y: position.y + 32 } }); });
2511
+ add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
2512
+ if (this.narrowValue)
2513
+ add('Details', 'details', () => promoteModal(this.shadowRoot.querySelector('[part=pane-drawer][data-pane=inspector]')));
2514
+ }
2515
+ else {
2516
+ add('Line up', 'align', () => this.selectionCommand('align'));
2517
+ add('Tidy these', 'space', () => this.selectionCommand('space'));
2518
+ add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
1457
2519
  }
1458
2520
  }
1459
2521
  updateToolbar() {
@@ -1461,15 +2523,23 @@ export class ProcessModeler extends BaseElement {
1461
2523
  button("undo").disabled = !this.history.canUndo || this.locked;
1462
2524
  button("redo").disabled = !this.history.canRedo || this.locked;
1463
2525
  button("tidy").disabled = this.locked;
1464
- button("connect").disabled = !this.selected || this.locked || this.disableConnections;
1465
- button("delete").disabled = !this.selected || this.locked;
1466
2526
  button("lock").setAttribute("aria-pressed", String(this.locked));
1467
2527
  button("snap").setAttribute("aria-pressed", String(this.snapToGrid));
2528
+ this.shadowRoot.querySelectorAll('[data-detail]').forEach(control => {
2529
+ control.setAttribute("aria-pressed", String(control.dataset.detail === this.detailValue));
2530
+ });
2531
+ const runToggle = this.shadowRoot.querySelector('[part=run-toggle]');
2532
+ runToggle.hidden = !this.lastRunValue;
2533
+ runToggle.querySelector('input').checked = this.showLastRunValue;
2534
+ const mobileRun = this.shadowRoot.querySelector('[data-view-option=last-run]');
2535
+ mobileRun.hidden = !this.lastRunValue;
2536
+ mobileRun.setAttribute('aria-pressed', String(this.showLastRunValue));
1468
2537
  }
1469
2538
  paintViewport() {
1470
2539
  this.shadowRoot.querySelector("[part=world]").style.transform =
1471
2540
  `translate(${this.viewport.x}px,${this.viewport.y}px) scale(${this.viewport.zoom})`;
1472
2541
  this.shadowRoot.querySelectorAll('[part=port]').forEach(port => port.style.transform = `scale(${1 / this.viewport.zoom})`);
2542
+ this.positionSelectionToolbar();
1473
2543
  this.shadowRoot.querySelector("[data-command=reset]").textContent =
1474
2544
  `${Math.round(this.viewport.zoom * 100)}%`;
1475
2545
  const minimap = this.shadowRoot.querySelector("[part=minimap]");
@@ -1482,8 +2552,8 @@ export class ProcessModeler extends BaseElement {
1482
2552
  const rect = svgElement("rect");
1483
2553
  rect.setAttribute("x", String(pos.x));
1484
2554
  rect.setAttribute("y", String(pos.y));
1485
- rect.setAttribute("width", String(pos.width ?? 220));
1486
- rect.setAttribute("height", String(pos.height ?? 96));
2555
+ rect.setAttribute("width", String(pos.width ?? 224));
2556
+ rect.setAttribute("height", String(pos.height ?? 64));
1487
2557
  minimap.append(rect);
1488
2558
  });
1489
2559
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
@@ -1495,6 +2565,26 @@ export class ProcessModeler extends BaseElement {
1495
2565
  viewport.setAttribute("height", String(canvas.clientHeight / this.viewport.zoom));
1496
2566
  minimap.append(viewport);
1497
2567
  }
2568
+ positionSelectionToolbar() {
2569
+ if (!this.isRendered || (!this.selectedIds.size && !this.selectedLineId))
2570
+ return;
2571
+ const canvas = this.shadowRoot.querySelector('[part=canvas]');
2572
+ const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
2573
+ const positions = [...this.selectedIds].map(id => this.layoutValue.boxes[id]).filter(Boolean);
2574
+ if (!positions.length && this.selectedLine) {
2575
+ const point = lineMidpoint(this.routedLines.get(this.selectedLine.id) ?? []);
2576
+ 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`;
2577
+ toolbar.style.top = `${Math.max(8, point.y * this.viewport.zoom + this.viewport.y - 52)}px`;
2578
+ return;
2579
+ }
2580
+ if (!positions.length)
2581
+ return;
2582
+ const left = Math.min(...positions.map(position => position.x * this.viewport.zoom + this.viewport.x));
2583
+ const top = Math.min(...positions.map(position => position.y * this.viewport.zoom + this.viewport.y));
2584
+ const bottom = Math.max(...positions.map(position => (position.y + (position.height ?? 64)) * this.viewport.zoom + this.viewport.y));
2585
+ toolbar.style.left = `${Math.max(8, Math.min(left, Math.max(8, canvas.clientWidth - toolbar.offsetWidth - 8)))}px`;
2586
+ toolbar.style.top = `${top > 56 ? top - 48 : Math.min(bottom + 8, Math.max(8, canvas.clientHeight - toolbar.offsetHeight - 8))}px`;
2587
+ }
1498
2588
  focusBox(id) {
1499
2589
  Array.from(this.shadowRoot.querySelectorAll("[data-box-id]"))
1500
2590
  .find((box) => box.dataset.boxId === id)