@unofficialbox/box-open-elements 0.24.0 → 0.26.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.
Files changed (40) hide show
  1. package/README.md +13 -13
  2. package/dist/components/actions/button.d.ts +1 -0
  3. package/dist/components/actions/button.js +13 -0
  4. package/dist/components/forms/checkbox.d.ts +1 -0
  5. package/dist/components/forms/checkbox.js +2 -0
  6. package/dist/components/forms/code-editor.d.ts +28 -3
  7. package/dist/components/forms/code-editor.js +71 -6
  8. package/dist/components/forms/combobox.js +7 -8
  9. package/dist/components/forms/number-input.d.ts +1 -0
  10. package/dist/components/forms/number-input.js +4 -1
  11. package/dist/components/forms/select.d.ts +1 -0
  12. package/dist/components/forms/select.js +3 -1
  13. package/dist/components/forms/text-area.js +5 -7
  14. package/dist/components/forms/text-field.d.ts +1 -0
  15. package/dist/components/forms/text-field.js +2 -0
  16. package/dist/components/navigation/accordion.d.ts +7 -0
  17. package/dist/components/navigation/accordion.js +31 -8
  18. package/dist/components/output/code-block.js +3 -1
  19. package/dist/components/overlays/drawer.js +2 -2
  20. package/dist/foundations/geometry/tokens.d.ts +2 -2
  21. package/dist/foundations/geometry/tokens.js +3 -3
  22. package/dist/foundations/icons/box-iconography.generated.d.ts +472 -1
  23. package/dist/foundations/icons/box-iconography.generated.js +473 -2
  24. package/dist/patterns/flow-builder/flow-builder.d.ts +5 -1
  25. package/dist/patterns/flow-builder/flow-builder.js +18 -7
  26. package/dist/patterns/flow-builder/primitives.d.ts +1 -0
  27. package/dist/patterns/flow-builder/primitives.js +11 -4
  28. package/dist/patterns/process-modeler/bridge.d.ts +10 -0
  29. package/dist/patterns/process-modeler/bridge.js +173 -0
  30. package/dist/patterns/process-modeler/design.d.ts +2 -0
  31. package/dist/patterns/process-modeler/design.js +288 -0
  32. package/dist/patterns/process-modeler/geometry.d.ts +20 -0
  33. package/dist/patterns/process-modeler/geometry.js +330 -0
  34. package/dist/patterns/process-modeler/index.d.ts +2 -0
  35. package/dist/patterns/process-modeler/index.js +2 -0
  36. package/dist/patterns/process-modeler/model.d.ts +106 -1
  37. package/dist/patterns/process-modeler/model.js +2 -2
  38. package/dist/patterns/process-modeler/process-modeler.d.ts +115 -3
  39. package/dist/patterns/process-modeler/process-modeler.js +1864 -128
  40. package/package.json +4 -3
@@ -1,6 +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";
3
+ import { dismissModal, promoteModal } from "../../foundations/overlay/index.js";
4
+ import { KindPicker } from "../flow-builder/primitives.js";
5
+ import { arrangeProcess, routeProcessLine, lineMidpoint, nearProcessLine, roundedProcessPath } from "./geometry.js";
6
+ import { restoreProcessPositions, snapshotProcessPositions, graphChecks, normalizeProcessSelectionPath } from "./bridge.js";
7
+ import { processModelerDesign } from "./design.js";
4
8
  import { completeLayout, ProcessHistory, validateProjection, } from "./model.js";
5
9
  const svgElement = (tag) => document.createElementNS("http://www.w3.org/2000/svg", tag);
