@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.
- package/README.md +13 -13
- package/dist/components/actions/button.d.ts +1 -0
- package/dist/components/actions/button.js +13 -0
- package/dist/components/forms/checkbox.d.ts +1 -0
- package/dist/components/forms/checkbox.js +2 -0
- package/dist/components/forms/code-editor.d.ts +28 -3
- package/dist/components/forms/code-editor.js +71 -6
- package/dist/components/forms/combobox.js +7 -8
- package/dist/components/forms/number-input.d.ts +1 -0
- package/dist/components/forms/number-input.js +4 -1
- package/dist/components/forms/select.d.ts +1 -0
- package/dist/components/forms/select.js +3 -1
- package/dist/components/forms/text-area.js +5 -7
- package/dist/components/forms/text-field.d.ts +1 -0
- package/dist/components/forms/text-field.js +2 -0
- package/dist/components/navigation/accordion.d.ts +7 -0
- package/dist/components/navigation/accordion.js +31 -8
- package/dist/components/output/code-block.js +3 -1
- package/dist/components/overlays/drawer.js +2 -2
- package/dist/foundations/geometry/tokens.d.ts +2 -2
- package/dist/foundations/geometry/tokens.js +3 -3
- package/dist/foundations/icons/box-iconography.generated.d.ts +472 -1
- package/dist/foundations/icons/box-iconography.generated.js +473 -2
- package/dist/patterns/flow-builder/flow-builder.d.ts +5 -1
- package/dist/patterns/flow-builder/flow-builder.js +18 -7
- package/dist/patterns/flow-builder/primitives.d.ts +1 -0
- package/dist/patterns/flow-builder/primitives.js +11 -4
- package/dist/patterns/process-modeler/bridge.d.ts +10 -0
- package/dist/patterns/process-modeler/bridge.js +173 -0
- package/dist/patterns/process-modeler/design.d.ts +2 -0
- package/dist/patterns/process-modeler/design.js +288 -0
- package/dist/patterns/process-modeler/geometry.d.ts +20 -0
- package/dist/patterns/process-modeler/geometry.js +330 -0
- package/dist/patterns/process-modeler/index.d.ts +2 -0
- package/dist/patterns/process-modeler/index.js +2 -0
- package/dist/patterns/process-modeler/model.d.ts +106 -1
- package/dist/patterns/process-modeler/model.js +2 -2
- package/dist/patterns/process-modeler/process-modeler.d.ts +115 -3
- package/dist/patterns/process-modeler/process-modeler.js +1864 -128
- 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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
-
|
|
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(
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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.
|
|
549
|
+
button.dataset.detail = value;
|
|
369
550
|
button.textContent = label;
|
|
370
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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 ?
|
|
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
|
-
(
|
|
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
|
-
|
|
558
|
-
element.style.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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("
|
|
614
|
-
element.
|
|
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
|
-
|
|
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-
|
|
1705
|
+
element.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
|
|
621
1706
|
this.place(element, this.layoutValue.boxes[box.id]);
|
|
622
|
-
const
|
|
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
|
-
|
|
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 =
|
|
1720
|
+
description.textContent = descriptionText;
|
|
1721
|
+
if (this.detailValue === "technical")
|
|
1722
|
+
description.setAttribute("part", "technical-description");
|
|
636
1723
|
element.append(description);
|
|
637
1724
|
}
|
|
638
|
-
|
|
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
|
-
|
|
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
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const
|
|
660
|
-
const
|
|
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.
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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.
|
|
677
|
-
label.
|
|
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
|
-
|
|
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", () =>
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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:
|
|
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 ??
|
|
714
|
-
height: `${position.height ??
|
|
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.
|
|
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", () =>
|
|
738
|
-
|
|
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
|
-
|
|
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
|
-
|
|
757
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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
|
-
|
|
798
|
-
editor.querySelector(
|
|
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(
|
|
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 ??
|
|
840
|
-
rect.setAttribute("height", String(pos.height ??
|
|
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)
|