@unofficialbox/box-open-elements 0.24.0 → 0.25.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 (36) 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.js +1 -1
  27. package/dist/patterns/process-modeler/bridge.d.ts +10 -0
  28. package/dist/patterns/process-modeler/bridge.js +173 -0
  29. package/dist/patterns/process-modeler/geometry.d.ts +18 -0
  30. package/dist/patterns/process-modeler/geometry.js +307 -0
  31. package/dist/patterns/process-modeler/index.d.ts +2 -0
  32. package/dist/patterns/process-modeler/index.js +2 -0
  33. package/dist/patterns/process-modeler/model.d.ts +41 -1
  34. package/dist/patterns/process-modeler/process-modeler.d.ts +48 -0
  35. package/dist/patterns/process-modeler/process-modeler.js +709 -63
  36. 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
3
  import { boeFocusVisibleStyles } from "../../foundations/tokens/interaction.js";
4
+ import { dismissModal, promoteModal } from "../../foundations/overlay/index.js";
5
+ import { KindPicker } from "../flow-builder/primitives.js";
6
+ import { arrangeProcess, routeProcessLine, lineMidpoint, nearProcessLine } from "./geometry.js";
7
+ import { restoreProcessPositions, snapshotProcessPositions, graphChecks, normalizeProcessSelectionPath } from "./bridge.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"];
17
21
  }
18
22
  documentValue;