6
10
  const emit = (element, name, detail) => element.dispatchEvent(new CustomEvent(name, {
@@ -13,7 +17,7 @@ const emit = (element, name, detail) => element.dispatchEvent(new CustomEvent(na
13
17
  export class ProcessModeler extends BaseElement {
14
18
  static tagName = "box-process-modeler";
15
19
  static get observedAttributes() {
16
- return ["locked", "snap-to-grid"];
20
+ return ["locked", "snap-to-grid", "heading-level", "disable-connections", "embed-mode"];
17
21
  }
18
22
  documentValue;
19
23
  modelValue = {
@@ -22,19 +26,139 @@ export class ProcessModeler extends BaseElement {
22
26
  projection = { boxes: [], lines: [] };
23
27
  layoutValue = { boxes: {} };
24
28
  catalogValue = [];
29
+ detailValue = "business";
30
+ lastRunValue;
31
+ showLastRunValue = false;
25
32
  selectedId = null;
33
+ selectedLineId = null;
26
34
  connecting = null;
27
35
  checks = [];
28
36
  renderer;
29
37
  cleanupInspector;
30
38
  inspected;
31
39
  inspectedId;
40
+ inspectedControls = "";
32
41
  historyValue = new ProcessHistory();
33
42
  viewport = { x: 0, y: 0, zoom: 1 };
34
43
  pointers = new Map();
35
44
  drag;
36
45
  pinchDistance = 0;
37
46
  gestureZoom = 1;
47
+ insertion;
48
+ selectedIds = new Set();
49
+ clipboardIds = [];
50
+ selectionToolbarKey = "";
51
+ sectionSequence = 0;
52
+ versionValue = 0;
53
+ lastProjection = "";
54
+ lastReadableProjection = "";
55
+ readableValue = null;
56
+ lastDocument;
57
+ connectionsValue = [];
58
+ variablesValue = [];
59
+ outlineValue = [];
60
+ fieldsValue = {};
61
+ processTitleValue = "Process";
62
+ processSummaryValue = "";
63
+ activePane = "Outline";
64
+ portDrag;
65
+ endDrag;
66
+ pendingReattach;
67
+ marquee;
68
+ segmentDrag;
69
+ frameResize;
70
+ spacePressed = false;
71
+ dropLine;
72
+ paletteDragKind = null;
73
+ narrowValue = false;
74
+ suppressClick = false;
75
+ resizeObserver;
76
+ routedLines = new Map();
77
+ computedChecks = [];
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; }
80
+ get selectedPath() { return this.selected?.path ? [...this.selected.path] : null; }
81
+ set selectedPath(path) {
82
+ if (!this.isRendered && this.documentValue !== undefined) {
83
+ this.projection = this.model.project(this.documentValue);
84
+ validateProjection(this.projection);
85
+ }
86
+ const normalized = normalizeProcessSelectionPath(this.projection, path);
87
+ this.select(normalized === null ? null : this.projection.boxes.find(box => JSON.stringify(box.path) === JSON.stringify(normalized))?.id ?? null);
88
+ }
89
+ get connections() { return this.connectionsValue; }
90
+ set connections(value) { this.connectionsValue = value; this.refresh(); }
91
+ get variables() { return this.variablesValue; }
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
+ }
109
+ get version() { return this.versionValue; }
110
+ get narrow() { return this.narrowValue; }
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
+ }
116
+ load(document, options = {}) {
117
+ const projection = this.model.project(document);
118
+ validateProjection(projection);
119
+ this.documentValue = document;
120
+ this.projection = projection;
121
+ this.versionValue = options.version ?? 0;
122
+ this.lastProjection = "";
123
+ this.lastReadableProjection = "";
124
+ this.readableValue = null;
125
+ this.layoutValue = restoreProcessPositions(projection, options.positions ?? []);
126
+ this.history.clear();
127
+ this.selectedId = null;
128
+ this.selectedLineId = null;
129
+ this.selectedIds.clear();
130
+ this.clipboardIds = [];
131
+ this.refresh();
132
+ if (options.selectedPath)
133
+ this.selectedPath = options.selectedPath;
134
+ }
135
+ selectMany(ids) {
136
+ if (!this.isRendered && this.documentValue !== undefined) {
137
+ this.projection = this.model.project(this.documentValue);
138
+ validateProjection(this.projection);
139
+ }
140
+ const next = new Set(ids.filter(id => this.projection.boxes.some(box => box.id === id)));
141
+ if (next.size === this.selectedIds.size && [...next].every(id => this.selectedIds.has(id)))
142
+ return;
143
+ this.selectedIds = next;
144
+ this.selectedId = this.selectedIds.values().next().value ?? null;
145
+ this.selectedLineId = null;
146
+ this.renderSelection();
147
+ if (this.isRendered)
148
+ this.updateToolbar();
149
+ emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
150
+ }
151
+ get headingLevel() { const level = Number(this.getAttribute("heading-level") ?? 2); return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2; }
152
+ set headingLevel(value) { this.setAttribute("heading-level", String(value)); }
153
+ get disableConnections() { return this.hasAttribute("disable-connections"); }
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); }
158
+ arrangedLayout(layout) {
159
+ const arranged = this.model.arrange?.(this.projection) ?? arrangeProcess(this.projection);
160
+ return completeLayout(this.projection, { ...layout, boxes: { ...arranged?.boxes, ...layout.boxes } });
161
+ }
38
162
  get document() {
39
163
  return this.documentValue;
40
164
  }
@@ -74,6 +198,18 @@ export class ProcessModeler extends BaseElement {
74
198
  this.catalogValue = value;
75
199
  this.refresh();
76
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(); }
77
213
  get locked() {
78
214
  return this.hasAttribute("locked");
79
215
  }
@@ -100,12 +236,23 @@ export class ProcessModeler extends BaseElement {
100
236
  this.refresh();
101
237
  }
102
238
  disconnectedCallback() {
239
+ this.resizeObserver?.disconnect();
240
+ this.shadowRoot?.querySelectorAll("[part=pane-drawer]").forEach(dialog => dismissModal(dialog));
241
+ const chooser = this.shadowRoot?.querySelector("[part=insert-chooser]");
242
+ if (chooser)
243
+ dismissModal(chooser);
103
244
  this.cleanupInspector?.();
104
245
  this.cleanupInspector = undefined;
105
246
  this.inspected = undefined;
106
247
  this.pointers.clear();
107
248
  this.drag = undefined;
249
+ this.portDrag = undefined;
250
+ this.endDrag = undefined;
251
+ this.pendingReattach = undefined;
252
+ this.marquee = undefined;
253
+ this.segmentDrag = undefined;
108
254
  }
255
+ connectedCallback() { super.connectedCallback(); this.resizeObserver?.observe(this); }
109
256
  refresh() {
110
257
  if (this.isRendered)
111
258
  this.update();
@@ -117,11 +264,26 @@ export class ProcessModeler extends BaseElement {
117
264
  }
118
265
  if (id !== null && !this.projection.boxes.some((box) => box.id === id))
119
266
  return;
267
+ if (id === this.selectedId && this.selectedIds.size === (id ? 1 : 0) && !this.selectedLineId)
268
+ return;
120
269
  this.selectedId = id;
270
+ this.selectedIds = new Set(id ? [id] : []);
271
+ this.selectedLineId = null;
121
272
  this.renderSelection();
122
273
  if (this.isRendered)
123
274
  this.updateToolbar();
124
- emit(this, "selection-changed", { box: this.selected });
275
+ emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
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 });
125
287
  }
126
288
  setValidation(checks) {
127
289
  this.checks = checks;
@@ -131,7 +293,7 @@ export class ProcessModeler extends BaseElement {
131
293
  this.setValidation(message ? [{ message, path }] : []);
132
294
  }
133
295
  requestEdit(edit) {
134
- if (this.locked)
296
+ if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect" || edit.type === 'reattach')))
135
297
  return;
136
298
  const before = this.layout;
137
299
  const previousIds = new Set(this.projection.boxes.map((box) => box.id));
@@ -142,11 +304,14 @@ export class ProcessModeler extends BaseElement {
142
304
  if (accepted)
143
305
  return;
144
306
  accepted = true;
307
+ if (command.layout)
308
+ this.layoutValue = structuredClone(command.layout);
145
309
  this.refresh();
146
- if (edit.position &&
310
+ if (!command.layout &&
311
+ edit.position &&
147
312
  Number.isFinite(edit.position.x) &&
148
313
  Number.isFinite(edit.position.y)) {
149
- const added = this.projection.boxes.find((box) => !previousIds.has(box.id));
314
+ const added = this.projection.boxes.find(box => box.id === edit.boxId) ?? this.projection.boxes.find((box) => !previousIds.has(box.id));
150
315
  if (added)
151
316
  this.layoutValue = this.translatedLayout(added.id, edit.position.x, edit.position.y);
152
317
  }
@@ -154,7 +319,7 @@ export class ProcessModeler extends BaseElement {
154
319
  const restore = (layout) => {
155
320
  this.layoutValue = structuredClone(layout);
156
321
  this.refresh();
157
- emit(this, "layout-changed", { layout: this.layout });
322
+ this.emitPositions();
158
323
  };
159
324
  this.history.record({
160
325
  undo: () => {
@@ -168,7 +333,7 @@ export class ProcessModeler extends BaseElement {
168
333
  });
169
334
  this.refresh();
170
335
  if (JSON.stringify(before) !== JSON.stringify(after))
171
- emit(this, "layout-changed", { layout: this.layout });
336
+ this.emitPositions();
172
337
  announce("Process updated", "polite", this.ownerDocument);
173
338
  },
174
339
  };
@@ -194,7 +359,7 @@ export class ProcessModeler extends BaseElement {
194
359
  const apply = (layout) => {
195
360
  this.layoutValue = structuredClone(layout);
196
361
  this.refresh();
197
- emit(this, "layout-changed", { layout: this.layout });
362
+ this.emitPositions();
198
363
  };
199
364
  apply(after);
200
365
  this.history.record({
@@ -213,8 +378,12 @@ export class ProcessModeler extends BaseElement {
213
378
  if (emit(this, "move-request", { boxId: id, position: next.boxes[id] }))
214
379
  this.commitLayout(next);
215
380
  }
381
+ emitPositions() {
382
+ emit(this, "layout-changed", { layout: this.layout });
383
+ emit(this, "positions-changed", { positions: this.positions, version: this.versionValue });
384
+ }
216
385
  translatedLayout(id, x, y) {
217
- const snap = (n) => (this.snapToGrid ? Math.round(n / 20) * 20 : n);
386
+ const snap = (n) => (this.snapToGrid ? Math.round(n / 16) * 16 : n);
218
387
  const next = this.layout;
219
388
  next.boxes[id] = { ...next.boxes[id], x: snap(x), y: snap(y) };
220
389
  const dx = next.boxes[id].x - this.layoutValue.boxes[id].x;
@@ -241,7 +410,20 @@ export class ProcessModeler extends BaseElement {
241
410
  tidy() {
242
411
  if (this.locked)
243
412
  return;
244
- this.commitLayout(completeLayout(this.projection, { ...this.layout, boxes: {} }));
413
+ const before = this.layout.boxes;
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
+ }
245
427
  this.fit();
246
428
  }
247
429
  resize(id, width, height) {
@@ -269,6 +451,13 @@ export class ProcessModeler extends BaseElement {
269
451
  },
270
452
  });
271
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
+ }
272
461
  setNote(note, id = note?.id) {
273
462
  if (!id || (note && (!Number.isFinite(note.x) || !Number.isFinite(note.y))))
274
463
  return;
@@ -300,7 +489,7 @@ export class ProcessModeler extends BaseElement {
300
489
  const centerX = viewport.clientWidth / 2;
301
490
  const centerY = viewport.clientHeight / 2;
302
491
  const previous = this.viewport.zoom;
303
- const next = Math.max(0.25, Math.min(3, previous * factor));
492
+ const next = Math.max(0.25, Math.min(2, previous * factor));
304
493
  this.viewport.x = centerX - ((centerX - this.viewport.x) / previous) * next;
305
494
  this.viewport.y = centerY - ((centerY - this.viewport.y) / previous) * next;
306
495
  this.viewport.zoom = next;
@@ -321,55 +510,448 @@ export class ProcessModeler extends BaseElement {
321
510
  return {
322
511
  x,
323
512
  y,
324
- 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))) -
325
514
  x,
326
- 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))) -
327
516
  y,
328
517
  };
329
518
  }
330
519
  renderTemplate() {
331
- this.shadowRoot.innerHTML = `<style>
332
- :host{display:block;font:inherit;color:var(--boe-token-text-text,#222);min-width:0}:host([hidden]){display:none!important}
333
- *{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}
334
- ${boeFocusVisibleStyles(":is(button,input,[part=canvas],[part=minimap])")}
335
- [part=toolbar]{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
336
- [part=layout]{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,280px);gap:16px}
337
- [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}
338
- [part=world]{position:absolute;inset:0;transform-origin:0 0}
339
- [part=lines]{position:absolute;inset:0;width:1px;height:1px;overflow:visible;pointer-events:none}
340
- [part=line]{stroke:var(--boe-token-text-text-secondary,#666);stroke-width:2;fill:none}
341
- [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}
342
- [part=box] strong,[part=frame] strong{overflow-wrap:anywhere}[part=box] small{color:var(--boe-token-text-text-secondary,#666)}
343
- [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}
344
- [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}
345
- [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}
346
- [part=connection] button{font-size:12px;min-height:32px;padding:4px}
347
- [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)}
348
- [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}
349
- [part=choices]{display:grid;gap:6px;max-height:300px;overflow:auto}[part=group]{font-weight:600;margin:8px 0 0}
350
- [part=checks]{display:grid;gap:6px;margin-top:12px}[part=checks] button{text-align:start;color:var(--boe-token-text-status-text-error,#b92340)}
351
- [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}
352
- [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}
353
- [part=icon]{width:20px;height:20px;color:var(--boe-token-surface-surface-brand,#0061d5)}[part=icon] svg{width:20px;height:20px}
354
- [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}
355
- [part=help]{font-size:13px;color:var(--boe-token-text-text-secondary,#666)}
356
- @media(max-width:800px){[part=layout]{grid-template-columns:minmax(0,1fr)}[part=canvas]{height:420px}}
357
- @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important;scroll-behavior:auto!important}}
358
- </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>`;
521
+ const chooser = document.createElement("dialog");
522
+ chooser.setAttribute("part", "insert-chooser");
523
+ chooser.setAttribute("aria-label", "Insert a building block");
524
+ const picker = new KindPicker();
525
+ picker.variant = "menu";
526
+ picker.searchable = true;
527
+ chooser.append(picker);
528
+ this.shadowRoot.append(chooser);
529
+ picker.addEventListener("kind-pick", event => {
530
+ const insertion = this.insertion;
531
+ dismissModal(chooser);
532
+ this.insertion = undefined;
533
+ if (insertion)
534
+ this.requestEdit({ ...insertion, kind: event.detail.kind });
535
+ });
536
+ picker.addEventListener("picker-cancel", () => { dismissModal(chooser); this.insertion = undefined; });
537
+ this.setupPanes();
359
538
  }
360
- setupListeners() {
361
- const toolbar = this.shadowRoot.querySelector("[part=toolbar]");
362
- for (const [command, label] of [
363
- ["connect", "Connect selected"],
364
- ["delete", "Delete selected"],
365
- ]) {
539
+ setupPanes() {
540
+ const root = this.shadowRoot;
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"]]) {
366
547
  const button = document.createElement("button");
367
548
  button.type = "button";
368
- button.dataset.command = command;
549
+ button.dataset.detail = value;
369
550
  button.textContent = label;
370
- toolbar.append(button);
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);
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);
621
+ for (const [part, label] of [["palette", "Building blocks"], ["inspector", "Process details"]]) {
622
+ const pane = root.querySelector(`[part=${part}]`);
623
+ const dialog = document.createElement("dialog");
624
+ dialog.setAttribute("part", "pane-drawer");
625
+ dialog.setAttribute("aria-label", label);
626
+ dialog.dataset.pane = part;
627
+ const close = document.createElement("button");
628
+ close.setAttribute("part", "pane-close");
629
+ close.textContent = 'Close';
630
+ close.setAttribute('aria-label', `Close ${label.toLowerCase()}`);
631
+ close.onclick = () => dismissModal(dialog);
632
+ dialog.append(close, pane);
633
+ if (part === "palette")
634
+ layout.prepend(dialog);
635
+ else
636
+ layout.append(dialog);
637
+ const trigger = document.createElement("button");
638
+ trigger.dataset.command = part === "palette" ? "palette" : "details";
639
+ trigger.textContent = part === "palette" ? "Add" : "Details";
640
+ trigger.setAttribute("aria-label", `Open ${label.toLowerCase()}`);
641
+ trigger.onclick = () => promoteModal(dialog);
642
+ toolbar.append(trigger);
643
+ }
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);
653
+ const tabs = document.createElement("div");
654
+ tabs.setAttribute("part", "pane-tabs");
655
+ tabs.setAttribute("role", "tablist");
656
+ tabs.setAttribute("aria-label", "Process details");
657
+ for (const name of ["Outline", "Checks", "Variables", "Connections", "Shortcuts"]) {
658
+ const button = document.createElement("button");
659
+ button.textContent = name;
660
+ button.dataset.pane = name;
661
+ button.setAttribute("role", "tab");
662
+ button.id = `process-tab-${name.toLowerCase()}`;
663
+ button.setAttribute("aria-controls", "process-pane-content");
664
+ button.onclick = () => { this.activePane = name; this.renderPane(); };
665
+ button.onkeydown = event => {
666
+ const buttons = Array.from(tabs.querySelectorAll("button"));
667
+ const index = buttons.indexOf(button);
668
+ const next = event.key === "ArrowRight" ? (index + 1) % buttons.length : event.key === "ArrowLeft" ? (index + buttons.length - 1) % buttons.length : event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : -1;
669
+ if (next >= 0) {
670
+ event.preventDefault();
671
+ buttons[next].click();
672
+ buttons[next].focus();
673
+ }
674
+ };
675
+ tabs.append(button);
676
+ }
677
+ const content = document.createElement("div");
678
+ content.setAttribute("part", "pane-content");
679
+ content.setAttribute("role", "tabpanel");
680
+ content.id = "process-pane-content";
681
+ heading.after(tabs);
682
+ inspector.append(content);
683
+ root.querySelector('[part=checks]').removeAttribute("role");
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>`;
371
723
  }
372
- this.shadowRoot.querySelector("[part=toolbar]").addEventListener("click", (event) => {
724
+ const floating = document.createElement("div");
725
+ floating.setAttribute("part", "selection-toolbar");
726
+ floating.setAttribute("role", "toolbar");
727
+ floating.setAttribute("aria-label", "Selected steps");
728
+ canvas.append(floating);
729
+ if (typeof ResizeObserver !== "undefined") {
730
+ this.resizeObserver = new ResizeObserver(entries => {
731
+ const narrow = entries[0].contentRect.width < 900;
732
+ this.narrowValue = narrow;
733
+ this.toggleAttribute("data-narrow", narrow);
734
+ this.toggleAttribute('data-phone', entries[0].contentRect.width < 560);
735
+ this.renderSelectionToolbar();
736
+ this.positionSelectionToolbar();
737
+ if (!narrow)
738
+ root.querySelectorAll('[part=pane-drawer]').forEach(dialog => dismissModal(dialog));
739
+ });
740
+ this.resizeObserver.observe(this);
741
+ }
742
+ }
743
+ renderPane() {
744
+ if (!this.isRendered)
745
+ return;
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);
751
+ root.querySelectorAll('[part=pane-tabs] button').forEach(button => {
752
+ button.textContent = button.dataset.pane === "Checks" ? `Checks ${this.allChecks.length}` : button.dataset.pane ?? "";
753
+ const selected = button.dataset.pane === this.activePane;
754
+ button.setAttribute("aria-selected", String(selected));
755
+ button.tabIndex = selected ? 0 : -1;
756
+ });
757
+ const content = root.querySelector('[part=pane-content]');
758
+ content.hidden = Boolean(selected);
759
+ content.setAttribute("aria-labelledby", `process-tab-${this.activePane.toLowerCase()}`);
760
+ const checks = root.querySelector('[part=checks]');
761
+ checks.hidden = this.activePane !== "Checks";
762
+ // Move the checks node out before replacing its parent; keep its handlers.
763
+ root.querySelector('[part=inspector]').append(checks);
764
+ content.replaceChildren();
765
+ root.querySelector('[part=editor]').hidden = !selected;
766
+ if (this.activePane === "Outline") {
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);
812
+ }
813
+ else if (this.activePane === "Checks") {
814
+ content.append(checks);
815
+ if (!this.allChecks.length) {
816
+ const message = document.createElement("p");
817
+ message.textContent = "No checks to resolve.";
818
+ content.prepend(message);
819
+ }
820
+ }
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);
874
+ content.append(row);
875
+ }
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);
881
+ }
882
+ else if (this.activePane === "Shortcuts") {
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.";
884
+ }
885
+ }
886
+ selectionCommand(command) {
887
+ if (this.locked)
888
+ return;
889
+ const boxes = this.selectedBoxes;
890
+ if (command === "delete-many") {
891
+ for (const box of boxes)
892
+ this.requestEdit({ type: "delete", boxId: box.id });
893
+ return;
894
+ }
895
+ this.arrangeSelection(command === 'align' ? 'top' : 'distribute-horizontal');
896
+ }
897
+ arrangeSelection(action) {
898
+ if (this.locked || this.selectedBoxes.length < 2)
899
+ return;
900
+ const boxes = this.selectedBoxes;
901
+ const next = this.layout;
902
+ const sorted = [...boxes].filter(box => {
903
+ let parent = box.parentId;
904
+ while (parent) {
905
+ if (this.selectedIds.has(parent))
906
+ return false;
907
+ parent = this.projection.boxes.find(box => box.id === parent)?.parentId;
908
+ }
909
+ return true;
910
+ });
911
+ if (sorted.length < 2)
912
+ return;
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
+ }
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 }))
940
+ this.commitLayout(next);
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
+ }
953
+ setupListeners() {
954
+ this.shadowRoot.addEventListener("click", (event) => {
373
955
  const command = event.target.closest("button")?.dataset.command;
374
956
  if (command === "zoom-in")
375
957
  this.zoomBy(1.2);
@@ -389,16 +971,19 @@ export class ProcessModeler extends BaseElement {
389
971
  this.snapToGrid = !this.snapToGrid;
390
972
  else if (command === "lock")
391
973
  this.locked = !this.locked;
392
- else if (command === "connect" && this.selected && !this.locked) {
974
+ else if (command === "connect" && this.selected && !this.locked && !this.disableConnections) {
393
975
  this.connecting = this.selected.id;
394
976
  this.setStatus(`Select the next box to connect from ${this.selected.title}`);
395
977
  }
396
978
  else if (command === "delete" && this.selected)
397
- this.requestEdit({ type: "delete", boxId: this.selected.id });
979
+ this.selectionCommand("delete-many");
398
980
  });
399
981
  this.shadowRoot.querySelector("[part=search]").addEventListener("input", () => this.renderPalette());
400
982
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
401
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);
402
987
  canvas.addEventListener("wheel", (event) => {
403
988
  event.preventDefault();
404
989
  if (event.ctrlKey || event.metaKey)
@@ -413,6 +998,11 @@ export class ProcessModeler extends BaseElement {
413
998
  canvas.addEventListener("pointermove", (event) => this.pointerMove(event));
414
999
  canvas.addEventListener("pointerup", (event) => this.pointerEnd(event, true));
415
1000
  canvas.addEventListener("pointercancel", (event) => this.pointerEnd(event, false));
1001
+ canvas.addEventListener("lostpointercapture", event => {
1002
+ // Touch transfers implicit box capture to the canvas during a drag.
1003
+ if (event.target === canvas)
1004
+ this.pointerEnd(event, false);
1005
+ });
416
1006
  canvas.addEventListener("gesturestart", (event) => {
417
1007
  event.preventDefault();
418
1008
  this.gestureZoom = this.viewport.zoom;
@@ -467,9 +1057,19 @@ export class ProcessModeler extends BaseElement {
467
1057
  });
468
1058
  }
469
1059
  onKey(event) {
470
- if (event.target.closest("input,textarea,select,[contenteditable]"))
1060
+ if (event.defaultPrevented)
471
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;
1068
+ return;
1069
+ }
472
1070
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
1071
+ if (this.locked || !(event.shiftKey ? this.history.canRedo : this.history.canUndo))
1072
+ return;
473
1073
  event.preventDefault();
474
1074
  if (event.shiftKey)
475
1075
  this.redo();
@@ -477,22 +1077,87 @@ export class ProcessModeler extends BaseElement {
477
1077
  this.undo();
478
1078
  return;
479
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
+ }
480
1103
  if (event.key === "Escape") {
481
1104
  this.connecting = null;
1105
+ this.pendingReattach = undefined;
1106
+ if (this.selectedLineId)
1107
+ this.selectLine(null);
1108
+ this.portDrag = undefined;
1109
+ this.marquee = undefined;
1110
+ this.segmentDrag = undefined;
1111
+ this.drag = undefined;
1112
+ this.pointers.clear();
1113
+ this.refresh();
482
1114
  this.setStatus("Connecting cancelled");
483
1115
  return;
484
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
+ }
485
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
+ }
486
1134
  if (!box || this.locked)
487
1135
  return;
488
- if (event.key.toLowerCase() === "c") {
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
+ }
1151
+ return;
1152
+ }
1153
+ if (event.key.toLowerCase() === "c" && !this.disableConnections) {
489
1154
  event.preventDefault();
490
1155
  this.connecting = box.id;
491
1156
  this.setStatus(`Select the next box to connect from ${box.title}`);
492
1157
  }
493
1158
  if (event.key === "Delete" || event.key === "Backspace") {
494
1159
  event.preventDefault();
495
- this.requestEdit({ type: "delete", boxId: box.id });
1160
+ this.selectionCommand("delete-many");
496
1161
  }
497
1162
  const direction = {
498
1163
  ArrowLeft: [-1, 0],
@@ -502,15 +1167,80 @@ export class ProcessModeler extends BaseElement {
502
1167
  }[event.key];
503
1168
  if (direction) {
504
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
+ }
505
1195
  const pos = this.layoutValue.boxes[box.id];
506
- const distance = event.shiftKey ? 100 : this.snapToGrid ? 20 : 10;
1196
+ const distance = event.shiftKey ? 64 : 16;
507
1197
  this.move(box.id, pos.x + direction[0] * distance, pos.y + direction[1] * distance);
508
1198
  this.focusBox(box.id);
509
1199
  }
510
1200
  }
511
1201
  pointerDown(event) {
1202
+ if (event.button !== 0)
1203
+ return;
1204
+ this.suppressClick = false;
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);
1223
+ return;
1224
+ }
1225
+ const control = event.target.closest("[part=port]");
1226
+ if (control && !this.locked && !this.disableConnections) {
1227
+ event.preventDefault();
1228
+ const point = this.canvasPoint(event);
1229
+ this.portDrag = { pointerId: event.pointerId, id: control.dataset.owner, side: control.dataset.side, start: point, point };
1230
+ this.shadowRoot.querySelector('[part=canvas]').setPointerCapture?.(event.pointerId);
1231
+ return;
1232
+ }
1233
+ const segment = event.target.closest("[data-segment]");
1234
+ if (segment && !this.locked) {
1235
+ const line = this.projection.lines.find(line => line.id === segment.dataset.lineId);
1236
+ this.selectLine(line.id);
1237
+ this.segmentDrag = { pointerId: event.pointerId, id: line.id, index: Number(segment.dataset.segment), points: routeProcessLine(line, this.layoutValue, this.projection) };
1238
+ this.shadowRoot.querySelector('[part=canvas]').setPointerCapture?.(event.pointerId);
1239
+ event.preventDefault();
1240
+ return;
1241
+ }
512
1242
  if (event.button !== 0 ||
513
- event.target.closest("[part=connection]"))
1243
+ event.target.closest("[part=connection],button,input,select,textarea,[contenteditable]"))
514
1244
  return;
515
1245
  this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
516
1246
  if (this.pointers.size === 2) {
@@ -522,9 +1252,17 @@ export class ProcessModeler extends BaseElement {
522
1252
  const target = event.target.closest("[data-box-id]");
523
1253
  const id = target?.dataset.boxId;
524
1254
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
525
- (target ?? canvas).setPointerCapture?.(event.pointerId);
1255
+ if (id && !this.spacePressed && !this.selectedIds.has(id) && !event.shiftKey)
1256
+ this.select(id);
1257
+ if (!id && event.shiftKey && !this.locked) {
1258
+ const point = this.canvasPoint(event);
1259
+ this.marquee = { pointerId: event.pointerId, start: point, point };
1260
+ canvas.setPointerCapture?.(event.pointerId);
1261
+ event.preventDefault();
1262
+ return;
1263
+ }
526
1264
  this.drag = {
527
- id: this.locked ? undefined : id,
1265
+ id: this.locked || this.spacePressed ? undefined : id,
528
1266
  x: event.clientX,
529
1267
  y: event.clientY,
530
1268
  position: id ? { ...this.layoutValue.boxes[id] } : undefined,
@@ -537,6 +1275,78 @@ export class ProcessModeler extends BaseElement {
537
1275
  return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
538
1276
  }
539
1277
  pointerMove(event) {
1278
+ const gesture = this.portDrag ?? this.endDrag ?? this.segmentDrag ?? this.marquee ?? this.frameResize;
1279
+ if (gesture && gesture.pointerId !== event.pointerId)
1280
+ return;
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
+ }
1291
+ if (this.portDrag) {
1292
+ this.portDrag.point = point;
1293
+ let ghost = this.shadowRoot.querySelector('[part=connection-preview]');
1294
+ if (!ghost) {
1295
+ ghost = svgElement("polyline");
1296
+ ghost.setAttribute("part", "connection-preview");
1297
+ ghost.style.cssText = "fill:none;stroke:var(--boe-token-surface-surface-brand,#0061d5);stroke-width:3";
1298
+ this.shadowRoot.querySelector('[part=lines]').append(ghost);
1299
+ }
1300
+ const start = this.portDrag.start;
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');
1327
+ return;
1328
+ }
1329
+ if (this.segmentDrag) {
1330
+ const { index, points } = this.segmentDrag;
1331
+ if (points[index].x === points[index + 1].x)
1332
+ points[index].x = points[index + 1].x = point.x;
1333
+ else
1334
+ points[index].y = points[index + 1].y = point.y;
1335
+ const path = Array.from(this.shadowRoot.querySelectorAll('[part=line]')).find(path => path.dataset.lineId === this.segmentDrag.id);
1336
+ path?.setAttribute("d", roundedProcessPath(points));
1337
+ return;
1338
+ }
1339
+ if (this.marquee) {
1340
+ this.marquee.point = point;
1341
+ let region = this.shadowRoot.querySelector('[part=marquee]');
1342
+ if (!region) {
1343
+ region = document.createElement("div");
1344
+ region.setAttribute("part", "marquee");
1345
+ this.shadowRoot.querySelector('[part=world]').append(region);
1346
+ }
1347
+ this.place(region, { x: Math.min(point.x, this.marquee.start.x), y: Math.min(point.y, this.marquee.start.y), width: Math.abs(point.x - this.marquee.start.x), height: Math.abs(point.y - this.marquee.start.y) });
1348
+ return;
1349
+ }
540
1350
  if (!this.pointers.has(event.pointerId))
541
1351
  return;
542
1352
  this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
@@ -551,11 +1361,16 @@ export class ProcessModeler extends BaseElement {
551
1361
  return;
552
1362
  const dx = event.clientX - this.drag.x;
553
1363
  const dy = event.clientY - this.drag.y;
1364
+ if (Math.hypot(dx, dy) <= 4)
1365
+ return;
1366
+ this.shadowRoot.querySelector('[part=canvas]').setPointerCapture?.(event.pointerId);
554
1367
  if (this.drag.id && this.drag.position) {
555
1368
  const element = Array.from(this.shadowRoot.querySelectorAll("[data-box-id]")).find((box) => box.dataset.boxId === this.drag.id);
556
1369
  if (element) {
557
- element.style.left = `${this.drag.position.x + dx / this.viewport.zoom}px`;
558
- element.style.top = `${this.drag.position.y + dy / this.viewport.zoom}px`;
1370
+ const position = this.alignedPoint(this.drag.id, this.drag.position.x + dx / this.viewport.zoom, this.drag.position.y + dy / this.viewport.zoom);
1371
+ element.style.left = `${position.x}px`;
1372
+ element.style.top = `${position.y}px`;
1373
+ this.markDropLine(point, this.drag.id);
559
1374
  }
560
1375
  }
561
1376
  else {
@@ -565,6 +1380,82 @@ export class ProcessModeler extends BaseElement {
565
1380
  }
566
1381
  }
567
1382
  pointerEnd(event, commit) {
1383
+ const gesture = this.portDrag ?? this.endDrag ?? this.segmentDrag ?? this.marquee ?? this.frameResize;
1384
+ if (gesture && gesture.pointerId !== event.pointerId)
1385
+ return;
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
+ }
1396
+ if (this.portDrag) {
1397
+ const port = this.portDrag;
1398
+ this.portDrag = undefined;
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; });
1403
+ if (commit) {
1404
+ if (Math.hypot(point.x - port.start.x, point.y - port.start.y) < 8) {
1405
+ const pos = this.layoutValue.boxes[port.id];
1406
+ const offsets = { east: [320, 0], west: [-320, 0], north: [0, -170], south: [0, 170] }[port.side];
1407
+ this.openChooser({ type: "add", from: port.id, fromSide: port.side, position: { x: pos.x + offsets[0], y: pos.y + offsets[1] } });
1408
+ }
1409
+ else {
1410
+ const target = this.boxAt(point, port.id);
1411
+ if (target)
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.');
1435
+ }
1436
+ }
1437
+ return;
1438
+ }
1439
+ if (this.segmentDrag) {
1440
+ const segment = this.segmentDrag;
1441
+ this.segmentDrag = undefined;
1442
+ if (commit)
1443
+ this.routeLine(segment.id, segment.points.slice(1, -1));
1444
+ else
1445
+ this.refresh();
1446
+ return;
1447
+ }
1448
+ if (this.marquee) {
1449
+ const { start } = this.marquee;
1450
+ this.marquee = undefined;
1451
+ if (commit)
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));
1453
+ this.shadowRoot.querySelector('[part=marquee]')?.remove();
1454
+ this.pointers.delete(event.pointerId);
1455
+ return;
1456
+ }
1457
+ if (!this.pointers.has(event.pointerId))
1458
+ return;
568
1459
  this.pointers.delete(event.pointerId);
569
1460
  const drag = this.drag;
570
1461
  this.drag = undefined;
@@ -572,22 +1463,191 @@ export class ProcessModeler extends BaseElement {
572
1463
  if (drag?.id && drag.position) {
573
1464
  const dx = event.clientX - drag.x;
574
1465
  const dy = event.clientY - drag.y;
575
- if (commit && Math.hypot(dx, dy) > 4)
576
- this.move(drag.id, drag.position.x + dx / this.viewport.zoom, drag.position.y + dy / this.viewport.zoom);
1466
+ if (commit && Math.hypot(dx, dy) > 4) {
1467
+ this.suppressClick = true;
1468
+ const position = this.alignedPoint(drag.id, drag.position.x + dx / this.viewport.zoom, drag.position.y + dy / this.viewport.zoom);
1469
+ const line = this.lineAt(point, drag.id);
1470
+ const frame = this.boxAt(point, drag.id, true);
1471
+ if (line)
1472
+ this.requestEdit({ type: "insert", boxId: drag.id, lineId: line.id, from: line.from, to: line.to, position });
1473
+ else if (this.projection.boxes.find(box => box.id === drag.id)?.parentId !== frame?.id)
1474
+ this.requestEdit({ type: "reparent", boxId: drag.id, parentId: frame?.id, position });
1475
+ else if (this.selectedIds.size > 1 && this.selectedIds.has(drag.id)) {
1476
+ const next = this.layout;
1477
+ const dx = position.x - drag.position.x;
1478
+ const dy = position.y - drag.position.y;
1479
+ const moved = new Set(this.selectedIds);
1480
+ for (let changed = true; changed;) {
1481
+ changed = false;
1482
+ for (const box of this.projection.boxes)
1483
+ if (box.parentId && moved.has(box.parentId) && !moved.has(box.id)) {
1484
+ moved.add(box.id);
1485
+ changed = true;
1486
+ }
1487
+ }
1488
+ const primary = next.boxes[drag.id];
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;
1491
+ for (const id of moved)
1492
+ next.boxes[id] = { ...next.boxes[id], x: next.boxes[id].x + shiftX, y: next.boxes[id].y + shiftY };
1493
+ if (emit(this, "move-request", { boxId: drag.id, position: next.boxes[drag.id], boxIds: [...moved], positions: next.boxes }))
1494
+ this.commitLayout(next);
1495
+ }
1496
+ else
1497
+ this.move(drag.id, position.x, position.y);
1498
+ }
577
1499
  else if (!commit)
578
1500
  this.refresh();
579
1501
  }
1502
+ this.shadowRoot.querySelectorAll('[part=guide],[part=measure]').forEach(element => element.remove());
1503
+ this.markDropLine(undefined);
1504
+ if (drag?.id && (!commit || Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4))
1505
+ this.refresh();
1506
+ }
1507
+ canvasPoint(event) {
1508
+ const rect = this.shadowRoot.querySelector('[part=canvas]').getBoundingClientRect();
1509
+ return { x: (event.clientX - rect.left - this.viewport.x) / this.viewport.zoom, y: (event.clientY - rect.top - this.viewport.y) / this.viewport.zoom };
1510
+ }
1511
+ boxAt(point, except, frameOnly = false) {
1512
+ return [...this.projection.boxes].reverse().find(box => {
1513
+ if (box.id === except || (frameOnly && !box.frame))
1514
+ return false;
1515
+ // A frame cannot be dropped inside its own descendants.
1516
+ let parent = box.parentId;
1517
+ while (parent) {
1518
+ if (parent === except)
1519
+ return false;
1520
+ parent = this.projection.boxes.find(b => b.id === parent)?.parentId;
1521
+ }
1522
+ const p = this.layoutValue.boxes[box.id];
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;
1524
+ });
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
+ }
1533
+ lineAt(point, except) {
1534
+ return this.projection.lines.find(line => line.from !== except && line.to !== except && nearProcessLine(point, this.routedLines.get(line.id) ?? [], 16 / this.viewport.zoom));
1535
+ }
1536
+ markDropLine(point, except) {
1537
+ this.dropLine = point ? this.lineAt(point, except)?.id : undefined;
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'})`); });
1539
+ }
1540
+ alignedPoint(id, x, y) {
1541
+ const world = this.shadowRoot.querySelector('[part=world]');
1542
+ world.querySelectorAll('[part=guide],[part=measure]').forEach(element => element.remove());
1543
+ for (const box of this.projection.boxes) {
1544
+ if (box.id === id)
1545
+ continue;
1546
+ const p = this.layoutValue.boxes[box.id];
1547
+ for (const axis of ["x", "y"]) {
1548
+ const value = axis === "x" ? x : y;
1549
+ if (Math.abs(value - p[axis]) < 8 / this.viewport.zoom) {
1550
+ if (axis === "x")
1551
+ x = p.x;
1552
+ else
1553
+ y = p.y;
1554
+ const guide = document.createElement("div");
1555
+ guide.setAttribute("part", "guide");
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`;
1557
+ world.append(guide);
1558
+ }
1559
+ }
1560
+ if (Math.abs(y - p.y) < 8) {
1561
+ const distance = x - p.x - (p.width ?? 224);
1562
+ if (distance > 0) {
1563
+ const measure = document.createElement("span");
1564
+ measure.setAttribute("part", "measure");
1565
+ measure.textContent = `${Math.round(distance)}px`;
1566
+ measure.style.cssText = `left:${p.x + (p.width ?? 224)}px;top:${y + 40}px`;
1567
+ world.append(measure);
1568
+ }
1569
+ }
1570
+ }
1571
+ return { x, y };
1572
+ }
1573
+ openChooser(edit) {
1574
+ this.insertion = edit;
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 });
1585
+ const picker = chooser.querySelector('box-kind-picker');
1586
+ picker.catalog = this.catalog.filter(kind => !kind.placement);
1587
+ picker.refresh();
1588
+ promoteModal(chooser);
1589
+ picker.focus();
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`;
580
1612
  }
581
1613
  update() {
1614
+ const selectionBefore = JSON.stringify({ ids: [...this.selectedIds], path: this.selectedPath });
582
1615
  if (this.documentValue !== undefined) {
583
1616
  this.projection = this.model.project(this.documentValue);
584
1617
  validateProjection(this.projection);
585
1618
  }
586
1619
  else
587
1620
  this.projection = { boxes: [], lines: [] };
588
- this.layoutValue = completeLayout(this.projection, this.layoutValue);
1621
+ this.layoutValue = this.arrangedLayout(this.layoutValue);
1622
+ this.routedLines = new Map(this.projection.lines.map(line => [line.id, routeProcessLine(line, this.layoutValue, this.projection)]));
1623
+ this.computedChecks = this.collectChecks();
589
1624
  if (this.selectedId && !this.selected)
590
1625
  this.selectedId = null;
1626
+ if (this.selectedLineId && !this.selectedLine)
1627
+ this.selectedLineId = null;
1628
+ this.selectedIds = new Set([...this.selectedIds].filter(id => this.projection.boxes.some(box => box.id === id)));
1629
+ if (!this.selectedId)
1630
+ this.selectedId = this.selectedIds.values().next().value ?? null;
1631
+ const serialized = JSON.stringify({
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 })),
1633
+ lines: this.projection.lines,
1634
+ });
1635
+ if (serialized !== this.lastProjection || this.documentValue !== this.lastDocument) {
1636
+ if (this.lastProjection)
1637
+ this.versionValue = typeof this.versionValue === "number" ? this.versionValue + 1 : `${this.versionValue}+1`;
1638
+ this.lastProjection = serialized;
1639
+ this.lastDocument = this.documentValue;
1640
+ emit(this, "projection-changed", { projection: this.projection, version: this.versionValue, checks: this.allChecks });
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
+ }
591
1651
  const focused = this.shadowRoot.activeElement
592
1652
  ?.dataset.boxId;
593
1653
  this.renderWorld();
@@ -596,46 +1656,118 @@ export class ProcessModeler extends BaseElement {
596
1656
  this.renderSelection();
597
1657
  this.paintViewport();
598
1658
  this.updateToolbar();
1659
+ if (selectionBefore !== JSON.stringify({ ids: [...this.selectedIds], path: this.selectedPath }))
1660
+ emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
599
1661
  if (focused)
600
1662
  this.focusBox(focused);
601
1663
  }
1664
+ get allChecks() { return this.computedChecks; }
1665
+ collectChecks() {
1666
+ const routes = this.projection.lines.filter(line => !this.routedLines.get(line.id)?.length)
1667
+ .map(line => ({ message: "A connection is blocked by overlapping steps. Move a step or adjust its route.", boxId: line.from }));
1668
+ const hostChecks = this.documentValue === undefined ? undefined : this.model.validate?.(this.documentValue, this.projection);
1669
+ return [...this.checks, ...(hostChecks ?? graphChecks(this.projection)), ...routes];
1670
+ }
602
1671
  renderWorld() {
603
1672
  const world = this.shadowRoot.querySelector("[part=world]");
604
1673
  world.replaceChildren();
605
1674
  for (const section of this.layoutValue.sections ?? []) {
606
1675
  const element = document.createElement("div");
607
1676
  element.setAttribute("part", "section");
608
- 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
+ }
609
1685
  this.place(element, section);
610
1686
  world.append(element);
611
1687
  }
612
1688
  for (const box of [...this.projection.boxes].sort((a, b) => Number(Boolean(b.frame)) - Number(Boolean(a.frame)))) {
613
- const element = document.createElement("button");
614
- element.type = "button";
1689
+ const element = document.createElement("div");
1690
+ element.tabIndex = 0;
1691
+ element.setAttribute("role", "group");
615
1692
  element.setAttribute("part", box.frame ? "frame" : "box");
616
1693
  element.dataset.boxId = box.id;
617
- 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}` : ""]
618
1703
  .filter(Boolean)
619
1704
  .join(". "));
620
- element.setAttribute("aria-pressed", String(box.id === this.selectedId));
1705
+ element.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
621
1706
  this.place(element, this.layoutValue.boxes[box.id]);
622
- 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");
623
1710
  if (kind?.icon) {
624
- const icon = document.createElement("span");
625
- icon.setAttribute("part", "icon");
626
- icon.setAttribute("aria-hidden", "true");
627
1711
  icon.append(kind.icon());
628
- element.append(icon);
629
1712
  }
1713
+ element.append(icon);
630
1714
  const title = document.createElement("strong");
631
1715
  title.textContent = box.title;
632
1716
  element.append(title);
633
- if (box.description) {
1717
+ const descriptionText = this.detailValue === "technical" ? box.technicalDescription ?? box.description : box.description;
1718
+ if (descriptionText) {
634
1719
  const description = document.createElement("small");
635
- description.textContent = box.description;
1720
+ description.textContent = descriptionText;
1721
+ if (this.detailValue === "technical")
1722
+ description.setAttribute("part", "technical-description");
636
1723
  element.append(description);
637
1724
  }
638
- element.addEventListener("click", () => {
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
+ }
1754
+ element.addEventListener("keydown", event => {
1755
+ if (event.target === element && (event.key === "Enter" || event.key === " ")) {
1756
+ event.preventDefault();
1757
+ element.click();
1758
+ }
1759
+ });
1760
+ element.addEventListener("click", event => {
1761
+ if (this.suppressClick && event.detail !== 0) {
1762
+ this.suppressClick = false;
1763
+ return;
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
+ }
639
1771
  if (this.connecting && this.connecting !== box.id) {
640
1772
  this.requestEdit({
641
1773
  type: "connect",
@@ -644,65 +1776,204 @@ export class ProcessModeler extends BaseElement {
644
1776
  });
645
1777
  this.connecting = null;
646
1778
  }
647
- this.select(box.id);
1779
+ if (event.shiftKey)
1780
+ this.selectMany(this.selectedIds.has(box.id) ? [...this.selectedIds].filter(id => id !== box.id) : [...this.selectedIds, box.id]);
1781
+ else
1782
+ this.select(box.id);
648
1783
  });
1784
+ if (!this.disableConnections && !this.locked) {
1785
+ for (const side of ["north", "east", "south", "west"]) {
1786
+ const port = document.createElement("button");
1787
+ port.type = "button";
1788
+ port.setAttribute("part", "port");
1789
+ port.dataset.side = side;
1790
+ port.dataset.owner = box.id;
1791
+ port.setAttribute("aria-label", `Add or connect a step ${side} of ${box.title}`);
1792
+ port.addEventListener("click", event => {
1793
+ event.stopPropagation();
1794
+ if (!event.detail) {
1795
+ const pos = this.layoutValue.boxes[box.id];
1796
+ const offsets = { east: [320, 0], west: [-320, 0], north: [0, -170], south: [0, 170] }[side];
1797
+ this.openChooser({ type: "add", from: box.id, fromSide: side, position: { x: pos.x + offsets[0], y: pos.y + offsets[1] } });
1798
+ }
1799
+ });
1800
+ element.append(port);
1801
+ }
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
+ }
649
1821
  world.append(element);
650
1822
  }
651
1823
  const lines = svgElement("svg");
652
1824
  lines.setAttribute("part", "lines");
653
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);
654
1843
  world.append(lines);
655
1844
  for (const line of this.projection.lines) {
656
- const from = this.layoutValue.boxes[line.from];
657
- const to = this.layoutValue.boxes[line.to];
658
- const x1 = from.x + (from.width ?? 220) / 2;
659
- const y1 = from.y + (from.height ?? 96);
660
- const x2 = to.x + (to.width ?? 220) / 2;
661
- const y2 = to.y;
662
- const path = svgElement("polyline");
1845
+ const points = this.routedLines.get(line.id);
1846
+ if (!points.length)
1847
+ continue;
1848
+ const midpoint = lineMidpoint(points);
1849
+ const path = svgElement("path");
663
1850
  path.setAttribute("part", "line");
664
- path.setAttribute("points", [
665
- { x: x1, y: y1 },
666
- ...(this.layoutValue.lines?.[line.id] ?? line.points ?? []),
667
- { x: x2, y: y2 },
668
- ]
669
- .map((point) => `${point.x},${point.y}`)
670
- .join(" "));
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'})`);
1854
+ path.setAttribute("vector-effect", "non-scaling-stroke");
1855
+ path.setAttribute("d", roundedProcessPath(points));
671
1856
  if (line.dashed)
672
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
+ }
673
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
+ }
1873
+ for (let i = 1; i < points.length - 2; i++) {
1874
+ const hit = svgElement("polyline");
1875
+ hit.setAttribute("part", "line-hit");
1876
+ hit.dataset.lineId = line.id;
1877
+ hit.dataset.segment = String(i);
1878
+ hit.setAttribute("vector-effect", "non-scaling-stroke");
1879
+ hit.setAttribute("points", `${points[i].x},${points[i].y} ${points[i + 1].x},${points[i + 1].y}`);
1880
+ lines.append(hit);
1881
+ }
674
1882
  const label = document.createElement("div");
675
1883
  label.setAttribute("part", "connection");
676
- label.style.left = `${(x1 + x2) / 2}px`;
677
- label.style.top = `${(y1 + y2) / 2}px`;
1884
+ label.setAttribute('role', 'group');
1885
+ label.tabIndex = 0;
1886
+ label.style.left = `${midpoint.x}px`;
1887
+ label.style.top = `${midpoint.y}px`;
678
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
+ } });
679
1896
  const text = document.createElement("span");
680
- text.textContent = line.label ?? "Connection";
1897
+ const source = this.projection.boxes.find(box => box.id === line.from);
1898
+ const branchIndex = this.projection.lines.filter(candidate => candidate.from === line.from).indexOf(line);
1899
+ text.textContent = line.label ?? (line.weight !== undefined ? String(line.weight) : /decision/i.test(source.kind) ? ["Yes", "No"][branchIndex] ?? `Route ${branchIndex + 1}` : /try/i.test(source.kind) && line.dashed ? "If it fails" : "");
681
1900
  label.append(text);