19
23
  modelValue = {
@@ -35,6 +39,78 @@ export class ProcessModeler extends BaseElement {
35
39
  drag;
36
40
  pinchDistance = 0;
37
41
  gestureZoom = 1;
42
+ insertion;
43
+ selectedIds = new Set();
44
+ versionValue = 0;
45
+ lastProjection = "";
46
+ lastDocument;
47
+ connectionsValue = [];
48
+ variablesValue = [];
49
+ activePane = "Outline";
50
+ portDrag;
51
+ marquee;
52
+ segmentDrag;
53
+ dropLine;
54
+ narrowValue = false;
55
+ suppressClick = false;
56
+ resizeObserver;
57
+ routedLines = new Map();
58
+ computedChecks = [];
59
+ get selectedBoxes() { return this.projection.boxes.filter(box => this.selectedIds.has(box.id)); }
60
+ get selectedPath() { return this.selected?.path ? [...this.selected.path] : null; }
61
+ set selectedPath(path) {
62
+ if (!this.isRendered && this.documentValue !== undefined) {
63
+ this.projection = this.model.project(this.documentValue);
64
+ validateProjection(this.projection);
65
+ }
66
+ const normalized = normalizeProcessSelectionPath(this.projection, path);
67
+ this.select(normalized === null ? null : this.projection.boxes.find(box => JSON.stringify(box.path) === JSON.stringify(normalized))?.id ?? null);
68
+ }
69
+ get connections() { return this.connectionsValue; }
70
+ set connections(value) { this.connectionsValue = value; this.refresh(); }
71
+ get variables() { return this.variablesValue; }
72
+ set variables(value) { this.variablesValue = value; this.refresh(); }
73
+ get version() { return this.versionValue; }
74
+ get narrow() { return this.narrowValue; }
75
+ get positions() { return snapshotProcessPositions(this.projection, this.layoutValue); }
76
+ load(document, options = {}) {
77
+ const projection = this.model.project(document);
78
+ validateProjection(projection);
79
+ this.documentValue = document;
80
+ this.projection = projection;
81
+ this.versionValue = options.version ?? 0;
82
+ this.lastProjection = "";
83
+ this.layoutValue = restoreProcessPositions(projection, options.positions ?? []);
84
+ this.history.clear();
85
+ this.selectedId = null;
86
+ this.selectedIds.clear();
87
+ this.refresh();
88
+ if (options.selectedPath)
89
+ this.selectedPath = options.selectedPath;
90
+ }
91
+ selectMany(ids) {
92
+ if (!this.isRendered && this.documentValue !== undefined) {
93
+ this.projection = this.model.project(this.documentValue);
94
+ validateProjection(this.projection);
95
+ }
96
+ const next = new Set(ids.filter(id => this.projection.boxes.some(box => box.id === id)));
97
+ if (next.size === this.selectedIds.size && [...next].every(id => this.selectedIds.has(id)))
98
+ return;
99
+ this.selectedIds = next;
100
+ this.selectedId = this.selectedIds.values().next().value ?? null;
101
+ this.renderSelection();
102
+ if (this.isRendered)
103
+ this.updateToolbar();
104
+ emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
105
+ }
106
+ get headingLevel() { const level = Number(this.getAttribute("heading-level") ?? 2); return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2; }
107
+ set headingLevel(value) { this.setAttribute("heading-level", String(value)); }
108
+ get disableConnections() { return this.hasAttribute("disable-connections"); }
109
+ set disableConnections(value) { this.toggleAttribute("disable-connections", value); }
110
+ arrangedLayout(layout) {
111
+ const arranged = this.model.arrange?.(this.projection) ?? arrangeProcess(this.projection);
112
+ return completeLayout(this.projection, { ...layout, boxes: { ...arranged?.boxes, ...layout.boxes } });
113
+ }
38
114
  get document() {
39
115
  return this.documentValue;
40
116
  }
@@ -100,12 +176,21 @@ export class ProcessModeler extends BaseElement {
100
176
  this.refresh();
101
177
  }
102
178
  disconnectedCallback() {
179
+ this.resizeObserver?.disconnect();
180
+ this.shadowRoot?.querySelectorAll("[part=pane-drawer]").forEach(dialog => dismissModal(dialog));
181
+ const chooser = this.shadowRoot?.querySelector("[part=insert-chooser]");
182
+ if (chooser)
183
+ dismissModal(chooser);
103
184
  this.cleanupInspector?.();
104
185
  this.cleanupInspector = undefined;
105
186
  this.inspected = undefined;
106
187
  this.pointers.clear();
107
188
  this.drag = undefined;
189
+ this.portDrag = undefined;
190
+ this.marquee = undefined;
191
+ this.segmentDrag = undefined;
108
192
  }
193
+ connectedCallback() { super.connectedCallback(); this.resizeObserver?.observe(this); }
109
194
  refresh() {
110
195
  if (this.isRendered)
111
196
  this.update();
@@ -117,11 +202,14 @@ export class ProcessModeler extends BaseElement {
117
202
  }
118
203
  if (id !== null && !this.projection.boxes.some((box) => box.id === id))
119
204
  return;
205
+ if (id === this.selectedId && this.selectedIds.size === (id ? 1 : 0))
206
+ return;
120
207
  this.selectedId = id;
208
+ this.selectedIds = new Set(id ? [id] : []);
121
209
  this.renderSelection();
122
210
  if (this.isRendered)
123
211
  this.updateToolbar();
124
- emit(this, "selection-changed", { box: this.selected });
212
+ emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
125
213
  }
126
214
  setValidation(checks) {
127
215
  this.checks = checks;
@@ -131,7 +219,7 @@ export class ProcessModeler extends BaseElement {
131
219
  this.setValidation(message ? [{ message, path }] : []);
132
220
  }
133
221
  requestEdit(edit) {
134
- if (this.locked)
222
+ if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect")))
135
223
  return;
136
224
  const before = this.layout;
137
225
  const previousIds = new Set(this.projection.boxes.map((box) => box.id));
@@ -142,11 +230,14 @@ export class ProcessModeler extends BaseElement {
142
230
  if (accepted)
143
231
  return;
144
232
  accepted = true;
233
+ if (command.layout)
234
+ this.layoutValue = structuredClone(command.layout);
145
235
  this.refresh();
146
- if (edit.position &&
236
+ if (!command.layout &&
237
+ edit.position &&
147
238
  Number.isFinite(edit.position.x) &&
148
239
  Number.isFinite(edit.position.y)) {
149
- const added = this.projection.boxes.find((box) => !previousIds.has(box.id));
240
+ const added = this.projection.boxes.find(box => box.id === edit.boxId) ?? this.projection.boxes.find((box) => !previousIds.has(box.id));
150
241
  if (added)
151
242
  this.layoutValue = this.translatedLayout(added.id, edit.position.x, edit.position.y);
152
243
  }
@@ -154,7 +245,7 @@ export class ProcessModeler extends BaseElement {
154
245
  const restore = (layout) => {
155
246
  this.layoutValue = structuredClone(layout);
156
247
  this.refresh();
157
- emit(this, "layout-changed", { layout: this.layout });
248
+ this.emitPositions();
158
249
  };
159
250
  this.history.record({
160
251
  undo: () => {
@@ -168,7 +259,7 @@ export class ProcessModeler extends BaseElement {
168
259
  });
169
260
  this.refresh();
170
261
  if (JSON.stringify(before) !== JSON.stringify(after))
171
- emit(this, "layout-changed", { layout: this.layout });
262
+ this.emitPositions();
172
263
  announce("Process updated", "polite", this.ownerDocument);
173
264
  },
174
265
  };
@@ -194,7 +285,7 @@ export class ProcessModeler extends BaseElement {
194
285
  const apply = (layout) => {
195
286
  this.layoutValue = structuredClone(layout);
196
287
  this.refresh();
197
- emit(this, "layout-changed", { layout: this.layout });
288
+ this.emitPositions();
198
289
  };
199
290
  apply(after);
200
291
  this.history.record({
@@ -213,6 +304,10 @@ export class ProcessModeler extends BaseElement {
213
304
  if (emit(this, "move-request", { boxId: id, position: next.boxes[id] }))
214
305
  this.commitLayout(next);
215
306
  }
307
+ emitPositions() {
308
+ emit(this, "layout-changed", { layout: this.layout });
309
+ emit(this, "positions-changed", { positions: this.positions, version: this.versionValue });
310
+ }
216
311
  translatedLayout(id, x, y) {
217
312
  const snap = (n) => (this.snapToGrid ? Math.round(n / 20) * 20 : n);
218
313
  const next = this.layout;
@@ -241,7 +336,7 @@ export class ProcessModeler extends BaseElement {
241
336
  tidy() {
242
337
  if (this.locked)
243
338
  return;
244
- this.commitLayout(completeLayout(this.projection, { ...this.layout, boxes: {} }));
339
+ this.commitLayout(this.arrangedLayout({ ...this.layout, boxes: {} }));
245
340
  this.fit();
246
341
  }
247
342
  resize(id, width, height) {
@@ -333,7 +428,7 @@ export class ProcessModeler extends BaseElement {
333
428
  *{box-sizing:border-box}button,input{font:inherit;color:inherit}button{background:var(--boe-token-surface-surface,#fff);border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:8px;padding:8px;min-height:36px;cursor:pointer}button:disabled{opacity:.5;cursor:default}
334
429
  ${boeFocusVisibleStyles(":is(button,input,[part=canvas],[part=minimap])")}
335
430
  [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}
431
+ [part=layout]{display:grid;grid-template-columns:minmax(0,1fr) minmax(var(--boe-process-inspector-min-width,240px),var(--boe-process-inspector-width,320px));gap:16px}
337
432
  [part=canvas]{position:relative;overflow:hidden;height:var(--boe-process-height,560px);background:var(--boe-token-surface-surface-secondary,#fbfbfb);border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:12px;touch-action:none}
338
433
  [part=world]{position:absolute;inset:0;transform-origin:0 0}
339
434
  [part=lines]{position:absolute;inset:0;width:1px;height:1px;overflow:visible;pointer-events:none}
@@ -344,7 +439,12 @@ export class ProcessModeler extends BaseElement {
344
439
  [aria-pressed=true]{border-color:var(--boe-token-surface-surface-brand,#0061d5)}[data-invalid=true]{border-color:var(--boe-token-text-status-text-error,#b92340);border-width:2px}
345
440
  [part=connection]{position:absolute;display:flex;gap:4px;align-items:center;background:var(--boe-token-surface-surface,#fff);border-radius:8px;padding:4px;font-size:12px;white-space:nowrap}
346
441
  [part=connection] button{font-size:12px;min-height:32px;padding:4px}
442
+ [part=connection] button{opacity:0;pointer-events:none}
443
+ [part=connection]:hover button,[part=connection]:focus-within button{opacity:1;pointer-events:auto}
444
+ [part=connection]{min-width:40px;min-height:32px}
445
+ [part=insert-chooser]{padding:12px;background:var(--boe-token-surface-surface,#fff);border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:12px;max-width:calc(100vw - 32px);max-height:65vh;overflow:auto}
347
446
  [part=inspector]{min-width:0;padding:12px;border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:12px;background:var(--boe-token-surface-surface,#fff)}
447
+ [hidden]{display:none!important}
348
448
  [part=palette]{display:grid;gap:8px}[part=palette] input{width:100%;min-width:0;padding:8px;background:var(--boe-token-surface-surface,#fff);border:1px solid var(--boe-token-stroke-stroke,#ddd);border-radius:8px}
349
449
  [part=choices]{display:grid;gap:6px;max-height:300px;overflow:auto}[part=group]{font-weight:600;margin:8px 0 0}
350
450
  [part=checks]{display:grid;gap:6px;margin-top:12px}[part=checks] button{text-align:start;color:var(--boe-token-text-status-text-error,#b92340)}
@@ -356,6 +456,207 @@ export class ProcessModeler extends BaseElement {
356
456
  @media(max-width:800px){[part=layout]{grid-template-columns:minmax(0,1fr)}[part=canvas]{height:420px}}
357
457
  @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important;scroll-behavior:auto!important}}
358
458
  </style><div part="toolbar" role="toolbar" aria-label="Diagram controls"><button data-command="zoom-out">Zoom out</button><button data-command="reset">100%</button><button data-command="zoom-in">Zoom in</button><button data-command="fit">Fit the whole process</button><button data-command="tidy">Tidy up</button><button data-command="undo">Undo</button><button data-command="redo">Redo</button><button data-command="snap" aria-pressed="false">Snap to grid</button><button data-command="lock" aria-pressed="false">Lock diagram</button></div><div part="layout"><div part="canvas" tabindex="0" role="region" aria-label="Process diagram" aria-describedby="process-help"><div part="world"></div><svg part="minimap" role="img" aria-label="Diagram overview. Click to jump"></svg></div><div part="inspector" role="region" aria-label="Process details"><div part="palette"><label>Find a building block<input type="search" part="search"></label><div part="choices"></div></div><div part="editor"></div><div part="checks" role="region" aria-label="Checks"></div></div></div><p part="help" id="process-help">Select a box. Arrow keys move it; C then another box connects it; Delete removes it. Drag the background to pan. Use zoom controls or pinch to zoom. Escape cancels connecting.</p><div part="status" role="status" aria-live="polite"></div>`;
459
+ const chooser = document.createElement("dialog");
460
+ chooser.setAttribute("part", "insert-chooser");
461
+ chooser.setAttribute("aria-label", "Insert a building block");
462
+ const picker = new KindPicker();
463
+ picker.variant = "menu";
464
+ chooser.append(picker);
465
+ this.shadowRoot.append(chooser);
466
+ picker.addEventListener("kind-pick", event => {
467
+ const insertion = this.insertion;
468
+ dismissModal(chooser);
469
+ this.insertion = undefined;
470
+ if (insertion)
471
+ this.requestEdit({ ...insertion, kind: event.detail.kind });
472
+ });
473
+ picker.addEventListener("picker-cancel", () => { dismissModal(chooser); this.insertion = undefined; });
474
+ this.setupPanes();
475
+ }
476
+ setupPanes() {
477
+ const root = this.shadowRoot;
478
+ const style = document.createElement("style");
479
+ style.textContent = `
480
+ [part=layout]{grid-template-columns:180px minmax(0,1fr) minmax(var(--boe-process-inspector-min-width,220px),var(--boe-process-inspector-width,280px))}
481
+ [part=pane-drawer]{display:contents;color:inherit}[part=pane-drawer]::backdrop{background:rgb(0 0 0 / .45)}
482
+ [part=pane-close],[data-command=palette],[data-command=details]{display:none}
483
+ [part=palette]{align-content:start;border:1px solid var(--boe-control-edge,#6f6f6f);border-radius:12px;padding:12px;background:var(--boe-token-surface-surface,#fff)}
484
+ [part=pane-tabs]{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:12px}[part=pane-tabs] button{font-size:12px;min-height:28px}
485
+ [part=pane-content]{display:grid;gap:8px}[part=pane-content] button{min-height:28px;text-align:start}
486
+ [part=port]{position:absolute;width:24px;height:24px;min-height:24px;border-radius:50%;padding:0;background:var(--boe-token-surface-surface-brand,#0061d5);border:2px solid var(--boe-token-surface-surface,#fff);opacity:0;z-index:3}
487
+ [part=box]:hover [part=port],[part=box]:focus-within [part=port],[part=box][aria-current=true] [part=port],[part=frame]:hover [part=port]{opacity:1}
488
+ [data-side=north]{left:calc(50% - 12px);top:-12px}[data-side=south]{left:calc(50% - 12px);bottom:-12px}[data-side=east]{right:-12px;top:calc(50% - 12px)}[data-side=west]{left:-12px;top:calc(50% - 12px)}
489
+ [part=box][aria-current=true],[part=frame][aria-current=true]{border:2px solid var(--boe-token-surface-surface-brand,#0061d5)}
490
+ [part=line-hit]{fill:none;stroke:transparent;stroke-width:24;pointer-events:stroke}[data-drop=true]{stroke:var(--boe-token-surface-surface-brand,#0061d5);stroke-width:4}
491
+ [part=connection]{transform:translate(-50%,-50%);font-size:12px}
492
+ [part=connection-actions]{position:absolute;top:100%;left:50%;transform:translateX(-50%);display:flex;gap:4px;width:max-content;background:var(--boe-token-surface-surface,#fff);border-radius:8px}
493
+ [part=marquee]{position:absolute;border:2px solid var(--boe-token-surface-surface-brand,#0061d5);background:color-mix(in srgb,var(--boe-token-surface-surface-brand,#0061d5) 12%,transparent);pointer-events:none}
494
+ [part=guide]{position:absolute;background:var(--boe-token-surface-surface-brand,#0061d5);pointer-events:none}
495
+ [part=measure]{position:absolute;background:var(--boe-token-surface-surface,#fff);font-size:12px;padding:4px;pointer-events:none}
496
+ [part=selection-toolbar]{position:absolute;left:12px;bottom:12px;display:flex;gap:4px;flex-wrap:wrap;background:var(--boe-token-surface-surface,#fff);padding:6px;border:1px solid var(--boe-control-edge,#6f6f6f);border-radius:8px;z-index:5}
497
+ :host([data-narrow]) [part=layout]{grid-template-columns:minmax(0,1fr)}
498
+ :host([data-narrow]) [part=pane-drawer]{display:none;max-width:calc(100vw - 24px);width:320px;max-height:85vh;overflow:auto;border:1px solid var(--boe-control-edge,#6f6f6f);border-radius:12px;padding:12px;background:var(--boe-token-surface-surface,#fff)}
499
+ :host([data-narrow]) [part=pane-drawer][open]{display:block}
500
+ :host([data-narrow]) [part=pane-close],:host([data-narrow]) [data-command=palette],:host([data-narrow]) [data-command=details]{display:inline-block}
501
+ `;
502
+ root.append(style);
503
+ const layout = root.querySelector('[part=layout]');
504
+ for (const [part, label] of [["palette", "Building blocks"], ["inspector", "Process details"]]) {
505
+ const pane = root.querySelector(`[part=${part}]`);
506
+ const dialog = document.createElement("dialog");
507
+ dialog.setAttribute("part", "pane-drawer");
508
+ dialog.setAttribute("aria-label", label);
509
+ const close = document.createElement("button");
510
+ close.setAttribute("part", "pane-close");
511
+ close.textContent = `Close ${label.toLowerCase()}`;
512
+ close.onclick = () => dismissModal(dialog);
513
+ dialog.append(close, pane);
514
+ if (part === "palette")
515
+ layout.prepend(dialog);
516
+ else
517
+ layout.append(dialog);
518
+ const trigger = document.createElement("button");
519
+ trigger.dataset.command = part === "palette" ? "palette" : "details";
520
+ trigger.textContent = label;
521
+ trigger.onclick = () => promoteModal(dialog);
522
+ root.querySelector('[part=toolbar]').append(trigger);
523
+ }
524
+ const inspector = root.querySelector('[part=inspector]');
525
+ const tabs = document.createElement("div");
526
+ tabs.setAttribute("part", "pane-tabs");
527
+ tabs.setAttribute("role", "tablist");
528
+ tabs.setAttribute("aria-label", "Process details");
529
+ for (const name of ["Outline", "Checks", "Variables", "Connections", "Shortcuts"]) {
530
+ const button = document.createElement("button");
531
+ button.textContent = name;
532
+ button.setAttribute("role", "tab");
533
+ button.id = `process-tab-${name.toLowerCase()}`;
534
+ button.setAttribute("aria-controls", "process-pane-content");
535
+ button.onclick = () => { this.activePane = name; this.renderPane(); };
536
+ button.onkeydown = event => {
537
+ const buttons = Array.from(tabs.querySelectorAll("button"));
538
+ const index = buttons.indexOf(button);
539
+ 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;
540
+ if (next >= 0) {
541
+ event.preventDefault();
542
+ buttons[next].click();
543
+ buttons[next].focus();
544
+ }
545
+ };
546
+ tabs.append(button);
547
+ }
548
+ const content = document.createElement("div");
549
+ content.setAttribute("part", "pane-content");
550
+ content.setAttribute("role", "tabpanel");
551
+ content.id = "process-pane-content";
552
+ inspector.prepend(tabs);
553
+ inspector.append(content);
554
+ root.querySelector('[part=checks]').removeAttribute("role");
555
+ const canvas = root.querySelector('[part=canvas]');
556
+ const floating = document.createElement("div");
557
+ floating.setAttribute("part", "selection-toolbar");
558
+ floating.setAttribute("role", "toolbar");
559
+ floating.setAttribute("aria-label", "Selected steps");
560
+ for (const [command, label] of [["align", "Align tops"], ["space", "Space evenly"], ["delete-many", "Delete selected"]]) {
561
+ const button = document.createElement("button");
562
+ button.textContent = label;
563
+ button.onclick = () => this.selectionCommand(command);
564
+ floating.append(button);
565
+ }
566
+ canvas.append(floating);
567
+ if (typeof ResizeObserver !== "undefined") {
568
+ this.resizeObserver = new ResizeObserver(entries => {
569
+ const narrow = entries[0].contentRect.width < 1000;
570
+ this.narrowValue = narrow;
571
+ this.toggleAttribute("data-narrow", narrow);
572
+ if (!narrow)
573
+ root.querySelectorAll('[part=pane-drawer]').forEach(dialog => dismissModal(dialog));
574
+ });
575
+ this.resizeObserver.observe(this);
576
+ }
577
+ }
578
+ renderPane() {
579
+ if (!this.isRendered)
580
+ return;
581
+ const root = this.shadowRoot;
582
+ root.querySelectorAll('[part=pane-tabs] button').forEach(button => {
583
+ const selected = button.textContent === this.activePane;
584
+ button.setAttribute("aria-selected", String(selected));
585
+ button.tabIndex = selected ? 0 : -1;
586
+ });
587
+ const content = root.querySelector('[part=pane-content]');
588
+ content.setAttribute("aria-labelledby", `process-tab-${this.activePane.toLowerCase()}`);
589
+ const checks = root.querySelector('[part=checks]');
590
+ checks.hidden = this.activePane !== "Checks";
591
+ // Move the checks node out before replacing its parent; keep its handlers.
592
+ root.querySelector('[part=inspector]').append(checks);
593
+ content.replaceChildren();
594
+ root.querySelector('[part=editor]').hidden = this.activePane !== "Outline";
595
+ if (this.activePane === "Outline") {
596
+ this.projection.boxes.forEach(box => {
597
+ const button = document.createElement("button");
598
+ button.textContent = box.title;
599
+ button.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
600
+ button.onclick = () => { this.select(box.id); this.focusBox(box.id); };
601
+ content.append(button);
602
+ });
603
+ }
604
+ else if (this.activePane === "Checks") {
605
+ content.append(checks);
606
+ if (!this.allChecks.length) {
607
+ const message = document.createElement("p");
608
+ message.textContent = "No checks to resolve.";
609
+ content.prepend(message);
610
+ }
611
+ }
612
+ else if (this.activePane === "Connections" || this.activePane === "Variables") {
613
+ const items = this.activePane === "Connections" ? this.connections : this.variables;
614
+ for (const item of items) {
615
+ const row = document.createElement("p");
616
+ row.textContent = "kind" in item ? `${item.name} (${item.kind})` : `${item.name}${item.description ? `: ${item.description}` : ""}`;
617
+ content.append(row);
618
+ }
619
+ if (!items.length)
620
+ content.textContent = "None supplied by the host.";
621
+ }
622
+ else if (this.activePane === "Shortcuts") {
623
+ content.textContent = "Arrow keys: move selected step. Shift+arrows: move farther. C: connect selected step. Shift+drag background: select steps. Delete: remove selected. Escape: cancel. Ctrl/Command+Z: undo. Shift+Ctrl/Command+Z: redo.";
624
+ }
625
+ }
626
+ selectionCommand(command) {
627
+ if (this.locked)
628
+ return;
629
+ const boxes = this.selectedBoxes;
630
+ if (command === "delete-many") {
631
+ for (const box of boxes)
632
+ this.requestEdit({ type: "delete", boxId: box.id });
633
+ return;
634
+ }
635
+ if (boxes.length < 2)
636
+ return;
637
+ const next = this.layout;
638
+ const sorted = [...boxes].filter(box => {
639
+ let parent = box.parentId;
640
+ while (parent) {
641
+ if (this.selectedIds.has(parent))
642
+ return false;
643
+ parent = this.projection.boxes.find(box => box.id === parent)?.parentId;
644
+ }
645
+ return true;
646
+ }).sort((a, b) => next.boxes[a.id].x - next.boxes[b.id].x);
647
+ if (sorted.length < 2)
648
+ return;
649
+ const first = next.boxes[sorted[0].id];
650
+ const last = next.boxes[sorted.at(-1).id];
651
+ sorted.forEach((box, i) => {
652
+ const current = next.boxes[box.id];
653
+ const moved = this.translatedLayout(box.id, command === "align" ? current.x : first.x + i * (last.x - first.x) / (sorted.length - 1), command === "align" ? first.y : current.y);
654
+ for (const candidate of this.projection.boxes)
655
+ if (moved.boxes[candidate.id].x !== this.layoutValue.boxes[candidate.id].x || moved.boxes[candidate.id].y !== this.layoutValue.boxes[candidate.id].y)
656
+ next.boxes[candidate.id] = moved.boxes[candidate.id];
657
+ });
658
+ if (emit(this, "move-request", { boxId: sorted[0].id, position: next.boxes[sorted[0].id], boxIds: sorted.map(box => box.id), positions: next.boxes }))
659
+ this.commitLayout(next);
359
660
  }
360
661
  setupListeners() {
361
662
  const toolbar = this.shadowRoot.querySelector("[part=toolbar]");
@@ -389,12 +690,12 @@ export class ProcessModeler extends BaseElement {
389
690
  this.snapToGrid = !this.snapToGrid;
390
691
  else if (command === "lock")
391
692
  this.locked = !this.locked;
392
- else if (command === "connect" && this.selected && !this.locked) {
693
+ else if (command === "connect" && this.selected && !this.locked && !this.disableConnections) {
393
694
  this.connecting = this.selected.id;
394
695
  this.setStatus(`Select the next box to connect from ${this.selected.title}`);
395
696
  }
396
697
  else if (command === "delete" && this.selected)
397
- this.requestEdit({ type: "delete", boxId: this.selected.id });
698
+ this.selectionCommand("delete-many");
398
699
  });
399
700
  this.shadowRoot.querySelector("[part=search]").addEventListener("input", () => this.renderPalette());
400
701
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
@@ -413,6 +714,11 @@ export class ProcessModeler extends BaseElement {
413
714
  canvas.addEventListener("pointermove", (event) => this.pointerMove(event));
414
715
  canvas.addEventListener("pointerup", (event) => this.pointerEnd(event, true));
415
716
  canvas.addEventListener("pointercancel", (event) => this.pointerEnd(event, false));
717
+ canvas.addEventListener("lostpointercapture", event => {
718
+ // Touch transfers implicit box capture to the canvas during a drag.
719
+ if (event.target === canvas)
720
+ this.pointerEnd(event, false);
721
+ });
416
722
  canvas.addEventListener("gesturestart", (event) => {
417
723
  event.preventDefault();
418
724
  this.gestureZoom = this.viewport.zoom;
@@ -470,6 +776,8 @@ export class ProcessModeler extends BaseElement {
470
776
  if (event.target.closest("input,textarea,select,[contenteditable]"))
471
777
  return;
472
778
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
779
+ if (this.locked || !(event.shiftKey ? this.history.canRedo : this.history.canUndo))
780
+ return;
473
781
  event.preventDefault();
474
782
  if (event.shiftKey)
475
783
  this.redo();
@@ -479,20 +787,28 @@ export class ProcessModeler extends BaseElement {
479
787
  }
480
788
  if (event.key === "Escape") {
481
789
  this.connecting = null;
790
+ this.portDrag = undefined;
791
+ this.marquee = undefined;
792
+ this.segmentDrag = undefined;
793
+ this.drag = undefined;
794
+ this.pointers.clear();
795
+ this.refresh();
482
796
  this.setStatus("Connecting cancelled");
483
797
  return;
484
798
  }
485
799
  const box = this.selected;
486
800
  if (!box || this.locked)
487
801
  return;
488
- if (event.key.toLowerCase() === "c") {
802
+ if (event.key === "Enter" || event.key === " ")
803
+ return;
804
+ if (event.key.toLowerCase() === "c" && !this.disableConnections) {
489
805
  event.preventDefault();
490
806
  this.connecting = box.id;
491
807
  this.setStatus(`Select the next box to connect from ${box.title}`);
492
808
  }
493
809
  if (event.key === "Delete" || event.key === "Backspace") {
494
810
  event.preventDefault();
495
- this.requestEdit({ type: "delete", boxId: box.id });
811
+ this.selectionCommand("delete-many");
496
812
  }
497
813
  const direction = {
498
814
  ArrowLeft: [-1, 0],
@@ -509,8 +825,29 @@ export class ProcessModeler extends BaseElement {
509
825
  }
510
826
  }
511
827
  pointerDown(event) {
828
+ if (event.button !== 0)
829
+ return;
830
+ this.suppressClick = false;
831
+ if (this.portDrag || this.segmentDrag || this.marquee)
832
+ return;
833
+ const control = event.target.closest("[part=port]");
834
+ if (control && !this.locked && !this.disableConnections) {
835
+ event.preventDefault();
836
+ const point = this.canvasPoint(event);
837
+ this.portDrag = { pointerId: event.pointerId, id: control.dataset.owner, side: control.dataset.side, start: point, point };
838
+ this.shadowRoot.querySelector('[part=canvas]').setPointerCapture?.(event.pointerId);
839
+ return;
840
+ }
841
+ const segment = event.target.closest("[data-segment]");
842
+ if (segment && !this.locked) {
843
+ const line = this.projection.lines.find(line => line.id === segment.dataset.lineId);
844
+ this.segmentDrag = { pointerId: event.pointerId, id: line.id, index: Number(segment.dataset.segment), points: routeProcessLine(line, this.layoutValue, this.projection) };
845
+ this.shadowRoot.querySelector('[part=canvas]').setPointerCapture?.(event.pointerId);
846
+ event.preventDefault();
847
+ return;
848
+ }
512
849
  if (event.button !== 0 ||
513
- event.target.closest("[part=connection]"))
850
+ event.target.closest("[part=connection],button,input,select,textarea,[contenteditable]"))
514
851
  return;
515
852
  this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
516
853
  if (this.pointers.size === 2) {
@@ -522,7 +859,15 @@ export class ProcessModeler extends BaseElement {
522
859
  const target = event.target.closest("[data-box-id]");
523
860
  const id = target?.dataset.boxId;
524
861
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
525
- (target ?? canvas).setPointerCapture?.(event.pointerId);
862
+ if (id && !this.selectedIds.has(id) && !event.shiftKey)
863
+ this.select(id);
864
+ if (!id && event.shiftKey && !this.locked) {
865
+ const point = this.canvasPoint(event);
866
+ this.marquee = { pointerId: event.pointerId, start: point, point };
867
+ canvas.setPointerCapture?.(event.pointerId);
868
+ event.preventDefault();
869
+ return;
870
+ }
526
871
  this.drag = {
527
872
  id: this.locked ? undefined : id,
528
873
  x: event.clientX,
@@ -537,6 +882,44 @@ export class ProcessModeler extends BaseElement {
537
882
  return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
538
883
  }
539
884
  pointerMove(event) {
885
+ const gesture = this.portDrag ?? this.segmentDrag ?? this.marquee;
886
+ if (gesture && gesture.pointerId !== event.pointerId)
887
+ return;
888
+ const point = this.canvasPoint(event);
889
+ if (this.portDrag) {
890
+ this.portDrag.point = point;
891
+ let ghost = this.shadowRoot.querySelector('[part=connection-preview]');
892
+ if (!ghost) {
893
+ ghost = svgElement("polyline");
894
+ ghost.setAttribute("part", "connection-preview");
895
+ ghost.style.cssText = "fill:none;stroke:var(--boe-token-surface-surface-brand,#0061d5);stroke-width:3";
896
+ this.shadowRoot.querySelector('[part=lines]').append(ghost);
897
+ }
898
+ const start = this.portDrag.start;
899
+ ghost.setAttribute("points", `${start.x},${start.y} ${point.x},${start.y} ${point.x},${point.y}`);
900
+ return;
901
+ }
902
+ if (this.segmentDrag) {
903
+ const { index, points } = this.segmentDrag;
904
+ if (points[index].x === points[index + 1].x)
905
+ points[index].x = points[index + 1].x = point.x;
906
+ else
907
+ points[index].y = points[index + 1].y = point.y;
908
+ const path = Array.from(this.shadowRoot.querySelectorAll('[part=line]')).find(path => path.dataset.lineId === this.segmentDrag.id);
909
+ path?.setAttribute("points", points.map(p => `${p.x},${p.y}`).join(" "));
910
+ return;
911
+ }
912
+ if (this.marquee) {
913
+ this.marquee.point = point;
914
+ let region = this.shadowRoot.querySelector('[part=marquee]');
915
+ if (!region) {
916
+ region = document.createElement("div");
917
+ region.setAttribute("part", "marquee");
918
+ this.shadowRoot.querySelector('[part=world]').append(region);
919
+ }
920
+ 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) });
921
+ return;
922
+ }
540
923
  if (!this.pointers.has(event.pointerId))
541
924
  return;
542
925
  this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
@@ -551,11 +934,16 @@ export class ProcessModeler extends BaseElement {
551
934
  return;
552
935
  const dx = event.clientX - this.drag.x;
553
936
  const dy = event.clientY - this.drag.y;
937
+ if (Math.hypot(dx, dy) <= 4)
938
+ return;
939
+ this.shadowRoot.querySelector('[part=canvas]').setPointerCapture?.(event.pointerId);
554
940
  if (this.drag.id && this.drag.position) {
555
941
  const element = Array.from(this.shadowRoot.querySelectorAll("[data-box-id]")).find((box) => box.dataset.boxId === this.drag.id);
556
942
  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`;
943
+ const position = this.alignedPoint(this.drag.id, this.drag.position.x + dx / this.viewport.zoom, this.drag.position.y + dy / this.viewport.zoom);
944
+ element.style.left = `${position.x}px`;
945
+ element.style.top = `${position.y}px`;
946
+ this.markDropLine(point, this.drag.id);
559
947
  }
560
948
  }
561
949
  else {
@@ -565,6 +953,48 @@ export class ProcessModeler extends BaseElement {
565
953
  }
566
954
  }
567
955
  pointerEnd(event, commit) {
956
+ const gesture = this.portDrag ?? this.segmentDrag ?? this.marquee;
957
+ if (gesture && gesture.pointerId !== event.pointerId)
958
+ return;
959
+ const point = this.canvasPoint(event);
960
+ if (this.portDrag) {
961
+ const port = this.portDrag;
962
+ this.portDrag = undefined;
963
+ this.shadowRoot.querySelector('[part=connection-preview]')?.remove();
964
+ if (commit) {
965
+ if (Math.hypot(point.x - port.start.x, point.y - port.start.y) < 8) {
966
+ const pos = this.layoutValue.boxes[port.id];
967
+ const offsets = { east: [320, 0], west: [-320, 0], north: [0, -170], south: [0, 170] }[port.side];
968
+ this.openChooser({ type: "add", from: port.id, fromSide: port.side, position: { x: pos.x + offsets[0], y: pos.y + offsets[1] } });
969
+ }
970
+ else {
971
+ const target = this.boxAt(point, port.id);
972
+ if (target)
973
+ this.requestEdit({ type: "connect", from: port.id, to: target.id, fromSide: port.side });
974
+ }
975
+ }
976
+ return;
977
+ }
978
+ if (this.segmentDrag) {
979
+ const segment = this.segmentDrag;
980
+ this.segmentDrag = undefined;
981
+ if (commit)
982
+ this.routeLine(segment.id, segment.points.slice(1, -1));
983
+ else
984
+ this.refresh();
985
+ return;
986
+ }
987
+ if (this.marquee) {
988
+ const { start } = this.marquee;
989
+ this.marquee = undefined;
990
+ if (commit)
991
+ this.selectMany(this.projection.boxes.filter(box => { const p = this.layoutValue.boxes[box.id]; return p.x < Math.max(start.x, point.x) && p.x + (p.width ?? 220) > Math.min(start.x, point.x) && p.y < Math.max(start.y, point.y) && p.y + (p.height ?? 96) > Math.min(start.y, point.y); }).map(box => box.id));
992
+ this.shadowRoot.querySelector('[part=marquee]')?.remove();
993
+ this.pointers.delete(event.pointerId);
994
+ return;
995
+ }
996
+ if (!this.pointers.has(event.pointerId))
997
+ return;
568
998
  this.pointers.delete(event.pointerId);
569
999
  const drag = this.drag;
570
1000
  this.drag = undefined;
@@ -572,22 +1002,142 @@ export class ProcessModeler extends BaseElement {
572
1002
  if (drag?.id && drag.position) {
573
1003
  const dx = event.clientX - drag.x;
574
1004
  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);
1005
+ if (commit && Math.hypot(dx, dy) > 4) {
1006
+ this.suppressClick = true;
1007
+ const position = this.alignedPoint(drag.id, drag.position.x + dx / this.viewport.zoom, drag.position.y + dy / this.viewport.zoom);
1008
+ const line = this.lineAt(point, drag.id);
1009
+ const frame = this.boxAt(point, drag.id, true);
1010
+ if (line)
1011
+ this.requestEdit({ type: "insert", boxId: drag.id, lineId: line.id, from: line.from, to: line.to, position });
1012
+ else if (this.projection.boxes.find(box => box.id === drag.id)?.parentId !== frame?.id)
1013
+ this.requestEdit({ type: "reparent", boxId: drag.id, parentId: frame?.id, position });
1014
+ else if (this.selectedIds.size > 1 && this.selectedIds.has(drag.id)) {
1015
+ const next = this.layout;
1016
+ const dx = position.x - drag.position.x;
1017
+ const dy = position.y - drag.position.y;
1018
+ const moved = new Set(this.selectedIds);
1019
+ for (let changed = true; changed;) {
1020
+ changed = false;
1021
+ for (const box of this.projection.boxes)
1022
+ if (box.parentId && moved.has(box.parentId) && !moved.has(box.id)) {
1023
+ moved.add(box.id);
1024
+ changed = true;
1025
+ }
1026
+ }
1027
+ const primary = next.boxes[drag.id];
1028
+ const shiftX = this.snapToGrid ? Math.round((primary.x + dx) / 20) * 20 - primary.x : dx;
1029
+ const shiftY = this.snapToGrid ? Math.round((primary.y + dy) / 20) * 20 - primary.y : dy;
1030
+ for (const id of moved)
1031
+ next.boxes[id] = { ...next.boxes[id], x: next.boxes[id].x + shiftX, y: next.boxes[id].y + shiftY };
1032
+ if (emit(this, "move-request", { boxId: drag.id, position: next.boxes[drag.id], boxIds: [...moved], positions: next.boxes }))
1033
+ this.commitLayout(next);
1034
+ }
1035
+ else
1036
+ this.move(drag.id, position.x, position.y);
1037
+ }
577
1038
  else if (!commit)
578
1039
  this.refresh();
579
1040
  }
1041
+ this.shadowRoot.querySelectorAll('[part=guide],[part=measure]').forEach(element => element.remove());
1042
+ this.markDropLine(undefined);
1043
+ if (drag?.id && (!commit || Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4))
1044
+ this.refresh();
1045
+ }
1046
+ canvasPoint(event) {
1047
+ const rect = this.shadowRoot.querySelector('[part=canvas]').getBoundingClientRect();
1048
+ return { x: (event.clientX - rect.left - this.viewport.x) / this.viewport.zoom, y: (event.clientY - rect.top - this.viewport.y) / this.viewport.zoom };
1049
+ }
1050
+ boxAt(point, except, frameOnly = false) {
1051
+ return [...this.projection.boxes].reverse().find(box => {
1052
+ if (box.id === except || (frameOnly && !box.frame))
1053
+ return false;
1054
+ // A frame cannot be dropped inside its own descendants.
1055
+ let parent = box.parentId;
1056
+ while (parent) {
1057
+ if (parent === except)
1058
+ return false;
1059
+ parent = this.projection.boxes.find(b => b.id === parent)?.parentId;
1060
+ }
1061
+ const p = this.layoutValue.boxes[box.id];
1062
+ return point.x >= p.x - 12 && point.x <= p.x + (p.width ?? 220) + 12 && point.y >= p.y - 12 && point.y <= p.y + (p.height ?? 96) + 12;
1063
+ });
1064
+ }
1065
+ lineAt(point, except) {
1066
+ return this.projection.lines.find(line => line.from !== except && line.to !== except && nearProcessLine(point, this.routedLines.get(line.id) ?? [], 16 / this.viewport.zoom));
1067
+ }
1068
+ markDropLine(point, except) {
1069
+ this.dropLine = point ? this.lineAt(point, except)?.id : undefined;
1070
+ this.shadowRoot.querySelectorAll('[part=line]').forEach(path => path.dataset.drop = String(path.dataset.lineId === this.dropLine));
1071
+ }
1072
+ alignedPoint(id, x, y) {
1073
+ const world = this.shadowRoot.querySelector('[part=world]');
1074
+ world.querySelectorAll('[part=guide],[part=measure]').forEach(element => element.remove());
1075
+ for (const box of this.projection.boxes) {
1076
+ if (box.id === id)
1077
+ continue;
1078
+ const p = this.layoutValue.boxes[box.id];
1079
+ for (const axis of ["x", "y"]) {
1080
+ const value = axis === "x" ? x : y;
1081
+ if (Math.abs(value - p[axis]) < 8 / this.viewport.zoom) {
1082
+ if (axis === "x")
1083
+ x = p.x;
1084
+ else
1085
+ y = p.y;
1086
+ const guide = document.createElement("div");
1087
+ guide.setAttribute("part", "guide");
1088
+ guide.style.cssText = axis === "x" ? `left:${x}px;top:${Math.min(y, p.y)}px;width:1px;height:${Math.abs(y - p.y) + 96}px` : `left:${Math.min(x, p.x)}px;top:${y}px;height:1px;width:${Math.abs(x - p.x) + 220}px`;
1089
+ world.append(guide);
1090
+ }
1091
+ }
1092
+ if (Math.abs(y - p.y) < 8) {
1093
+ const distance = x - p.x - (p.width ?? 220);
1094
+ if (distance > 0) {
1095
+ const measure = document.createElement("span");
1096
+ measure.setAttribute("part", "measure");
1097
+ measure.textContent = `${Math.round(distance)}px`;
1098
+ measure.style.cssText = `left:${p.x + (p.width ?? 220)}px;top:${y + 40}px`;
1099
+ world.append(measure);
1100
+ }
1101
+ }
1102
+ }
1103
+ return { x, y };
1104
+ }
1105
+ openChooser(edit) {
1106
+ this.insertion = edit;
1107
+ const chooser = this.shadowRoot.querySelector('[part=insert-chooser]');
1108
+ const picker = chooser.querySelector('box-kind-picker');
1109
+ picker.catalog = this.catalog;
1110
+ picker.refresh();
1111
+ promoteModal(chooser);
1112
+ picker.focus();
580
1113
  }
581
1114
  update() {
1115
+ const selectionBefore = JSON.stringify({ ids: [...this.selectedIds], path: this.selectedPath });
582
1116
  if (this.documentValue !== undefined) {
583
1117
  this.projection = this.model.project(this.documentValue);
584
1118
  validateProjection(this.projection);
585
1119
  }
586
1120
  else
587
1121
  this.projection = { boxes: [], lines: [] };
588
- this.layoutValue = completeLayout(this.projection, this.layoutValue);
1122
+ this.layoutValue = this.arrangedLayout(this.layoutValue);
1123
+ this.routedLines = new Map(this.projection.lines.map(line => [line.id, routeProcessLine(line, this.layoutValue, this.projection)]));
1124
+ this.computedChecks = this.collectChecks();
589
1125
  if (this.selectedId && !this.selected)
590
1126
  this.selectedId = null;
1127
+ this.selectedIds = new Set([...this.selectedIds].filter(id => this.projection.boxes.some(box => box.id === id)));
1128
+ if (!this.selectedId)
1129
+ this.selectedId = this.selectedIds.values().next().value ?? null;
1130
+ const serialized = JSON.stringify({
1131
+ boxes: this.projection.boxes.map(box => ({ id: box.id, kind: box.kind, title: box.title, description: box.description, path: box.path, fingerprint: box.fingerprint, frame: box.frame, parentId: box.parentId })),
1132
+ lines: this.projection.lines,
1133
+ });
1134
+ if (serialized !== this.lastProjection || this.documentValue !== this.lastDocument) {
1135
+ if (this.lastProjection)
1136
+ this.versionValue = typeof this.versionValue === "number" ? this.versionValue + 1 : `${this.versionValue}+1`;
1137
+ this.lastProjection = serialized;
1138
+ this.lastDocument = this.documentValue;
1139
+ emit(this, "projection-changed", { projection: this.projection, version: this.versionValue, checks: this.allChecks });
1140
+ }
591
1141
  const focused = this.shadowRoot.activeElement
592
1142
  ?.dataset.boxId;
593
1143
  this.renderWorld();
@@ -596,9 +1146,17 @@ export class ProcessModeler extends BaseElement {
596
1146
  this.renderSelection();
597
1147
  this.paintViewport();
598
1148
  this.updateToolbar();
1149
+ if (selectionBefore !== JSON.stringify({ ids: [...this.selectedIds], path: this.selectedPath }))
1150
+ emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
599
1151
  if (focused)
600
1152
  this.focusBox(focused);
601
1153
  }
1154
+ get allChecks() { return this.computedChecks; }
1155
+ collectChecks() {
1156
+ const routes = this.projection.lines.filter(line => !this.routedLines.get(line.id)?.length)
1157
+ .map(line => ({ message: "A connection is blocked by overlapping steps. Move a step or adjust its route.", boxId: line.from }));
1158
+ return [...this.checks, ...graphChecks(this.projection), ...routes, ...(this.documentValue === undefined ? [] : this.model.validate?.(this.documentValue, this.projection) ?? [])];
1159
+ }
602
1160
  renderWorld() {
603
1161
  const world = this.shadowRoot.querySelector("[part=world]");
604
1162
  world.replaceChildren();
@@ -610,14 +1168,15 @@ export class ProcessModeler extends BaseElement {
610
1168
  world.append(element);
611
1169
  }
612
1170
  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";
1171
+ const element = document.createElement("div");
1172
+ element.tabIndex = 0;
1173
+ element.setAttribute("role", "group");
615
1174
  element.setAttribute("part", box.frame ? "frame" : "box");
616
1175
  element.dataset.boxId = box.id;
617
1176
  element.setAttribute("aria-label", [box.title, box.description, box.frame ? "Frame" : ""]
618
1177
  .filter(Boolean)
619
1178
  .join(". "));
620
- element.setAttribute("aria-pressed", String(box.id === this.selectedId));
1179
+ element.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
621
1180
  this.place(element, this.layoutValue.boxes[box.id]);
622
1181
  const kind = this.catalog.find((kind) => kind.kind === box.kind);
623
1182
  if (kind?.icon) {
@@ -635,7 +1194,17 @@ export class ProcessModeler extends BaseElement {
635
1194
  description.textContent = box.description;
636
1195
  element.append(description);
637
1196
  }
638
- element.addEventListener("click", () => {
1197
+ element.addEventListener("keydown", event => {
1198
+ if (event.target === element && (event.key === "Enter" || event.key === " ")) {
1199
+ event.preventDefault();
1200
+ element.click();
1201
+ }
1202
+ });
1203
+ element.addEventListener("click", event => {
1204
+ if (this.suppressClick && event.detail !== 0) {
1205
+ this.suppressClick = false;
1206
+ return;
1207
+ }
639
1208
  if (this.connecting && this.connecting !== box.id) {
640
1209
  this.requestEdit({
641
1210
  type: "connect",
@@ -644,8 +1213,30 @@ export class ProcessModeler extends BaseElement {
644
1213
  });
645
1214
  this.connecting = null;
646
1215
  }
647
- this.select(box.id);
1216
+ if (event.shiftKey)
1217
+ this.selectMany(this.selectedIds.has(box.id) ? [...this.selectedIds].filter(id => id !== box.id) : [...this.selectedIds, box.id]);
1218
+ else
1219
+ this.select(box.id);
648
1220
  });
1221
+ if (!this.disableConnections && !this.locked) {
1222
+ for (const side of ["north", "east", "south", "west"]) {
1223
+ const port = document.createElement("button");
1224
+ port.type = "button";
1225
+ port.setAttribute("part", "port");
1226
+ port.dataset.side = side;
1227
+ port.dataset.owner = box.id;
1228
+ port.setAttribute("aria-label", `Add or connect a step ${side} of ${box.title}`);
1229
+ port.addEventListener("click", event => {
1230
+ event.stopPropagation();
1231
+ if (!event.detail) {
1232
+ const pos = this.layoutValue.boxes[box.id];
1233
+ const offsets = { east: [320, 0], west: [-320, 0], north: [0, -170], south: [0, 170] }[side];
1234
+ this.openChooser({ type: "add", from: box.id, fromSide: side, position: { x: pos.x + offsets[0], y: pos.y + offsets[1] } });
1235
+ }
1236
+ });
1237
+ element.append(port);
1238
+ }
1239
+ }
649
1240
  world.append(element);
650
1241
  }
651
1242
  const lines = svgElement("svg");
@@ -653,48 +1244,93 @@ export class ProcessModeler extends BaseElement {
653
1244
  lines.setAttribute("aria-hidden", "true");
654
1245
  world.append(lines);
655
1246
  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;
1247
+ const points = this.routedLines.get(line.id);
1248
+ if (!points.length)
1249
+ continue;
1250
+ const midpoint = lineMidpoint(points);
662
1251
  const path = svgElement("polyline");
663
1252
  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
- ]
1253
+ path.dataset.lineId = line.id;
1254
+ path.setAttribute("vector-effect", "non-scaling-stroke");
1255
+ path.setAttribute("points", points
669
1256
  .map((point) => `${point.x},${point.y}`)
670
1257
  .join(" "));
671
1258
  if (line.dashed)
672
1259
  path.setAttribute("stroke-dasharray", "6 4");
673
1260
  lines.append(path);
1261
+ for (let i = 1; i < points.length - 2; i++) {
1262
+ const hit = svgElement("polyline");
1263
+ hit.setAttribute("part", "line-hit");
1264
+ hit.dataset.lineId = line.id;
1265
+ hit.dataset.segment = String(i);
1266
+ hit.setAttribute("vector-effect", "non-scaling-stroke");
1267
+ hit.setAttribute("points", `${points[i].x},${points[i].y} ${points[i + 1].x},${points[i + 1].y}`);
1268
+ lines.append(hit);
1269
+ }
674
1270
  const label = document.createElement("div");
675
1271
  label.setAttribute("part", "connection");
676
- label.style.left = `${(x1 + x2) / 2}px`;
677
- label.style.top = `${(y1 + y2) / 2}px`;
1272
+ label.style.left = `${midpoint.x}px`;
1273
+ label.style.top = `${midpoint.y}px`;
678
1274
  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}` : ""}`;
679
1275
  const text = document.createElement("span");
680
- text.textContent = line.label ?? "Connection";
1276
+ const source = this.projection.boxes.find(box => box.id === line.from);
1277
+ const branchIndex = this.projection.lines.filter(candidate => candidate.from === line.from).indexOf(line);
1278
+ 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
1279
  label.append(text);
1280
+ const actions = document.createElement("div");
1281
+ actions.setAttribute("part", "connection-actions");
1282
+ label.append(actions);
1283
+ if (points.length >= 4 && !this.locked) {
1284
+ const adjust = document.createElement("button");
1285
+ adjust.type = "button";
1286
+ adjust.textContent = "Route";
1287
+ adjust.dataset.segment = "1";
1288
+ adjust.dataset.lineId = line.id;
1289
+ adjust.setAttribute("aria-label", `Adjust route: ${names}. Use arrow keys`);
1290
+ adjust.onkeydown = event => {
1291
+ const direction = { ArrowLeft: [-10, 0], ArrowRight: [10, 0], ArrowUp: [0, -10], ArrowDown: [0, 10] }[event.key];
1292
+ if (!direction)
1293
+ return;
1294
+ event.preventDefault();
1295
+ event.stopPropagation();
1296
+ const current = routeProcessLine(line, this.layoutValue, this.projection);
1297
+ const vertical = current[1].x === current[2].x;
1298
+ for (const point of [current[1], current[2]]) {
1299
+ if (vertical)
1300
+ point.x += direction[0];
1301
+ else
1302
+ point.y += direction[1];
1303
+ }
1304
+ this.routeLine(line.id, current.slice(1, -1));
1305
+ Array.from(this.shadowRoot.querySelectorAll('[data-segment]')).find(button => button.dataset.lineId === line.id)?.focus();
1306
+ };
1307
+ actions.append(adjust);
1308
+ }
682
1309
  for (const [type, title] of [
683
1310
  ["insert", "Insert a step here"],
684
1311
  ["disconnect", "Remove connection"],
685
1312
  ]) {
1313
+ if (type === "disconnect" && this.disableConnections)
1314
+ continue;
686
1315
  const button = document.createElement("button");
687
1316
  button.type = "button";
688
1317
  button.textContent = type === "insert" ? "+" : "Remove";
689
1318
  button.setAttribute("aria-label", `${title}: ${names}`);
690
1319
  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);
1320
+ button.addEventListener("click", () => {
1321
+ const edit = {
1322
+ type,
1323
+ lineId: line.id,
1324
+ from: line.from,
1325
+ to: line.to,
1326
+ };
1327
+ if (type === "insert" && this.catalog.length) {
1328
+ this.openChooser(edit);
1329
+ }
1330
+ else
1331
+ this.requestEdit(edit);
1332
+ });
1333
+ actions.append(button);
698
1334
  }
699
1335
  world.append(label);
700
1336
  }
@@ -734,36 +1370,40 @@ export class ProcessModeler extends BaseElement {
734
1370
  button.textContent = kind.label;
735
1371
  button.disabled = this.locked;
736
1372
  button.draggable = !this.locked;
737
- button.addEventListener("click", () => this.requestEdit({ type: "add", kind, position: { x: 40, y: 40 } }));
1373
+ button.addEventListener("click", () => this.requestEdit({ type: "add", kind }));
738
1374
  button.addEventListener("dragstart", (event) => event.dataTransfer?.setData("application/boe-process-kind", kind.kind));
739
1375
  choices.append(button);
740
1376
  }
741
1377
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
742
1378
  canvas.ondragover = (event) => {
743
- if (!this.locked)
1379
+ if (!this.locked) {
744
1380
  event.preventDefault();
1381
+ this.markDropLine(this.canvasPoint(event));
1382
+ }
745
1383
  };
1384
+ canvas.ondragleave = () => this.markDropLine(undefined);
746
1385
  canvas.ondrop = (event) => {
747
1386
  event.preventDefault();
748
1387
  const kind = this.catalog.find((kind) => kind.kind ===
749
1388
  event.dataTransfer?.getData("application/boe-process-kind"));
750
- if (!kind)
1389
+ if (!kind || this.locked)
751
1390
  return;
752
- const rect = canvas.getBoundingClientRect();
1391
+ const position = this.canvasPoint(event);
1392
+ const line = this.lineAt(position);
1393
+ const frame = this.boxAt(position, undefined, true);
753
1394
  this.requestEdit({
754
- type: "add",
1395
+ type: line ? "insert" : "add",
755
1396
  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
- },
1397
+ lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id,
1398
+ position,
760
1399
  });
1400
+ this.markDropLine(undefined);
761
1401
  };
762
1402
  }
763
1403
  renderChecks() {
764
1404
  const checks = this.shadowRoot.querySelector("[part=checks]");
765
1405
  checks.replaceChildren();
766
- this.checks.forEach((check) => {
1406
+ this.allChecks.forEach((check) => {
767
1407
  const button = document.createElement("button");
768
1408
  button.type = "button";
769
1409
  button.textContent = check.message;
@@ -784,18 +1424,22 @@ export class ProcessModeler extends BaseElement {
784
1424
  if (!this.isRendered)
785
1425
  return;
786
1426
  this.shadowRoot.querySelectorAll("[data-box-id]").forEach((element) => {
787
- element.setAttribute("aria-pressed", String(element.dataset.boxId === this.selectedId));
1427
+ element.setAttribute("aria-current", String(this.selectedIds.has(element.dataset.boxId)));
788
1428
  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 ||
1429
+ element.dataset.invalid = String(this.allChecks.some((check) => check.boxId === box.id ||
790
1430
  (check.path &&
791
1431
  JSON.stringify(check.path) === JSON.stringify(box.path))));
792
1432
  });
793
1433
  const editor = this.shadowRoot.querySelector("[part=editor]");
794
1434
  const selected = this.selected;
1435
+ this.shadowRoot.querySelector("[part=palette]").hidden = false;
1436
+ this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selectedIds.size === 0;
1437
+ this.shadowRoot.querySelectorAll('[part=selection-toolbar] button').forEach((button, index) => button.disabled = this.locked || (index < 2 && this.selectedIds.size < 2));
1438
+ this.renderPane();
795
1439
  if (this.inspected === selected?.node &&
796
1440
  this.inspectedId === selected?.id &&
797
- editor.childNodes.length) {
798
- editor.querySelector("h3").textContent = selected?.title ?? "";
1441
+ editor.querySelector(`[part=inspector-heading]`)?.tagName === `H${this.headingLevel}`) {
1442
+ editor.querySelector("[part=inspector-heading]").textContent = selected?.title ?? "";
799
1443
  return;
800
1444
  }
801
1445
  this.cleanupInspector?.();
@@ -804,7 +1448,8 @@ export class ProcessModeler extends BaseElement {
804
1448
  this.inspected = selected?.node;
805
1449
  this.inspectedId = selected?.id;
806
1450
  if (selected) {
807
- const heading = document.createElement("h3");
1451
+ const heading = document.createElement(`h${this.headingLevel}`);
1452
+ heading.setAttribute("part", "inspector-heading");
808
1453
  heading.textContent = selected.title;
809
1454
  editor.append(heading);
810
1455
  this.cleanupInspector =
@@ -816,7 +1461,7 @@ export class ProcessModeler extends BaseElement {
816
1461
  button("undo").disabled = !this.history.canUndo || this.locked;
817
1462
  button("redo").disabled = !this.history.canRedo || this.locked;
818
1463
  button("tidy").disabled = this.locked;
819
- button("connect").disabled = !this.selected || this.locked;
1464
+ button("connect").disabled = !this.selected || this.locked || this.disableConnections;
820
1465
  button("delete").disabled = !this.selected || this.locked;
821
1466
  button("lock").setAttribute("aria-pressed", String(this.locked));
822
1467
  button("snap").setAttribute("aria-pressed", String(this.snapToGrid));
@@ -824,6 +1469,7 @@ export class ProcessModeler extends BaseElement {
824
1469
  paintViewport() {
825
1470
  this.shadowRoot.querySelector("[part=world]").style.transform =
826
1471
  `translate(${this.viewport.x}px,${this.viewport.y}px) scale(${this.viewport.zoom})`;
1472
+ this.shadowRoot.querySelectorAll('[part=port]').forEach(port => port.style.transform = `scale(${1 / this.viewport.zoom})`);
827
1473
  this.shadowRoot.querySelector("[data-command=reset]").textContent =
828
1474
  `${Math.round(this.viewport.zoom * 100)}%`;
829
1475
  const minimap = this.shadowRoot.querySelector("[part=minimap]");