1901
+ const actions = document.createElement("div");
1902
+ actions.setAttribute("part", "connection-actions");
1903
+ label.append(actions);
1904
+ if (points.length >= 4 && !this.locked) {
1905
+ const adjust = document.createElement("button");
1906
+ adjust.type = "button";
1907
+ adjust.textContent = "Route";
1908
+ adjust.dataset.segment = "1";
1909
+ adjust.dataset.lineId = line.id;
1910
+ adjust.setAttribute("aria-label", `Adjust route: ${names}. Use arrow keys`);
1911
+ adjust.onkeydown = event => {
1912
+ const direction = { ArrowLeft: [-10, 0], ArrowRight: [10, 0], ArrowUp: [0, -10], ArrowDown: [0, 10] }[event.key];
1913
+ if (!direction)
1914
+ return;
1915
+ event.preventDefault();
1916
+ event.stopPropagation();
1917
+ const current = routeProcessLine(line, this.layoutValue, this.projection);
1918
+ const vertical = current[1].x === current[2].x;
1919
+ for (const point of [current[1], current[2]]) {
1920
+ if (vertical)
1921
+ point.x += direction[0];
1922
+ else
1923
+ point.y += direction[1];
1924
+ }
1925
+ this.routeLine(line.id, current.slice(1, -1));
1926
+ Array.from(this.shadowRoot.querySelectorAll('[data-segment]')).find(button => button.dataset.lineId === line.id)?.focus();
1927
+ };
1928
+ actions.append(adjust);
1929
+ }
682
1930
  for (const [type, title] of [
683
1931
  ["insert", "Insert a step here"],
684
1932
  ["disconnect", "Remove connection"],
685
1933
  ]) {
1934
+ if (type === "disconnect" && this.disableConnections)
1935
+ continue;
686
1936
  const button = document.createElement("button");
687
1937
  button.type = "button";
688
1938
  button.textContent = type === "insert" ? "+" : "Remove";
689
1939
  button.setAttribute("aria-label", `${title}: ${names}`);
690
1940
  button.disabled = this.locked;
691
- button.addEventListener("click", () => this.requestEdit({
692
- type,
693
- lineId: line.id,
694
- from: line.from,
695
- to: line.to,
696
- }));
697
- label.append(button);
1941
+ button.addEventListener("click", () => {
1942
+ const edit = {
1943
+ type,
1944
+ lineId: line.id,
1945
+ from: line.from,
1946
+ to: line.to,
1947
+ };
1948
+ if (type === "insert" && this.catalog.length) {
1949
+ this.openChooser(edit);
1950
+ }
1951
+ else
1952
+ this.requestEdit(edit);
1953
+ });
1954
+ actions.append(button);
698
1955
  }
699
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
+ }
700
1971
  }
701
1972
  for (const note of this.layoutValue.notes ?? []) {
702
1973
  const element = document.createElement("div");
703
1974
  element.setAttribute("part", "note");
704
1975
  element.textContent = note.text;
705
- this.place(element, { ...note, width: 180, height: 80 });
1976
+ this.place(element, { ...note, width: 208, height: 80 });
706
1977
  world.append(element);
707
1978
  }
708
1979
  }
@@ -710,8 +1981,8 @@ export class ProcessModeler extends BaseElement {
710
1981
  Object.assign(element.style, {
711
1982
  left: `${position.x}px`,
712
1983
  top: `${position.y}px`,
713
- width: `${position.width ?? 220}px`,
714
- height: `${position.height ?? 96}px`,
1984
+ width: `${position.width ?? 224}px`,
1985
+ height: `${position.height ?? 64}px`,
715
1986
  });
716
1987
  }
717
1988
  renderPalette() {
@@ -719,7 +1990,7 @@ export class ProcessModeler extends BaseElement {
719
1990
  const query = this.shadowRoot.querySelector("[part=search]").value.toLowerCase();
720
1991
  choices.replaceChildren();
721
1992
  let group = "";
722
- 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(" ")}`
723
1994
  .toLowerCase()
724
1995
  .includes(query))) {
725
1996
  if (kind.group && kind.group !== group) {
@@ -731,39 +2002,87 @@ export class ProcessModeler extends BaseElement {
731
2002
  }
732
2003
  const button = document.createElement("button");
733
2004
  button.type = "button";
734
- 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);
735
2019
  button.disabled = this.locked;
736
2020
  button.draggable = !this.locked;
737
- button.addEventListener("click", () => this.requestEdit({ type: "add", kind, position: { x: 40, y: 40 } }));
738
- 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); });
739
2033
  choices.append(button);
740
2034
  }
741
2035
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
742
2036
  canvas.ondragover = (event) => {
743
- if (!this.locked)
2037
+ if (!this.locked) {
744
2038
  event.preventDefault();
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
+ }
2051
+ }
745
2052
  };
2053
+ canvas.ondragleave = () => { this.markDropLine(undefined); this.shadowRoot.querySelector('[part=palette-ghost]')?.remove(); };
746
2054
  canvas.ondrop = (event) => {
747
2055
  event.preventDefault();
2056
+ this.shadowRoot.querySelector('[part=palette-ghost]')?.remove();
2057
+ this.paletteDragKind = null;
748
2058
  const kind = this.catalog.find((kind) => kind.kind ===
749
2059
  event.dataTransfer?.getData("application/boe-process-kind"));
750
- if (!kind)
2060
+ if (!kind || this.locked)
2061
+ return;
2062
+ const position = this.canvasPoint(event);
2063
+ if (kind.placement) {
2064
+ this.addLayoutKind(kind, position);
2065
+ this.markDropLine(undefined);
751
2066
  return;
752
- const rect = canvas.getBoundingClientRect();
2067
+ }
2068
+ const line = this.lineAt(position);
2069
+ const frame = this.boxAt(position, undefined, true);
753
2070
  this.requestEdit({
754
- type: "add",
2071
+ type: line ? "insert" : "add",
755
2072
  kind,
756
- position: {
757
- x: (event.clientX - rect.left - this.viewport.x) / this.viewport.zoom,
758
- y: (event.clientY - rect.top - this.viewport.y) / this.viewport.zoom,
759
- },
2073
+ lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id,
2074
+ position,
760
2075
  });
2076
+ this.markDropLine(undefined);
761
2077
  };
762
2078
  }
763
2079
  renderChecks() {
764
2080
  const checks = this.shadowRoot.querySelector("[part=checks]");
765
2081
  checks.replaceChildren();
766
- this.checks.forEach((check) => {
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.`;
2085
+ this.allChecks.forEach((check) => {
767
2086
  const button = document.createElement("button");
768
2087
  button.type = "button";
769
2088
  button.textContent = check.message;
@@ -780,22 +2099,252 @@ export class ProcessModeler extends BaseElement {
780
2099
  checks.append(button);
781
2100
  });
782
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
+ }
783
2309
  renderSelection() {
784
2310
  if (!this.isRendered)
785
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'})`); });
786
2327
  this.shadowRoot.querySelectorAll("[data-box-id]").forEach((element) => {
787
- element.setAttribute("aria-pressed", String(element.dataset.boxId === this.selectedId));
2328
+ element.setAttribute("aria-current", String(this.selectedIds.has(element.dataset.boxId)));
788
2329
  const box = this.projection.boxes.find((box) => box.id === element.dataset.boxId);
789
- element.dataset.invalid = String(this.checks.some((check) => check.boxId === box.id ||
2330
+ element.dataset.invalid = String(this.allChecks.some((check) => check.boxId === box.id ||
790
2331
  (check.path &&
791
2332
  JSON.stringify(check.path) === JSON.stringify(box.path))));
792
2333
  });
793
2334
  const editor = this.shadowRoot.querySelector("[part=editor]");
794
2335
  const selected = this.selected;
2336
+ const controlsKey = `${[...this.selectedIds].join(',')}|${this.locked}|${this.disableConnections}|${this.showLastRunValue}|${JSON.stringify(selected ? this.fieldsValue[selected.id] ?? [] : [])}`;
2337
+ this.shadowRoot.querySelector("[part=palette]").hidden = false;
2338
+ this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selectedIds.size === 0 && !this.selectedLineId;
2339
+ this.renderSelectionToolbar();
2340
+ this.renderPane();
2341
+ this.positionSelectionToolbar();
795
2342
  if (this.inspected === selected?.node &&
796
2343
  this.inspectedId === selected?.id &&
797
- editor.childNodes.length) {
798
- editor.querySelector("h3").textContent = selected?.title ?? "";
2344
+ this.inspectedControls === controlsKey &&
2345
+ editor.querySelector(`[part=inspector-heading]`)?.tagName === `H${this.headingLevel}`) {
2346
+ editor.querySelector("[part=inspector-heading]").textContent = selected?.title ?? "";
2347
+ restoreControlFocus();
799
2348
  return;
800
2349
  }
801
2350
  this.cleanupInspector?.();
@@ -803,12 +2352,170 @@ export class ProcessModeler extends BaseElement {
803
2352
  editor.replaceChildren();
804
2353
  this.inspected = selected?.node;
805
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
+ }
806
2381
  if (selected) {
807
- const heading = document.createElement("h3");
2382
+ const heading = document.createElement(`h${this.headingLevel}`);
2383
+ heading.setAttribute("part", "inspector-heading");
2384
+ heading.dataset.single = 'true';
808
2385
  heading.textContent = selected.title;
809
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));
810
2400
  this.cleanupInspector =
811
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'));
812
2519
  }
813
2520
  }
814
2521
  updateToolbar() {
@@ -816,14 +2523,23 @@ export class ProcessModeler extends BaseElement {
816
2523
  button("undo").disabled = !this.history.canUndo || this.locked;
817
2524
  button("redo").disabled = !this.history.canRedo || this.locked;
818
2525
  button("tidy").disabled = this.locked;
819
- button("connect").disabled = !this.selected || this.locked;
820
- button("delete").disabled = !this.selected || this.locked;
821
2526
  button("lock").setAttribute("aria-pressed", String(this.locked));
822
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));
823
2537
  }
824
2538
  paintViewport() {
825
2539
  this.shadowRoot.querySelector("[part=world]").style.transform =
826
2540
  `translate(${this.viewport.x}px,${this.viewport.y}px) scale(${this.viewport.zoom})`;
2541
+ this.shadowRoot.querySelectorAll('[part=port]').forEach(port => port.style.transform = `scale(${1 / this.viewport.zoom})`);
2542
+ this.positionSelectionToolbar();
827
2543
  this.shadowRoot.querySelector("[data-command=reset]").textContent =
828
2544
  `${Math.round(this.viewport.zoom * 100)}%`;
829
2545
  const minimap = this.shadowRoot.querySelector("[part=minimap]");
@@ -836,8 +2552,8 @@ export class ProcessModeler extends BaseElement {
836
2552
  const rect = svgElement("rect");
837
2553
  rect.setAttribute("x", String(pos.x));
838
2554
  rect.setAttribute("y", String(pos.y));
839
- rect.setAttribute("width", String(pos.width ?? 220));
840
- rect.setAttribute("height", String(pos.height ?? 96));
2555
+ rect.setAttribute("width", String(pos.width ?? 224));
2556
+ rect.setAttribute("height", String(pos.height ?? 64));
841
2557
  minimap.append(rect);
842
2558
  });
843
2559
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
@@ -849,6 +2565,26 @@ export class ProcessModeler extends BaseElement {
849
2565
  viewport.setAttribute("height", String(canvas.clientHeight / this.viewport.zoom));
850
2566
  minimap.append(viewport);
851
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
+ }
852
2588
  focusBox(id) {
853
2589
  Array.from(this.shadowRoot.querySelectorAll("[data-box-id]"))
854
2590
  .find((box) => box.dataset.boxId === id)