@unofficialbox/box-open-elements 0.32.0 → 0.34.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/native-types.d.ts +37 -0
- package/dist/patterns/process-modeler/bridge.js +1 -0
- package/dist/patterns/process-modeler/design.d.ts +1 -1
- package/dist/patterns/process-modeler/design.js +80 -16
- package/dist/patterns/process-modeler/model.d.ts +118 -1
- package/dist/patterns/process-modeler/process-modeler.d.ts +58 -1
- package/dist/patterns/process-modeler/process-modeler.js +906 -144
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BaseElement } from "../../core/index.js";
|
|
2
|
+
import { iconPlus, iconXBatsu } from "../../foundations/icons/glyphs/index.js";
|
|
2
3
|
import { announce } from "../../foundations/a11y/index.js";
|
|
3
4
|
import { dismissModal, dismissPopover, promoteModal, promotePopover, trackAnchor } from "../../foundations/overlay/index.js";
|
|
4
5
|
import { KindPicker } from "../flow-builder/primitives.js";
|
|
@@ -7,6 +8,20 @@ import { restoreProcessPositions, snapshotProcessPositions, snapshotProcessProje
|
|
|
7
8
|
import { processModelerDesign } from "./design.js";
|
|
8
9
|
import { completeLayout, ProcessHistory, validateProjection, } from "./model.js";
|
|
9
10
|
const svgElement = (tag) => document.createElementNS("http://www.w3.org/2000/svg", tag);
|
|
11
|
+
const variableLabel = (text) => {
|
|
12
|
+
const label = document.createElement('label'), name = document.createElement('span');
|
|
13
|
+
name.setAttribute('part', 'sr-only');
|
|
14
|
+
name.textContent = text;
|
|
15
|
+
label.append(name);
|
|
16
|
+
return label;
|
|
17
|
+
};
|
|
18
|
+
const variableGlyph = (add = false) => {
|
|
19
|
+
const template = document.createElement('template');
|
|
20
|
+
template.innerHTML = add ? iconPlus : iconXBatsu;
|
|
21
|
+
const glyph = template.content.firstElementChild;
|
|
22
|
+
glyph.setAttribute('part', 'variable-icon');
|
|
23
|
+
return glyph;
|
|
24
|
+
};
|
|
10
25
|
const checkGlyph = (success = false) => {
|
|
11
26
|
const icon = svgElement('svg');
|
|
12
27
|
icon.setAttribute('part', 'check-icon');
|
|
@@ -53,6 +68,9 @@ export class ProcessModeler extends BaseElement {
|
|
|
53
68
|
showLastRunValue = false;
|
|
54
69
|
selectedId = null;
|
|
55
70
|
selectedLineId = null;
|
|
71
|
+
selectedLineIds = new Set();
|
|
72
|
+
selectedNoteIds = new Set();
|
|
73
|
+
graphSelection = false;
|
|
56
74
|
connecting = null;
|
|
57
75
|
checks = [];
|
|
58
76
|
renderer;
|
|
@@ -60,6 +78,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
60
78
|
inspected;
|
|
61
79
|
inspectedId;
|
|
62
80
|
inspectedControls = "";
|
|
81
|
+
localEchoRevision = 0;
|
|
82
|
+
pendingLocalRename;
|
|
63
83
|
historyValue = new ProcessHistory();
|
|
64
84
|
viewport = { x: 0, y: 0, zoom: 1 };
|
|
65
85
|
pointers = new Map();
|
|
@@ -76,6 +96,33 @@ export class ProcessModeler extends BaseElement {
|
|
|
76
96
|
selectedIds = new Set();
|
|
77
97
|
clipboard = null;
|
|
78
98
|
clipboardSession = 0;
|
|
99
|
+
layoutEditSession = 0;
|
|
100
|
+
acceptanceNotifications = 0;
|
|
101
|
+
deferredNotifications = [];
|
|
102
|
+
notify(name, detail) {
|
|
103
|
+
if (this.acceptanceNotifications)
|
|
104
|
+
this.deferredNotifications.push({ name, detail, session: this.layoutEditSession });
|
|
105
|
+
else
|
|
106
|
+
emit(this, name, detail);
|
|
107
|
+
}
|
|
108
|
+
flushAcceptanceNotifications() {
|
|
109
|
+
if (this.acceptanceNotifications)
|
|
110
|
+
return;
|
|
111
|
+
this.acceptanceNotifications++;
|
|
112
|
+
try {
|
|
113
|
+
// Observer edits append behind the complete accepted batch, rather than
|
|
114
|
+
// publishing newer state before older queued persistence notifications.
|
|
115
|
+
while (this.deferredNotifications.length) {
|
|
116
|
+
const notification = this.deferredNotifications.shift();
|
|
117
|
+
if (notification.session !== this.layoutEditSession)
|
|
118
|
+
continue;
|
|
119
|
+
emit(this, notification.name, notification.detail);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
this.acceptanceNotifications--;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
79
126
|
restoringBoxFocus = false;
|
|
80
127
|
selectionToolbarKey = "";
|
|
81
128
|
sectionSequence = 0;
|
|
@@ -167,7 +214,16 @@ export class ProcessModeler extends BaseElement {
|
|
|
167
214
|
};
|
|
168
215
|
}
|
|
169
216
|
load(document, options = {}) {
|
|
217
|
+
this.pendingLocalRename = undefined;
|
|
218
|
+
const session = ++this.layoutEditSession;
|
|
219
|
+
this.insertion = undefined;
|
|
170
220
|
this.cancelPalettePointer();
|
|
221
|
+
this.closeKeyboardChooser();
|
|
222
|
+
const chooser = this.shadowRoot?.querySelector('[part=insert-chooser]');
|
|
223
|
+
if (chooser)
|
|
224
|
+
dismissModal(chooser);
|
|
225
|
+
if (session !== this.layoutEditSession)
|
|
226
|
+
return;
|
|
171
227
|
const projection = this.model.project(document);
|
|
172
228
|
validateProjection(projection);
|
|
173
229
|
this.documentValue = document;
|
|
@@ -177,6 +233,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
177
233
|
this.lastReadableProjection = "";
|
|
178
234
|
this.readableValue = null;
|
|
179
235
|
this.layoutValue = restoreProcessPositions(projection, options.positions ?? []);
|
|
236
|
+
this.clearMixedSelection();
|
|
180
237
|
this.history.clear();
|
|
181
238
|
this.selectedId = null;
|
|
182
239
|
this.selectedLineId = null;
|
|
@@ -192,15 +249,54 @@ export class ProcessModeler extends BaseElement {
|
|
|
192
249
|
validateProjection(this.projection);
|
|
193
250
|
}
|
|
194
251
|
const next = new Set(ids.filter(id => this.projection.boxes.some(box => box.id === id)));
|
|
195
|
-
if (next.size === this.selectedIds.size && [...next].every(id => this.selectedIds.has(id)))
|
|
252
|
+
if (!this.graphSelection && !this.selectedLineIds.size && !this.selectedNoteIds.size && next.size === this.selectedIds.size && [...next].every(id => this.selectedIds.has(id)))
|
|
196
253
|
return;
|
|
254
|
+
this.clearMixedSelection();
|
|
197
255
|
this.selectedIds = next;
|
|
198
256
|
this.selectedId = this.selectedIds.values().next().value ?? null;
|
|
199
257
|
this.selectedLineId = null;
|
|
200
258
|
this.renderSelection();
|
|
201
259
|
if (this.isRendered)
|
|
202
260
|
this.updateToolbar();
|
|
203
|
-
|
|
261
|
+
this.notify("selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, ...(this.graphSelection ? { selection: this.selection } : {}) });
|
|
262
|
+
}
|
|
263
|
+
/** Detached mixed selection. Invalid and duplicate IDs are ignored by selectItems. */
|
|
264
|
+
get selection() {
|
|
265
|
+
return [...[...this.selectedIds].map(id => ({ type: 'box', id })),
|
|
266
|
+
...[...this.selectedLineIds].map(id => ({ type: 'line', id })),
|
|
267
|
+
...[...this.selectedNoteIds].map(id => ({ type: 'note', id }))];
|
|
268
|
+
}
|
|
269
|
+
selectItems(items) {
|
|
270
|
+
if (!this.isRendered && this.documentValue !== undefined) {
|
|
271
|
+
this.projection = this.model.project(this.documentValue);
|
|
272
|
+
validateProjection(this.projection);
|
|
273
|
+
}
|
|
274
|
+
const boxes = new Set(), lines = new Set(), notes = new Set();
|
|
275
|
+
for (const item of items) {
|
|
276
|
+
if (item.type === 'box' && this.projection.boxes.some(box => box.id === item.id))
|
|
277
|
+
boxes.add(item.id);
|
|
278
|
+
if (item.type === 'line' && this.projection.lines.some(line => line.id === item.id))
|
|
279
|
+
lines.add(item.id);
|
|
280
|
+
if (item.type === 'note' && this.layoutValue.notes?.some(note => note.id === item.id))
|
|
281
|
+
notes.add(item.id);
|
|
282
|
+
}
|
|
283
|
+
const before = JSON.stringify(this.selection);
|
|
284
|
+
this.selectedIds = boxes;
|
|
285
|
+
this.selectedLineIds = lines;
|
|
286
|
+
this.selectedNoteIds = notes;
|
|
287
|
+
this.selectedId = boxes.values().next().value ?? null;
|
|
288
|
+
this.selectedLineId = !boxes.size && !notes.size && lines.size === 1 ? lines.values().next().value : null;
|
|
289
|
+
this.graphSelection = true;
|
|
290
|
+
this.renderSelection();
|
|
291
|
+
if (this.isRendered)
|
|
292
|
+
this.updateToolbar();
|
|
293
|
+
if (before !== JSON.stringify(this.selection))
|
|
294
|
+
this.notify('selection-changed', { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, selection: this.selection });
|
|
295
|
+
}
|
|
296
|
+
clearMixedSelection() {
|
|
297
|
+
this.selectedLineIds.clear();
|
|
298
|
+
this.selectedNoteIds.clear();
|
|
299
|
+
this.graphSelection = false;
|
|
204
300
|
}
|
|
205
301
|
get headingLevel() { const level = Number(this.getAttribute("heading-level") ?? 2); return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2; }
|
|
206
302
|
set headingLevel(value) { this.setAttribute("heading-level", String(value)); }
|
|
@@ -224,8 +320,13 @@ export class ProcessModeler extends BaseElement {
|
|
|
224
320
|
this.refresh();
|
|
225
321
|
}
|
|
226
322
|
set document(value) {
|
|
323
|
+
this.localEchoRevision++;
|
|
324
|
+
const rename = this.pendingLocalRename;
|
|
227
325
|
this.documentValue = value;
|
|
228
326
|
this.refresh();
|
|
327
|
+
// One explicit echo settles this intent, including a rejected unchanged echo.
|
|
328
|
+
if (rename === this.pendingLocalRename && rename && this.localEchoRevision > rename.echoRevision)
|
|
329
|
+
this.pendingLocalRename = undefined;
|
|
229
330
|
}
|
|
230
331
|
get model() {
|
|
231
332
|
return this.modelValue;
|
|
@@ -293,6 +394,12 @@ export class ProcessModeler extends BaseElement {
|
|
|
293
394
|
this.refresh();
|
|
294
395
|
}
|
|
295
396
|
disconnectedCallback() {
|
|
397
|
+
const menu = this.shadowRoot?.querySelector('[part=view-menu]');
|
|
398
|
+
if (menu)
|
|
399
|
+
menu.open = false;
|
|
400
|
+
this.viewMenuDocument?.removeEventListener('pointerdown', this.dismissViewMenuOutside, true);
|
|
401
|
+
this.viewMenuDocument = undefined;
|
|
402
|
+
this.layoutEditSession++;
|
|
296
403
|
this.resizeObserver?.disconnect();
|
|
297
404
|
this.clearClipboard();
|
|
298
405
|
this.cancelPalettePointer();
|
|
@@ -312,7 +419,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
312
419
|
this.marquee = undefined;
|
|
313
420
|
this.segmentDrag = undefined;
|
|
314
421
|
}
|
|
315
|
-
connectedCallback() { super.connectedCallback(); this.resizeObserver?.observe(this); }
|
|
422
|
+
connectedCallback() { super.connectedCallback(); this.resizeObserver?.observe(this); this.syncViewMenuDismissal(); }
|
|
316
423
|
refresh() {
|
|
317
424
|
if (this.isRendered)
|
|
318
425
|
this.update();
|
|
@@ -327,8 +434,9 @@ export class ProcessModeler extends BaseElement {
|
|
|
327
434
|
}
|
|
328
435
|
if (id !== null && !this.projection.boxes.some((box) => box.id === id))
|
|
329
436
|
return;
|
|
330
|
-
if (id === this.selectedId && this.selectedIds.size === (id ? 1 : 0) && !this.selectedLineId)
|
|
437
|
+
if (id === this.selectedId && this.selectedIds.size === (id ? 1 : 0) && !this.selectedLineId && !this.graphSelection)
|
|
331
438
|
return;
|
|
439
|
+
this.clearMixedSelection();
|
|
332
440
|
this.selectedId = id;
|
|
333
441
|
this.selectedIds = new Set(id ? [id] : []);
|
|
334
442
|
this.selectedLineId = null;
|
|
@@ -336,18 +444,20 @@ export class ProcessModeler extends BaseElement {
|
|
|
336
444
|
if (this.isRendered)
|
|
337
445
|
this.updateToolbar();
|
|
338
446
|
if (notify)
|
|
339
|
-
|
|
447
|
+
this.notify("selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, ...(this.graphSelection ? { selection: this.selection } : {}) });
|
|
340
448
|
}
|
|
341
449
|
selectLine(id) {
|
|
342
450
|
if (id !== null && !this.projection.lines.some(line => line.id === id))
|
|
343
451
|
return;
|
|
344
|
-
if (this.selectedLineId === id && !this.selectedIds.size)
|
|
452
|
+
if (!this.graphSelection && this.selectedLineId === id && !this.selectedIds.size)
|
|
345
453
|
return;
|
|
454
|
+
this.clearMixedSelection();
|
|
455
|
+
this.selectedLineIds = new Set(id ? [id] : []);
|
|
346
456
|
this.selectedLineId = id;
|
|
347
457
|
this.selectedId = null;
|
|
348
458
|
this.selectedIds.clear();
|
|
349
459
|
this.refresh();
|
|
350
|
-
|
|
460
|
+
this.notify('selection-changed', { box: null, boxes: [], line: this.selectedLine, path: null });
|
|
351
461
|
}
|
|
352
462
|
setValidation(checks) {
|
|
353
463
|
this.checks = checks;
|
|
@@ -373,67 +483,136 @@ export class ProcessModeler extends BaseElement {
|
|
|
373
483
|
requestEdit(edit) {
|
|
374
484
|
if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect" || edit.type === 'reattach')))
|
|
375
485
|
return;
|
|
486
|
+
if (edit.type === 'delete-selection' && (!edit.selection?.length || edit.selection.some(item => !['box', 'line', 'note'].includes(item.type) || typeof item.id !== 'string' || !item.id))) {
|
|
487
|
+
this.setStatus('The selected graph items are invalid', true);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (edit.selection)
|
|
491
|
+
edit = { ...edit, selection: Object.freeze(edit.selection.map(item => Object.freeze({ type: item.type, id: item.id }))) };
|
|
492
|
+
if (this.disableConnections && edit.type === 'delete-selection' && edit.selection?.some(item => item.type === 'line')) {
|
|
493
|
+
this.setStatus('Deleting selected lines is disabled');
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const placement = edit.placement;
|
|
497
|
+
if (placement) {
|
|
498
|
+
const source = placement.source;
|
|
499
|
+
let normalized;
|
|
500
|
+
if (edit.type === 'add' || edit.type === 'insert') {
|
|
501
|
+
if (source === 'next')
|
|
502
|
+
normalized = { source };
|
|
503
|
+
else if (source === 'direction') {
|
|
504
|
+
const side = placement.side;
|
|
505
|
+
if (['north', 'east', 'south', 'west'].includes(side))
|
|
506
|
+
normalized = { source, side };
|
|
507
|
+
}
|
|
508
|
+
else if (source === 'point' || source === 'line') {
|
|
509
|
+
const { x, y } = placement.center ?? {};
|
|
510
|
+
if (Number.isFinite(x) && Number.isFinite(y))
|
|
511
|
+
normalized = { source, center: { x, y } };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (!normalized) {
|
|
515
|
+
this.setStatus('The insertion placement is invalid', true);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
edit = { ...edit, placement: normalized };
|
|
519
|
+
}
|
|
376
520
|
const problem = this.connectionProblem(edit);
|
|
377
521
|
if (problem) {
|
|
378
522
|
this.setStatus(problem, true);
|
|
379
523
|
return;
|
|
380
524
|
}
|
|
381
525
|
const request = { ...edit, accept: this.editAcceptance(edit) };
|
|
382
|
-
|
|
526
|
+
if (edit.type !== 'delete-selection') {
|
|
527
|
+
emit(this, 'process-edit-request', request);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
// A synchronous host echo can prune selected lines before accept records
|
|
531
|
+
// history. Keep those observers behind the complete atomic transaction.
|
|
532
|
+
this.acceptanceNotifications++;
|
|
533
|
+
try {
|
|
534
|
+
emit(this, 'process-edit-request', request);
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
this.acceptanceNotifications--;
|
|
538
|
+
this.flushAcceptanceNotifications();
|
|
539
|
+
}
|
|
383
540
|
}
|
|
384
541
|
editAcceptance(edit) {
|
|
385
542
|
const before = this.layout;
|
|
386
543
|
const beforeSelection = [...this.selectedIds];
|
|
544
|
+
const beforeItems = this.selection;
|
|
387
545
|
const previousIds = new Set(this.projection.boxes.map((box) => box.id));
|
|
546
|
+
const session = this.layoutEditSession;
|
|
388
547
|
let accepted = false;
|
|
389
548
|
return (command) => {
|
|
390
|
-
if (accepted)
|
|
549
|
+
if (accepted || session !== this.layoutEditSession)
|
|
391
550
|
return;
|
|
392
551
|
accepted = true;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (!reflowed &&
|
|
402
|
-
!command.layout &&
|
|
403
|
-
edit.position &&
|
|
404
|
-
Number.isFinite(edit.position.x) &&
|
|
405
|
-
Number.isFinite(edit.position.y)) {
|
|
406
|
-
const added = this.projection.boxes.find(box => box.id === edit.boxId) ?? this.projection.boxes.find((box) => !previousIds.has(box.id));
|
|
407
|
-
if (added)
|
|
408
|
-
this.layoutValue = this.translatedLayout(added.id, edit.position.x, edit.position.y);
|
|
409
|
-
}
|
|
410
|
-
const after = this.layout;
|
|
411
|
-
const acceptedSelection = command.selectionIds ? [...command.selectionIds] : undefined;
|
|
412
|
-
const restore = (layout) => {
|
|
413
|
-
this.layoutValue = structuredClone(layout);
|
|
552
|
+
// Projection/readability/selection observers may synchronously edit or
|
|
553
|
+
// replace the document. Publish only after the whole history entry exists.
|
|
554
|
+
this.acceptanceNotifications++;
|
|
555
|
+
try {
|
|
556
|
+
if (command.layout)
|
|
557
|
+
this.layoutValue = structuredClone(command.layout);
|
|
558
|
+
if (edit.type === 'reset-line' && edit.lineId)
|
|
559
|
+
delete this.layoutValue.lines?.[edit.lineId];
|
|
414
560
|
this.refresh();
|
|
415
|
-
this.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
561
|
+
if (session !== this.layoutEditSession)
|
|
562
|
+
return;
|
|
563
|
+
const inserted = edit.type === 'insert' && !edit.boxId && !command.layout
|
|
564
|
+
? this.projection.boxes.find(box => !previousIds.has(box.id)) : undefined;
|
|
565
|
+
const reflowed = inserted ? this.reflowStraightInsert(edit, inserted.id, before) : false;
|
|
566
|
+
if (!reflowed &&
|
|
567
|
+
!command.layout &&
|
|
568
|
+
edit.position &&
|
|
569
|
+
Number.isFinite(edit.position.x) &&
|
|
570
|
+
Number.isFinite(edit.position.y)) {
|
|
571
|
+
const added = this.projection.boxes.find(box => box.id === edit.boxId) ?? this.projection.boxes.find((box) => !previousIds.has(box.id));
|
|
572
|
+
if (added)
|
|
573
|
+
this.layoutValue = this.translatedLayout(added.id, edit.position.x, edit.position.y);
|
|
574
|
+
}
|
|
575
|
+
if (session !== this.layoutEditSession)
|
|
576
|
+
return;
|
|
577
|
+
const after = this.layout;
|
|
578
|
+
const acceptedItems = command.selection ? command.selection.map(item => ({ type: item.type, id: item.id })) : undefined;
|
|
579
|
+
const acceptedSelection = command.selectionIds ? [...command.selectionIds] : undefined;
|
|
580
|
+
const restore = (layout) => {
|
|
581
|
+
this.layoutValue = structuredClone(layout);
|
|
582
|
+
this.refresh();
|
|
583
|
+
this.emitPositions();
|
|
584
|
+
};
|
|
585
|
+
this.history.record({
|
|
586
|
+
undo: () => {
|
|
587
|
+
command.undo();
|
|
588
|
+
restore(before);
|
|
589
|
+
if (acceptedItems)
|
|
590
|
+
this.selectItems(beforeItems);
|
|
591
|
+
else if (acceptedSelection)
|
|
592
|
+
this.selectMany(beforeSelection);
|
|
593
|
+
},
|
|
594
|
+
redo: () => {
|
|
595
|
+
command.redo();
|
|
596
|
+
restore(after);
|
|
597
|
+
if (acceptedItems)
|
|
598
|
+
this.selectItems(acceptedItems);
|
|
599
|
+
else if (acceptedSelection)
|
|
600
|
+
this.selectMany(acceptedSelection);
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
if (acceptedItems)
|
|
604
|
+
this.selectItems(acceptedItems);
|
|
605
|
+
else if (acceptedSelection)
|
|
606
|
+
this.selectMany(acceptedSelection);
|
|
607
|
+
this.refresh();
|
|
608
|
+
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
609
|
+
this.emitPositions();
|
|
610
|
+
announce("Process updated", "polite", this.ownerDocument);
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
this.acceptanceNotifications--;
|
|
614
|
+
this.flushAcceptanceNotifications();
|
|
615
|
+
}
|
|
437
616
|
};
|
|
438
617
|
}
|
|
439
618
|
undo() {
|
|
@@ -448,6 +627,53 @@ export class ProcessModeler extends BaseElement {
|
|
|
448
627
|
this.history.redo();
|
|
449
628
|
this.refresh();
|
|
450
629
|
}
|
|
630
|
+
requestLayoutEdit(type, sourceIds, title, onAccepted) {
|
|
631
|
+
const capability = this.model.layoutEdit;
|
|
632
|
+
if (!capability || this.documentValue === undefined)
|
|
633
|
+
return false;
|
|
634
|
+
if (this.locked)
|
|
635
|
+
return true;
|
|
636
|
+
if (!this.isRendered) {
|
|
637
|
+
this.projection = this.model.project(this.documentValue);
|
|
638
|
+
validateProjection(this.projection);
|
|
639
|
+
}
|
|
640
|
+
const session = this.layoutEditSession;
|
|
641
|
+
const accept = this.editAcceptance({ type });
|
|
642
|
+
let active = true, settled = false, acceptedLayout = false;
|
|
643
|
+
const valid = () => active && !settled && session === this.layoutEditSession && !this.locked;
|
|
644
|
+
const request = {
|
|
645
|
+
type, sourceIds: sourceIds ? Object.freeze([...sourceIds]) : null, ...(title !== undefined ? { title } : {}), layout: this.layout,
|
|
646
|
+
accept: command => {
|
|
647
|
+
if (!valid())
|
|
648
|
+
return;
|
|
649
|
+
settled = true;
|
|
650
|
+
if (!command.layout) {
|
|
651
|
+
this.setStatus('The host must supply the complete accepted layout', true);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
accept(command);
|
|
655
|
+
acceptedLayout = session === this.layoutEditSession;
|
|
656
|
+
},
|
|
657
|
+
refuse: message => { if (!valid())
|
|
658
|
+
return; settled = true; this.setStatus(message ?? 'The host refused this layout edit', true); },
|
|
659
|
+
};
|
|
660
|
+
// Hosts may assign a fresh document before accepting the request. Its
|
|
661
|
+
// notifications must follow the same atomic history boundary.
|
|
662
|
+
this.acceptanceNotifications++;
|
|
663
|
+
try {
|
|
664
|
+
capability.call(this.model, this.documentValue, request, this.projection);
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
active = false;
|
|
668
|
+
this.acceptanceNotifications--;
|
|
669
|
+
this.flushAcceptanceNotifications();
|
|
670
|
+
}
|
|
671
|
+
if (acceptedLayout && session === this.layoutEditSession)
|
|
672
|
+
onAccepted?.();
|
|
673
|
+
if (!settled && session === this.layoutEditSession)
|
|
674
|
+
this.setStatus('This layout edit was not accepted by the host', true);
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
451
677
|
commitLayout(next) {
|
|
452
678
|
const before = this.layout;
|
|
453
679
|
const after = structuredClone(next);
|
|
@@ -476,8 +702,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
476
702
|
this.commitLayout(next);
|
|
477
703
|
}
|
|
478
704
|
emitPositions() {
|
|
479
|
-
|
|
480
|
-
|
|
705
|
+
this.notify("layout-changed", { layout: this.layout });
|
|
706
|
+
this.notify("positions-changed", { positions: this.positions, version: this.versionValue });
|
|
481
707
|
}
|
|
482
708
|
translatedLayout(id, x, y) {
|
|
483
709
|
const snap = (n) => (this.snapToGrid ? Math.round(n / 16) * 16 : n);
|
|
@@ -543,6 +769,9 @@ export class ProcessModeler extends BaseElement {
|
|
|
543
769
|
tidy() {
|
|
544
770
|
if (this.locked)
|
|
545
771
|
return;
|
|
772
|
+
if (this.requestLayoutEdit('tidy', null, undefined, () => { if (this.isRendered)
|
|
773
|
+
this.fitTo(this.narrowValue ? 0.55 : 0.7); }))
|
|
774
|
+
return;
|
|
546
775
|
const before = this.layout.boxes;
|
|
547
776
|
this.commitLayout(this.arrangedLayout({ ...this.layout, boxes: {} }));
|
|
548
777
|
if (!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
|
@@ -689,13 +918,13 @@ export class ProcessModeler extends BaseElement {
|
|
|
689
918
|
chooser.append(picker);
|
|
690
919
|
this.shadowRoot.append(chooser);
|
|
691
920
|
picker.addEventListener("kind-pick", event => {
|
|
692
|
-
const insertion = this.insertion;
|
|
693
|
-
dismissModal(chooser);
|
|
921
|
+
const insertion = this.insertion, session = this.layoutEditSession;
|
|
694
922
|
this.insertion = undefined;
|
|
695
|
-
|
|
923
|
+
dismissModal(chooser);
|
|
924
|
+
if (insertion && session === this.layoutEditSession && this.isConnected)
|
|
696
925
|
this.requestEdit({ ...insertion, kind: event.detail.kind });
|
|
697
926
|
});
|
|
698
|
-
picker.addEventListener("picker-cancel", () => {
|
|
927
|
+
picker.addEventListener("picker-cancel", () => { this.insertion = undefined; dismissModal(chooser); });
|
|
699
928
|
const keyboardChooser = document.createElement('div');
|
|
700
929
|
keyboardChooser.setAttribute('part', 'keyboard-chooser');
|
|
701
930
|
keyboardChooser.setAttribute('popover', 'manual');
|
|
@@ -710,18 +939,35 @@ export class ProcessModeler extends BaseElement {
|
|
|
710
939
|
keyboardChooser.append(keyboardTitle, keyboardPicker);
|
|
711
940
|
this.shadowRoot.append(keyboardChooser);
|
|
712
941
|
keyboardPicker.addEventListener('kind-pick', event => {
|
|
713
|
-
const edit = this.keyboardInsertion;
|
|
942
|
+
const edit = this.keyboardInsertion, session = this.layoutEditSession;
|
|
714
943
|
this.closeKeyboardChooser(true);
|
|
944
|
+
if (session !== this.layoutEditSession || !this.isConnected)
|
|
945
|
+
return;
|
|
715
946
|
// Keep a stable step focused while the host applies and accepts the edit.
|
|
716
947
|
// World refreshes preserve step focus, including deferred acceptance.
|
|
717
948
|
if (edit?.from)
|
|
718
949
|
this.focusBox(edit.from);
|
|
719
|
-
if (edit)
|
|
950
|
+
if (edit && session === this.layoutEditSession && this.isConnected)
|
|
720
951
|
this.requestEdit({ ...edit, kind: event.detail.kind });
|
|
721
952
|
});
|
|
722
953
|
keyboardPicker.addEventListener('picker-cancel', () => this.closeKeyboardChooser(true));
|
|
723
954
|
this.setupPanes();
|
|
724
955
|
}
|
|
956
|
+
viewMenuDocument;
|
|
957
|
+
syncViewMenuDismissal() {
|
|
958
|
+
const menu = this.shadowRoot?.querySelector('[part=view-menu]');
|
|
959
|
+
const target = this.isConnected && menu?.open ? this.ownerDocument : undefined;
|
|
960
|
+
if (this.viewMenuDocument === target)
|
|
961
|
+
return;
|
|
962
|
+
this.viewMenuDocument?.removeEventListener('pointerdown', this.dismissViewMenuOutside, true);
|
|
963
|
+
this.viewMenuDocument = target;
|
|
964
|
+
target?.addEventListener('pointerdown', this.dismissViewMenuOutside, true);
|
|
965
|
+
}
|
|
966
|
+
dismissViewMenuOutside = (event) => {
|
|
967
|
+
const menu = this.shadowRoot?.querySelector('[part=view-menu]');
|
|
968
|
+
if (menu?.open && !event.composedPath().includes(menu))
|
|
969
|
+
menu.open = false;
|
|
970
|
+
};
|
|
725
971
|
setupPanes() {
|
|
726
972
|
const root = this.shadowRoot;
|
|
727
973
|
const toolbar = root.querySelector('[part=toolbar]');
|
|
@@ -754,27 +1000,99 @@ export class ProcessModeler extends BaseElement {
|
|
|
754
1000
|
viewMenu.append(summary);
|
|
755
1001
|
const viewOptions = document.createElement('div');
|
|
756
1002
|
viewOptions.setAttribute('part', 'view-menu-options');
|
|
1003
|
+
viewOptions.setAttribute('role', 'menu');
|
|
1004
|
+
viewOptions.setAttribute('aria-label', 'Diagram view');
|
|
757
1005
|
viewMenu.append(viewOptions);
|
|
1006
|
+
summary.setAttribute('aria-haspopup', 'menu');
|
|
1007
|
+
viewMenu.addEventListener('toggle', () => { summary.setAttribute('aria-expanded', String(viewMenu.open)); this.syncViewMenuDismissal(); });
|
|
1008
|
+
const closeViewMenu = () => { const session = this.layoutEditSession; viewMenu.open = false; summary.focus({ preventScroll: true }); return session === this.layoutEditSession && this.isConnected; };
|
|
1009
|
+
viewMenu.addEventListener('keydown', event => {
|
|
1010
|
+
const items = Array.from(viewOptions.querySelectorAll('button')).filter(item => !item.hidden && !item.disabled);
|
|
1011
|
+
if (event.key === 'Tab') {
|
|
1012
|
+
if (viewMenu.open && root.activeElement === summary && !event.shiftKey && items.length) {
|
|
1013
|
+
event.preventDefault();
|
|
1014
|
+
items[0].focus();
|
|
1015
|
+
}
|
|
1016
|
+
else
|
|
1017
|
+
viewMenu.open = false;
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) || !items.length)
|
|
1021
|
+
return;
|
|
1022
|
+
event.preventDefault();
|
|
1023
|
+
viewMenu.open = true;
|
|
1024
|
+
const index = items.indexOf(root.activeElement);
|
|
1025
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : event.key === 'ArrowDown' ? (index + 1) % items.length : (index < 0 ? items.length - 1 : (index + items.length - 1) % items.length);
|
|
1026
|
+
items[next].focus();
|
|
1027
|
+
});
|
|
1028
|
+
root.addEventListener('pointerdown', event => { if (!event.composedPath().includes(viewMenu))
|
|
1029
|
+
viewMenu.open = false; });
|
|
758
1030
|
for (const [value, label] of [["business", "Business view"], ["technical", "Technical view"]]) {
|
|
759
1031
|
const button = document.createElement("button");
|
|
760
1032
|
button.type = "button";
|
|
761
1033
|
button.dataset.detail = value;
|
|
762
1034
|
button.textContent = label;
|
|
763
|
-
button.
|
|
1035
|
+
button.setAttribute('role', 'menuitemradio');
|
|
1036
|
+
button.onclick = () => { if (closeViewMenu())
|
|
1037
|
+
this.detail = value; };
|
|
764
1038
|
viewOptions.append(button);
|
|
765
1039
|
}
|
|
766
1040
|
const mobileRun = document.createElement('button');
|
|
767
1041
|
mobileRun.type = 'button';
|
|
768
1042
|
mobileRun.dataset.viewOption = 'last-run';
|
|
769
|
-
mobileRun.textContent = '
|
|
770
|
-
mobileRun.
|
|
1043
|
+
mobileRun.textContent = 'Show last run';
|
|
1044
|
+
mobileRun.setAttribute('role', 'menuitem');
|
|
1045
|
+
mobileRun.onclick = () => { if (closeViewMenu())
|
|
1046
|
+
this.showLastRun = !this.showLastRun; };
|
|
771
1047
|
viewOptions.append(mobileRun);
|
|
772
1048
|
const mobileChecks = document.createElement('button');
|
|
773
1049
|
mobileChecks.type = 'button';
|
|
774
|
-
mobileChecks.
|
|
775
|
-
mobileChecks.
|
|
1050
|
+
mobileChecks.dataset.viewOption = 'checks';
|
|
1051
|
+
mobileChecks.textContent = 'Checks: ready to run';
|
|
1052
|
+
mobileChecks.setAttribute('role', 'menuitem');
|
|
1053
|
+
mobileChecks.onclick = () => { if (closeViewMenu())
|
|
1054
|
+
checksStatus.click(); };
|
|
776
1055
|
viewOptions.append(mobileChecks);
|
|
777
|
-
|
|
1056
|
+
const separator = document.createElement('div');
|
|
1057
|
+
separator.setAttribute('role', 'separator');
|
|
1058
|
+
viewOptions.append(separator);
|
|
1059
|
+
for (const [command, label, hint] of [['tidy', 'Tidy up', ''], ['undo', 'Undo', 'Ctrl Z'], ['redo', 'Redo', 'Shift Ctrl Z']]) {
|
|
1060
|
+
const item = document.createElement('button');
|
|
1061
|
+
item.type = 'button';
|
|
1062
|
+
item.dataset.viewOption = command;
|
|
1063
|
+
item.setAttribute('role', 'menuitem');
|
|
1064
|
+
const text = document.createElement('span');
|
|
1065
|
+
text.textContent = label;
|
|
1066
|
+
item.append(text);
|
|
1067
|
+
if (hint) {
|
|
1068
|
+
const key = document.createElement('small');
|
|
1069
|
+
key.textContent = hint;
|
|
1070
|
+
item.append(key);
|
|
1071
|
+
}
|
|
1072
|
+
item.onclick = () => { if (!closeViewMenu())
|
|
1073
|
+
return; if (command === 'tidy')
|
|
1074
|
+
this.tidy();
|
|
1075
|
+
else if (command === 'undo')
|
|
1076
|
+
this.undo();
|
|
1077
|
+
else
|
|
1078
|
+
this.redo(); };
|
|
1079
|
+
viewOptions.append(item);
|
|
1080
|
+
}
|
|
1081
|
+
const spacer = document.createElement('div');
|
|
1082
|
+
spacer.setAttribute('part', 'toolbar-document');
|
|
1083
|
+
spacer.setAttribute('aria-hidden', 'true');
|
|
1084
|
+
const spacerTitle = document.createElement('strong');
|
|
1085
|
+
spacerTitle.setAttribute('part', 'toolbar-title');
|
|
1086
|
+
spacerTitle.textContent = this.processTitleValue;
|
|
1087
|
+
const saved = document.createElement('span');
|
|
1088
|
+
saved.textContent = 'Saved';
|
|
1089
|
+
spacer.append(spacerTitle, saved);
|
|
1090
|
+
toolbar.prepend(spacer, viewSwitch, runToggle, viewMenu);
|
|
1091
|
+
const actions = document.createElement('div');
|
|
1092
|
+
actions.setAttribute('part', 'toolbar-actions');
|
|
1093
|
+
for (const command of ['undo', 'redo', 'tidy'])
|
|
1094
|
+
actions.append(toolbar.querySelector(`[data-command=${command}]`));
|
|
1095
|
+
toolbar.append(actions);
|
|
778
1096
|
const layout = root.querySelector('[part=layout]');
|
|
779
1097
|
const scrim = document.createElement('div');
|
|
780
1098
|
scrim.setAttribute('part', 'pane-scrim');
|
|
@@ -854,7 +1172,13 @@ export class ProcessModeler extends BaseElement {
|
|
|
854
1172
|
trigger.setAttribute("aria-label", part === "palette" ? "Add building blocks" : `Open ${label.toLowerCase()}`);
|
|
855
1173
|
trigger.setAttribute('aria-expanded', 'false');
|
|
856
1174
|
trigger.onclick = () => this.openDrawer(part);
|
|
857
|
-
|
|
1175
|
+
if (part === 'palette') {
|
|
1176
|
+
trigger.textContent = '';
|
|
1177
|
+
trigger.append(variableGlyph(true));
|
|
1178
|
+
toolbar.prepend(trigger);
|
|
1179
|
+
}
|
|
1180
|
+
else
|
|
1181
|
+
actions.append(trigger);
|
|
858
1182
|
}
|
|
859
1183
|
const inspector = root.querySelector('[part=inspector]');
|
|
860
1184
|
const heading = document.createElement("div");
|
|
@@ -953,6 +1277,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
953
1277
|
this.narrowValue = narrow;
|
|
954
1278
|
this.toggleAttribute("data-narrow", narrow);
|
|
955
1279
|
this.toggleAttribute('data-phone', entries[0].contentRect.width < 560);
|
|
1280
|
+
this.toggleAttribute('data-compact-toolbar', entries[0].contentRect.width <= 1100);
|
|
956
1281
|
this.toggleAttribute('data-hide-tidy', entries[0].contentRect.width <= 560);
|
|
957
1282
|
this.renderSelectionToolbar();
|
|
958
1283
|
this.positionSelectionToolbar();
|
|
@@ -1026,6 +1351,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
1026
1351
|
});
|
|
1027
1352
|
const content = root.querySelector('[part=pane-content]');
|
|
1028
1353
|
content.hidden = Boolean(selected);
|
|
1354
|
+
content.dataset.pane = this.activePane;
|
|
1029
1355
|
content.setAttribute("aria-labelledby", `process-tab-${this.activePane.toLowerCase()}`);
|
|
1030
1356
|
const checks = root.querySelector('[part=checks]');
|
|
1031
1357
|
checks.hidden = this.activePane !== "Checks";
|
|
@@ -1084,29 +1410,50 @@ export class ProcessModeler extends BaseElement {
|
|
|
1084
1410
|
content.append(checks);
|
|
1085
1411
|
}
|
|
1086
1412
|
else if (this.activePane === "Variables") {
|
|
1087
|
-
|
|
1088
|
-
|
|
1413
|
+
const intro = document.createElement('p');
|
|
1414
|
+
intro.setAttribute('part', 'variables-intro');
|
|
1415
|
+
intro.textContent = 'Values the process keeps while it runs. Decisions and step inputs read them by name, written in CEL.';
|
|
1416
|
+
content.append(intro);
|
|
1417
|
+
const scopes = document.createElement('dl');
|
|
1418
|
+
scopes.setAttribute('part', 'variable-scopes');
|
|
1419
|
+
for (const [name, meaning] of [['Iteration', 'Starts fresh each time a test user runs the process.'], ['Process', 'Set once per test user and kept across their iterations.'], ['Step', 'Only inside one step or frame. Add those in the step’s details.']]) {
|
|
1420
|
+
const term = document.createElement('dt');
|
|
1421
|
+
term.textContent = name;
|
|
1422
|
+
const definition = document.createElement('dd');
|
|
1423
|
+
definition.textContent = meaning;
|
|
1424
|
+
scopes.append(term, definition);
|
|
1425
|
+
}
|
|
1426
|
+
content.append(scopes);
|
|
1427
|
+
const collection = document.createElement('div');
|
|
1428
|
+
collection.setAttribute('part', 'variable-list');
|
|
1429
|
+
content.append(collection);
|
|
1430
|
+
if (!this.variables.length) {
|
|
1431
|
+
const empty = document.createElement('p');
|
|
1432
|
+
empty.textContent = 'None yet.';
|
|
1433
|
+
collection.append(empty);
|
|
1434
|
+
}
|
|
1089
1435
|
for (const [index, variable] of this.variables.entries()) {
|
|
1090
1436
|
const row = document.createElement('div');
|
|
1091
1437
|
row.setAttribute('part', 'variable-row');
|
|
1092
1438
|
const identify = (control, key) => { control.dataset.variable = variable.name; control.dataset.variableKey = key; control.dataset.variableIndex = String(index); };
|
|
1093
1439
|
if (this.variablesEditable) {
|
|
1094
|
-
const label =
|
|
1095
|
-
label.textContent = 'Name';
|
|
1440
|
+
const label = variableLabel('Name');
|
|
1096
1441
|
const name = document.createElement('input');
|
|
1097
1442
|
name.type = 'text';
|
|
1443
|
+
name.placeholder = 'name';
|
|
1444
|
+
name.spellcheck = false;
|
|
1445
|
+
name.autocomplete = 'off';
|
|
1098
1446
|
name.value = variable.name;
|
|
1099
1447
|
name.disabled = this.locked;
|
|
1100
1448
|
identify(name, 'name');
|
|
1101
1449
|
name.onchange = () => this.requestVariableEdit({ type: 'rename', name: variable.name, value: name.value });
|
|
1102
1450
|
label.append(name);
|
|
1103
1451
|
row.append(label);
|
|
1104
|
-
const scopeLabel =
|
|
1105
|
-
scopeLabel.textContent = 'Scope';
|
|
1452
|
+
const scopeLabel = variableLabel('Kept for');
|
|
1106
1453
|
const scope = document.createElement('select');
|
|
1107
1454
|
identify(scope, 'scope');
|
|
1108
1455
|
scope.disabled = this.locked;
|
|
1109
|
-
for (const [value, text] of [['iteration', '
|
|
1456
|
+
for (const [value, text] of [['iteration', 'Iteration'], ['process', 'Process']]) {
|
|
1110
1457
|
const option = document.createElement('option');
|
|
1111
1458
|
option.value = value;
|
|
1112
1459
|
option.textContent = text;
|
|
@@ -1128,16 +1475,32 @@ export class ProcessModeler extends BaseElement {
|
|
|
1128
1475
|
row.append(scope);
|
|
1129
1476
|
}
|
|
1130
1477
|
}
|
|
1131
|
-
if (
|
|
1478
|
+
if (this.variablesEditable) {
|
|
1479
|
+
const label = variableLabel('What it’s for');
|
|
1480
|
+
const about = document.createElement('input');
|
|
1481
|
+
about.type = 'text';
|
|
1482
|
+
about.placeholder = 'What it’s for';
|
|
1483
|
+
about.value = variable.description ?? '';
|
|
1484
|
+
about.disabled = this.locked;
|
|
1485
|
+
identify(about, 'description');
|
|
1486
|
+
about.oninput = () => this.requestVariableEdit({ type: 'description', name: variable.name, value: about.value });
|
|
1487
|
+
label.append(about);
|
|
1488
|
+
row.append(label);
|
|
1489
|
+
}
|
|
1490
|
+
else if (variable.description) {
|
|
1132
1491
|
const description = document.createElement('p');
|
|
1133
1492
|
description.textContent = variable.description;
|
|
1134
1493
|
row.append(description);
|
|
1135
1494
|
}
|
|
1136
1495
|
if (variable.startingValue !== undefined) {
|
|
1137
|
-
const label = document.createElement('label');
|
|
1138
|
-
|
|
1496
|
+
const label = this.variablesEditable ? variableLabel('Starts as') : document.createElement('label');
|
|
1497
|
+
if (!this.variablesEditable)
|
|
1498
|
+
label.textContent = 'Starting value';
|
|
1139
1499
|
const input = document.createElement('input');
|
|
1140
1500
|
input.type = 'text';
|
|
1501
|
+
input.placeholder = 'starts as, like 0';
|
|
1502
|
+
input.spellcheck = false;
|
|
1503
|
+
input.autocomplete = 'off';
|
|
1141
1504
|
identify(input, 'value');
|
|
1142
1505
|
input.value = variable.startingValue;
|
|
1143
1506
|
input.disabled = this.locked;
|
|
@@ -1160,13 +1523,26 @@ export class ProcessModeler extends BaseElement {
|
|
|
1160
1523
|
remove.type = 'button';
|
|
1161
1524
|
remove.setAttribute('part', 'variable-remove');
|
|
1162
1525
|
remove.setAttribute('aria-label', `Remove ${variable.name || 'this variable'}`);
|
|
1163
|
-
remove.
|
|
1526
|
+
remove.append(variableGlyph());
|
|
1164
1527
|
remove.disabled = this.locked;
|
|
1165
1528
|
identify(remove, 'remove');
|
|
1166
1529
|
remove.onclick = () => this.requestVariableEdit({ type: 'remove', name: variable.name });
|
|
1167
1530
|
row.append(remove);
|
|
1168
1531
|
}
|
|
1169
|
-
|
|
1532
|
+
if (this.variablesEditable) {
|
|
1533
|
+
// The grid's visual ordering must also be the keyboard/read order.
|
|
1534
|
+
for (const key of ['name', 'value', 'remove', 'description', 'scope']) {
|
|
1535
|
+
const control = row.querySelector(`[data-variable-key=${key}]`);
|
|
1536
|
+
if (control)
|
|
1537
|
+
row.append(control.closest('label') ?? control);
|
|
1538
|
+
}
|
|
1539
|
+
const problem = row.querySelector('[part=field-problem]');
|
|
1540
|
+
if (problem)
|
|
1541
|
+
row.append(problem);
|
|
1542
|
+
}
|
|
1543
|
+
row.setAttribute('role', 'group');
|
|
1544
|
+
row.setAttribute('aria-label', `Variable ${index + 1}`);
|
|
1545
|
+
collection.append(row);
|
|
1170
1546
|
}
|
|
1171
1547
|
if (this.variablesEditable) {
|
|
1172
1548
|
const add = document.createElement('button');
|
|
@@ -1174,11 +1550,45 @@ export class ProcessModeler extends BaseElement {
|
|
|
1174
1550
|
add.setAttribute('part', 'variable-add');
|
|
1175
1551
|
add.dataset.variable = '@add';
|
|
1176
1552
|
add.dataset.variableKey = 'add';
|
|
1177
|
-
add.
|
|
1553
|
+
add.append(variableGlyph(true), document.createTextNode('Add a variable'));
|
|
1178
1554
|
add.disabled = this.locked;
|
|
1179
1555
|
add.onclick = () => this.requestVariableEdit({ type: 'add' });
|
|
1180
|
-
|
|
1556
|
+
const actions = document.createElement('div');
|
|
1557
|
+
actions.setAttribute('part', 'variable-actions');
|
|
1558
|
+
actions.append(add);
|
|
1559
|
+
content.append(actions);
|
|
1181
1560
|
}
|
|
1561
|
+
const localRows = this.projection.boxes.flatMap(box => (box.localVariables ?? []).filter(variable => variable.name).map(variable => ({ name: variable.name, connective: 'only in', title: box.title })));
|
|
1562
|
+
const savedRows = this.projection.boxes.filter(box => box.savedResult).map(box => ({ name: box.savedResult, connective: 'from', title: box.title }));
|
|
1563
|
+
const summary = (part, heading, rows, empty) => {
|
|
1564
|
+
if (!rows.length && !empty)
|
|
1565
|
+
return;
|
|
1566
|
+
const section = document.createElement('section');
|
|
1567
|
+
section.setAttribute('part', part);
|
|
1568
|
+
const title = document.createElement('h3');
|
|
1569
|
+
title.textContent = heading;
|
|
1570
|
+
section.append(title);
|
|
1571
|
+
if (rows.length) {
|
|
1572
|
+
const list = document.createElement('ul');
|
|
1573
|
+
for (const item of rows) {
|
|
1574
|
+
const row = document.createElement('li'), text = document.createElement('span'), name = document.createElement('code'), connective = document.createElement('em');
|
|
1575
|
+
name.textContent = item.name;
|
|
1576
|
+
connective.textContent = item.connective;
|
|
1577
|
+
text.append(name, document.createTextNode(' '), connective, document.createTextNode(` ${item.title}`));
|
|
1578
|
+
row.append(text);
|
|
1579
|
+
list.append(row);
|
|
1580
|
+
}
|
|
1581
|
+
section.append(list);
|
|
1582
|
+
}
|
|
1583
|
+
else {
|
|
1584
|
+
const help = document.createElement('p');
|
|
1585
|
+
help.textContent = empty;
|
|
1586
|
+
section.append(help);
|
|
1587
|
+
}
|
|
1588
|
+
content.append(section);
|
|
1589
|
+
};
|
|
1590
|
+
summary('local-variable-summary', 'Only in one step', localRows);
|
|
1591
|
+
summary('saved-result-summary', 'Saved by steps', savedRows, 'No step saves its result yet. Set “Save the result as” on a step.');
|
|
1182
1592
|
}
|
|
1183
1593
|
else if (this.activePane === "Connections") {
|
|
1184
1594
|
if (!this.connections.length)
|
|
@@ -1220,6 +1630,14 @@ export class ProcessModeler extends BaseElement {
|
|
|
1220
1630
|
if (this.locked)
|
|
1221
1631
|
return;
|
|
1222
1632
|
const boxes = this.selectedBoxes;
|
|
1633
|
+
if (command === "delete-many" && this.graphSelection) {
|
|
1634
|
+
if (this.disableConnections && this.selectedLineIds.size) {
|
|
1635
|
+
this.setStatus('Deleting selected lines is disabled');
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
this.requestEdit({ type: 'delete-selection', selection: this.selection });
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1223
1641
|
if (command === "delete-many") {
|
|
1224
1642
|
for (const box of boxes)
|
|
1225
1643
|
this.requestEdit({ type: "delete", boxId: box.id });
|
|
@@ -1237,6 +1655,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
1237
1655
|
tidySelection() {
|
|
1238
1656
|
if (this.locked || this.selectedBoxes.length < 2)
|
|
1239
1657
|
return;
|
|
1658
|
+
if (this.requestLayoutEdit('tidy', [...this.selectedIds]))
|
|
1659
|
+
return;
|
|
1240
1660
|
const next = this.layout;
|
|
1241
1661
|
const roots = this.selectedBoxes.filter(box => {
|
|
1242
1662
|
let parent = box.parentId;
|
|
@@ -1276,11 +1696,13 @@ export class ProcessModeler extends BaseElement {
|
|
|
1276
1696
|
const arrangedY = Math.min(...group.map(box => arranged.boxes[box.id]?.y ?? Infinity));
|
|
1277
1697
|
if (![arrangedX, arrangedY].every(Number.isFinite))
|
|
1278
1698
|
continue;
|
|
1699
|
+
const dx = Math.round((startX - arrangedX) / 16) * 16;
|
|
1700
|
+
const dy = Math.round((startY - arrangedY) / 16) * 16;
|
|
1279
1701
|
for (const box of subset.boxes) {
|
|
1280
1702
|
const position = arranged.boxes[box.id];
|
|
1281
1703
|
if (!position || !next.boxes[box.id])
|
|
1282
1704
|
continue;
|
|
1283
|
-
next.boxes[box.id] = { ...next.boxes[box.id], x:
|
|
1705
|
+
next.boxes[box.id] = { ...next.boxes[box.id], x: position.x + dx, y: position.y + dy };
|
|
1284
1706
|
}
|
|
1285
1707
|
}
|
|
1286
1708
|
this.commitLayout(next);
|
|
@@ -1373,7 +1795,11 @@ export class ProcessModeler extends BaseElement {
|
|
|
1373
1795
|
this.commitLayout(next);
|
|
1374
1796
|
}
|
|
1375
1797
|
makeSection(title = 'New section') {
|
|
1376
|
-
if (this.locked || this.selectedBoxes.length
|
|
1798
|
+
if (this.locked || !this.selectedBoxes.length)
|
|
1799
|
+
return;
|
|
1800
|
+
if (this.requestLayoutEdit('make-section', [...this.selectedIds], title))
|
|
1801
|
+
return;
|
|
1802
|
+
if (this.selectedBoxes.length < 2)
|
|
1377
1803
|
return;
|
|
1378
1804
|
const positions = this.selectedBoxes.map(box => this.layoutValue.boxes[box.id]);
|
|
1379
1805
|
const x = Math.min(...positions.map(position => position.x)) - 24;
|
|
@@ -1536,6 +1962,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
1536
1962
|
let claimed = false;
|
|
1537
1963
|
const request = {
|
|
1538
1964
|
sourceIds: Object.freeze([...this.selectedIds]),
|
|
1965
|
+
selection: Object.freeze(this.selection.map(item => Object.freeze(item))),
|
|
1539
1966
|
capture: clipboard => {
|
|
1540
1967
|
if (!active || claimed || session !== this.clipboardSession || !this.isConnected)
|
|
1541
1968
|
return;
|
|
@@ -1551,7 +1978,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
1551
1978
|
return; claimed = true; this.setStatus(message ?? 'Copy is not supported by this host'); },
|
|
1552
1979
|
};
|
|
1553
1980
|
try {
|
|
1554
|
-
emit(this, 'process-copy-request', request);
|
|
1981
|
+
emit(this, this.graphSelection && (this.selectedLineIds.size || this.selectedNoteIds.size) ? 'process-selection-copy-request' : 'process-copy-request', request);
|
|
1555
1982
|
}
|
|
1556
1983
|
finally {
|
|
1557
1984
|
active = false;
|
|
@@ -1632,11 +2059,15 @@ export class ProcessModeler extends BaseElement {
|
|
|
1632
2059
|
}
|
|
1633
2060
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
|
1634
2061
|
event.preventDefault();
|
|
1635
|
-
this.
|
|
1636
|
-
|
|
2062
|
+
this.selectItems([
|
|
2063
|
+
...this.projection.boxes.filter(box => box.kind !== 'section').map(box => ({ type: 'box', id: box.id })),
|
|
2064
|
+
...this.projection.lines.map(line => ({ type: 'line', id: line.id })),
|
|
2065
|
+
...(this.layoutValue.notes ?? []).map(note => ({ type: 'note', id: note.id })),
|
|
2066
|
+
]);
|
|
2067
|
+
this.setStatus(`${this.selection.length} items selected`);
|
|
1637
2068
|
return;
|
|
1638
2069
|
}
|
|
1639
|
-
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && this.selectedIds.size) {
|
|
2070
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && (this.selectedIds.size || this.selectedNoteIds.size)) {
|
|
1640
2071
|
if (this.copySelection())
|
|
1641
2072
|
event.preventDefault();
|
|
1642
2073
|
return;
|
|
@@ -1674,6 +2105,11 @@ export class ProcessModeler extends BaseElement {
|
|
|
1674
2105
|
this.setStatus("Whole process fitted to the canvas");
|
|
1675
2106
|
return;
|
|
1676
2107
|
}
|
|
2108
|
+
if ((event.key === 'Delete' || event.key === 'Backspace') && this.graphSelection && this.selection.length && !this.locked) {
|
|
2109
|
+
event.preventDefault();
|
|
2110
|
+
this.selectionCommand('delete-many');
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
1677
2113
|
const box = this.selected;
|
|
1678
2114
|
if (this.selectedLine && (event.key === 'Delete' || event.key === 'Backspace') && !this.locked && !this.disableConnections) {
|
|
1679
2115
|
event.preventDefault();
|
|
@@ -1691,6 +2127,32 @@ export class ProcessModeler extends BaseElement {
|
|
|
1691
2127
|
ArrowUp: [0, -1],
|
|
1692
2128
|
ArrowDown: [0, 1],
|
|
1693
2129
|
}[event.key];
|
|
2130
|
+
if (direction && this.graphSelection && !this.locked && !event.altKey && !event.ctrlKey && !event.metaKey && (this.selectedIds.size || this.selectedNoteIds.size)) {
|
|
2131
|
+
event.preventDefault();
|
|
2132
|
+
const distance = event.shiftKey ? 64 : 16, dx = direction[0] * distance, dy = direction[1] * distance;
|
|
2133
|
+
const next = this.layout, moved = new Set(this.selectedIds);
|
|
2134
|
+
for (let changed = true; changed;) {
|
|
2135
|
+
changed = false;
|
|
2136
|
+
for (const box of this.projection.boxes)
|
|
2137
|
+
if (box.parentId && moved.has(box.parentId) && !moved.has(box.id)) {
|
|
2138
|
+
moved.add(box.id);
|
|
2139
|
+
changed = true;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
for (const id of moved) {
|
|
2143
|
+
const p = next.boxes[id];
|
|
2144
|
+
if (p)
|
|
2145
|
+
next.boxes[id] = { ...p, x: p.x + dx, y: p.y + dy };
|
|
2146
|
+
}
|
|
2147
|
+
next.notes = next.notes?.map(note => this.selectedNoteIds.has(note.id) ? { ...note, x: note.x + dx, y: note.y + dy } : note);
|
|
2148
|
+
const primary = this.selectedId;
|
|
2149
|
+
const allowed = primary
|
|
2150
|
+
? emit(this, 'move-request', { boxId: primary, position: next.boxes[primary], boxIds: [...moved], positions: next.boxes, noteIds: [...this.selectedNoteIds], notes: next.notes ?? [] })
|
|
2151
|
+
: emit(this, 'note-move-request', { noteIds: [...this.selectedNoteIds], notes: next.notes ?? [] });
|
|
2152
|
+
if (allowed)
|
|
2153
|
+
this.commitLayout(next);
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
1694
2156
|
const canInsertDirection = event.ctrlKey && event.altKey && box && !this.locked && this.catalog.length && !this.disableConnections;
|
|
1695
2157
|
if (direction && !canInsertDirection && (event.altKey || !box || this.locked)) {
|
|
1696
2158
|
event.preventDefault();
|
|
@@ -2033,7 +2495,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2033
2495
|
if ((frame?.id ?? null) !== (source.parentId ?? null))
|
|
2034
2496
|
this.setStatus('Drop inside the same frame to add a connected step');
|
|
2035
2497
|
else
|
|
2036
|
-
this.openChooserAtPoint({ type: 'add', from: port.id, fromSide: port.side, parentId: source.parentId, position: point }, `Add after ${source.title}`, point);
|
|
2498
|
+
this.openChooserAtPoint({ type: 'add', from: port.id, fromSide: port.side, parentId: source.parentId, position: point, placement: { source: 'point', center: { ...point } } }, `Add after ${source.title}`, point);
|
|
2037
2499
|
}
|
|
2038
2500
|
}
|
|
2039
2501
|
}
|
|
@@ -2093,8 +2555,11 @@ export class ProcessModeler extends BaseElement {
|
|
|
2093
2555
|
const position = this.alignedPoint(drag.id, drag.position.x + dx / this.viewport.zoom, drag.position.y + dy / this.viewport.zoom);
|
|
2094
2556
|
const line = this.lineAt(point, drag.id);
|
|
2095
2557
|
const frame = this.boxAt(point, drag.id, true);
|
|
2096
|
-
if (line)
|
|
2097
|
-
this.
|
|
2558
|
+
if (line) {
|
|
2559
|
+
const box = this.projection.boxes.find(box => box.id === drag.id);
|
|
2560
|
+
const { width, height } = this.selectionDimensions(box);
|
|
2561
|
+
this.requestEdit({ type: "insert", boxId: drag.id, lineId: line.id, from: line.from, to: line.to, position, placement: { source: 'point', center: { x: position.x + width / 2, y: position.y + height / 2 } } });
|
|
2562
|
+
}
|
|
2098
2563
|
else if (this.projection.boxes.find(box => box.id === drag.id)?.parentId !== frame?.id)
|
|
2099
2564
|
this.requestEdit({ type: "reparent", boxId: drag.id, parentId: frame?.id, position });
|
|
2100
2565
|
else if (this.selectedIds.size > 1 && this.selectedIds.has(drag.id)) {
|
|
@@ -2219,9 +2684,10 @@ export class ProcessModeler extends BaseElement {
|
|
|
2219
2684
|
nextEdit(box, side, position) {
|
|
2220
2685
|
const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
|
|
2221
2686
|
const outgoing = this.projection.lines.filter(line => line.from === box.id);
|
|
2687
|
+
const placement = side ? { source: 'direction', side } : { source: 'next' };
|
|
2222
2688
|
if ((!side || side === 'east') && shape !== 'gateway' && outgoing.length === 1)
|
|
2223
|
-
return { type: 'insert', lineId: outgoing[0].id, from: box.id, to: outgoing[0].to };
|
|
2224
|
-
return { type: 'add', from: box.id, ...(side ? { fromSide: side } : {}), parentId: box.parentId, ...(position ? { position } : {}) };
|
|
2689
|
+
return { type: 'insert', lineId: outgoing[0].id, from: box.id, to: outgoing[0].to, placement };
|
|
2690
|
+
return { type: 'add', from: box.id, placement, ...(side ? { fromSide: side } : {}), parentId: box.parentId, ...(position ? { position } : {}) };
|
|
2225
2691
|
}
|
|
2226
2692
|
nextChooserTitle(box, side) {
|
|
2227
2693
|
const edit = this.nextEdit(box, side);
|
|
@@ -2247,7 +2713,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2247
2713
|
const source = this.projection.boxes.find(box => box.id === line.from);
|
|
2248
2714
|
const target = this.projection.boxes.find(box => box.id === line.to);
|
|
2249
2715
|
const points = this.routedLines.get(line.id) ?? routeProcessLine(line, this.layoutValue, this.projection);
|
|
2250
|
-
this.openChooserAtPoint({ type: 'insert', lineId: line.id, from: line.from, to: line.to }, `Insert between ${source?.title ?? line.from} and ${target?.title ?? line.to}`, lineMidpoint(points), returnFocus);
|
|
2716
|
+
this.openChooserAtPoint({ type: 'insert', lineId: line.id, from: line.from, to: line.to, placement: { source: 'line', center: lineMidpoint(points) } }, `Insert between ${source?.title ?? line.from} and ${target?.title ?? line.to}`, lineMidpoint(points), returnFocus);
|
|
2251
2717
|
}
|
|
2252
2718
|
closeKeyboardChooser(returnFocus = false) {
|
|
2253
2719
|
const chooser = this.shadowRoot?.querySelector('[part=keyboard-chooser]');
|
|
@@ -2258,9 +2724,10 @@ export class ProcessModeler extends BaseElement {
|
|
|
2258
2724
|
this.transientChooserAnchor = undefined;
|
|
2259
2725
|
document.removeEventListener('pointerdown', this.dismissKeyboardChooserOutside, true);
|
|
2260
2726
|
this.keyboardInsertion = undefined;
|
|
2261
|
-
|
|
2262
|
-
this.keyboardReturn?.focus({ preventScroll: true });
|
|
2727
|
+
const target = this.keyboardReturn;
|
|
2263
2728
|
this.keyboardReturn = undefined;
|
|
2729
|
+
if (returnFocus)
|
|
2730
|
+
target?.focus({ preventScroll: true });
|
|
2264
2731
|
}
|
|
2265
2732
|
dismissKeyboardChooserOutside = (event) => {
|
|
2266
2733
|
const chooser = this.shadowRoot?.querySelector('[part=keyboard-chooser]');
|
|
@@ -2275,8 +2742,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
2275
2742
|
const anchor = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
|
|
2276
2743
|
.find(element => element.dataset.boxId === box.id);
|
|
2277
2744
|
this.openAnchoredChooser(insert
|
|
2278
|
-
? { type: 'insert', lineId: outgoing[0].id, from: box.id, to: target.id }
|
|
2279
|
-
: { type: 'add', from: box.id }, insert ? `Insert between ${box.title} and ${target.title}`
|
|
2745
|
+
? { type: 'insert', lineId: outgoing[0].id, from: box.id, to: target.id, placement: { source: 'next' } }
|
|
2746
|
+
: { type: 'add', from: box.id, placement: { source: 'next' } }, insert ? `Insert between ${box.title} and ${target.title}`
|
|
2280
2747
|
: shape === 'gateway' ? `Add a path from ${box.title}` : `Add after ${box.title}`, anchor);
|
|
2281
2748
|
}
|
|
2282
2749
|
openAnchoredChooser(edit, title, anchor, returnFocus) {
|
|
@@ -2325,7 +2792,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2325
2792
|
tooltip.style.top = `${point.y + 16}px`;
|
|
2326
2793
|
}
|
|
2327
2794
|
update() {
|
|
2328
|
-
const selectionBefore = JSON.stringify({
|
|
2795
|
+
const selectionBefore = JSON.stringify({ items: this.selection, path: this.selectedPath });
|
|
2329
2796
|
if (this.documentValue !== undefined) {
|
|
2330
2797
|
this.projection = this.model.project(this.documentValue);
|
|
2331
2798
|
validateProjection(this.projection);
|
|
@@ -2340,10 +2807,12 @@ export class ProcessModeler extends BaseElement {
|
|
|
2340
2807
|
if (this.selectedLineId && !this.selectedLine)
|
|
2341
2808
|
this.selectedLineId = null;
|
|
2342
2809
|
this.selectedIds = new Set([...this.selectedIds].filter(id => this.projection.boxes.some(box => box.id === id)));
|
|
2810
|
+
this.selectedLineIds = new Set([...this.selectedLineIds].filter(id => this.projection.lines.some(line => line.id === id)));
|
|
2811
|
+
this.selectedNoteIds = new Set([...this.selectedNoteIds].filter(id => this.layoutValue.notes?.some(note => note.id === id)));
|
|
2343
2812
|
if (!this.selectedId)
|
|
2344
2813
|
this.selectedId = this.selectedIds.values().next().value ?? null;
|
|
2345
2814
|
const serialized = JSON.stringify({
|
|
2346
|
-
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 })),
|
|
2815
|
+
boxes: this.projection.boxes.map(box => ({ id: box.id, kind: box.kind, title: box.title, description: box.description, path: box.path, fingerprint: box.fingerprint, frame: box.frame, loopMark: box.loopMark, parentId: box.parentId, localVariables: box.localVariables, localVariablesEditable: box.localVariablesEditable, savedResult: box.savedResult })),
|
|
2347
2816
|
lines: this.projection.lines,
|
|
2348
2817
|
});
|
|
2349
2818
|
if (this.documentValue !== undefined && (serialized !== this.lastProjection || this.documentValue !== this.lastDocument)) {
|
|
@@ -2351,7 +2820,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2351
2820
|
this.versionValue = typeof this.versionValue === "number" ? this.versionValue + 1 : `${this.versionValue}+1`;
|
|
2352
2821
|
this.lastProjection = serialized;
|
|
2353
2822
|
this.lastDocument = this.documentValue;
|
|
2354
|
-
|
|
2823
|
+
this.notify("projection-changed", { projection: snapshotProcessProjection(this.projection), version: this.versionValue, checks: this.allChecks });
|
|
2355
2824
|
}
|
|
2356
2825
|
// Riptide or another host owns conversion. It supplies validate() and
|
|
2357
2826
|
// receives this event only when that conversion can read the drawing.
|
|
@@ -2361,7 +2830,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2361
2830
|
if (this.documentValue !== undefined && !this.allChecks.length && readableKey !== this.lastReadableProjection) {
|
|
2362
2831
|
this.lastReadableProjection = readableKey;
|
|
2363
2832
|
this.readableValue = snapshotProcessProjection(this.projection);
|
|
2364
|
-
|
|
2833
|
+
this.notify("readable-projection-changed", { projection: snapshotProcessProjection(this.readableValue), version: this.versionValue });
|
|
2365
2834
|
}
|
|
2366
2835
|
if (this.documentValue === undefined) {
|
|
2367
2836
|
this.lastProjection = "";
|
|
@@ -2369,16 +2838,16 @@ export class ProcessModeler extends BaseElement {
|
|
|
2369
2838
|
this.readableValue = null;
|
|
2370
2839
|
this.lastDocument = undefined;
|
|
2371
2840
|
}
|
|
2372
|
-
const
|
|
2373
|
-
|
|
2841
|
+
const active = this.shadowRoot.activeElement;
|
|
2842
|
+
const focused = active?.dataset.boxId, focusedNote = active?.dataset.noteId;
|
|
2374
2843
|
this.renderWorld();
|
|
2375
2844
|
this.renderPalette();
|
|
2376
2845
|
this.renderChecks();
|
|
2377
2846
|
this.renderSelection();
|
|
2378
2847
|
this.paintViewport();
|
|
2379
2848
|
this.updateToolbar();
|
|
2380
|
-
if (selectionBefore !== JSON.stringify({
|
|
2381
|
-
|
|
2849
|
+
if (selectionBefore !== JSON.stringify({ items: this.selection, path: this.selectedPath }))
|
|
2850
|
+
this.notify("selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, ...(this.graphSelection ? { selection: this.selection } : {}) });
|
|
2382
2851
|
if (focused) {
|
|
2383
2852
|
this.restoringBoxFocus = true;
|
|
2384
2853
|
try {
|
|
@@ -2388,6 +2857,10 @@ export class ProcessModeler extends BaseElement {
|
|
|
2388
2857
|
this.restoringBoxFocus = false;
|
|
2389
2858
|
}
|
|
2390
2859
|
}
|
|
2860
|
+
else if (focusedNote) {
|
|
2861
|
+
const note = Array.from(this.shadowRoot.querySelectorAll('[data-note-id]')).find(note => note.dataset.noteId === focusedNote);
|
|
2862
|
+
(note ?? this.shadowRoot.querySelector('[part=canvas]'))?.focus({ preventScroll: true });
|
|
2863
|
+
}
|
|
2391
2864
|
}
|
|
2392
2865
|
get allChecks() { return this.computedChecks; }
|
|
2393
2866
|
collectChecks() {
|
|
@@ -2434,7 +2907,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2434
2907
|
const problem = this.allChecks.find(check => check.boxId === box.id || (check.path && JSON.stringify(check.path) === JSON.stringify(box.path)));
|
|
2435
2908
|
const incoming = this.projection.lines.filter(line => line.to === box.id).length;
|
|
2436
2909
|
const outgoing = this.projection.lines.filter(line => line.from === box.id).length;
|
|
2437
|
-
element.setAttribute("aria-label", [`${kind?.label ?? box.kind}: ${box.title}. ${incoming} in, ${outgoing} out`, problem ? `Needs attention: ${problem.message}` : ""]
|
|
2910
|
+
element.setAttribute("aria-label", [`${kind?.label ?? box.kind}: ${box.title}. ${incoming} in, ${outgoing} out`, problem ? `Needs attention: ${problem.title || problem.message}` : ""]
|
|
2438
2911
|
.filter(Boolean)
|
|
2439
2912
|
.join(". "));
|
|
2440
2913
|
element.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
|
|
@@ -2551,7 +3024,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2551
3024
|
if (problem) {
|
|
2552
3025
|
const message = document.createElement("span");
|
|
2553
3026
|
message.setAttribute("part", "problem");
|
|
2554
|
-
message.append(checkGlyph(), document.createTextNode(problem.title
|
|
3027
|
+
message.append(checkGlyph(), document.createTextNode(problem.title || problem.message));
|
|
2555
3028
|
element.append(message);
|
|
2556
3029
|
}
|
|
2557
3030
|
element.addEventListener("keydown", event => {
|
|
@@ -2660,8 +3133,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
2660
3133
|
const path = svgElement("path");
|
|
2661
3134
|
path.setAttribute("part", "line");
|
|
2662
3135
|
path.dataset.lineId = line.id;
|
|
2663
|
-
path.dataset.selected = String(this.
|
|
2664
|
-
path.setAttribute('marker-end', `url(#${this.
|
|
3136
|
+
path.dataset.selected = String(this.selectedLineIds.has(line.id));
|
|
3137
|
+
path.setAttribute('marker-end', `url(#${this.selectedLineIds.has(line.id) ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`);
|
|
2665
3138
|
path.setAttribute("vector-effect", "non-scaling-stroke");
|
|
2666
3139
|
path.setAttribute("d", roundedProcessPath(points));
|
|
2667
3140
|
if (line.dashed)
|
|
@@ -2760,6 +3233,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2760
3233
|
lineId: line.id,
|
|
2761
3234
|
from: line.from,
|
|
2762
3235
|
to: line.to,
|
|
3236
|
+
...(type === 'insert' ? { placement: { source: 'line', center: lineMidpoint(points) } } : {}),
|
|
2763
3237
|
};
|
|
2764
3238
|
if (type === "insert" && this.catalog.length) {
|
|
2765
3239
|
this.openLineChooser(line, button);
|
|
@@ -2789,6 +3263,18 @@ export class ProcessModeler extends BaseElement {
|
|
|
2789
3263
|
const element = document.createElement("div");
|
|
2790
3264
|
element.setAttribute("part", "note");
|
|
2791
3265
|
element.textContent = note.text;
|
|
3266
|
+
element.dataset.noteId = note.id;
|
|
3267
|
+
element.addEventListener('pointerdown', event => event.stopPropagation());
|
|
3268
|
+
element.tabIndex = 0;
|
|
3269
|
+
element.setAttribute('role', 'button');
|
|
3270
|
+
element.setAttribute('aria-label', note.text || 'Diagram note');
|
|
3271
|
+
element.addEventListener('click', event => { const items = event.shiftKey ? this.selection.filter(item => !(item.type === 'note' && item.id === note.id)) : []; if (!event.shiftKey || !this.selectedNoteIds.has(note.id))
|
|
3272
|
+
items.push({ type: 'note', id: note.id }); this.selectItems(items); });
|
|
3273
|
+
element.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') {
|
|
3274
|
+
event.preventDefault();
|
|
3275
|
+
event.stopPropagation();
|
|
3276
|
+
this.selectItems([{ type: 'note', id: note.id }]);
|
|
3277
|
+
} });
|
|
2792
3278
|
this.place(element, { ...note, width: 208, height: 80 });
|
|
2793
3279
|
world.append(element);
|
|
2794
3280
|
}
|
|
@@ -2814,9 +3300,13 @@ export class ProcessModeler extends BaseElement {
|
|
|
2814
3300
|
}
|
|
2815
3301
|
const selected = this.selectedIds.size === 1 ? this.selected : null;
|
|
2816
3302
|
const from = selected && !selected.frame && selected.kind !== 'section' && !['end', 'finish'].includes(selected.kind) ? selected.id : undefined;
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
3303
|
+
if (from && selected)
|
|
3304
|
+
this.requestEdit({ ...this.nextEdit(selected), kind });
|
|
3305
|
+
else {
|
|
3306
|
+
const canvas = this.shadowRoot.querySelector('[part=canvas]');
|
|
3307
|
+
const center = { x: (canvas.clientWidth / 2 - this.viewport.x) / this.viewport.zoom, y: (canvas.clientHeight / 2 - this.viewport.y) / this.viewport.zoom };
|
|
3308
|
+
this.requestEdit({ type: 'add', kind, placement: { source: 'point', center } });
|
|
3309
|
+
}
|
|
2820
3310
|
}
|
|
2821
3311
|
paletteLineAt(point) {
|
|
2822
3312
|
let nearest;
|
|
@@ -2938,7 +3428,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2938
3428
|
}
|
|
2939
3429
|
const line = drag.kind.kind === 'end' ? undefined : this.paletteLineAt(point);
|
|
2940
3430
|
const frame = line ? undefined : this.paletteFrameAt(point);
|
|
2941
|
-
this.requestEdit({ type: line ? 'insert' : 'add', kind: drag.kind, lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id, position: point });
|
|
3431
|
+
this.requestEdit({ type: line ? 'insert' : 'add', kind: drag.kind, lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id, position: point, placement: { source: 'point', center: { ...point } } });
|
|
2942
3432
|
}
|
|
2943
3433
|
renderPalette() {
|
|
2944
3434
|
const choices = this.shadowRoot.querySelector("[part=choices]");
|
|
@@ -3037,7 +3527,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
3037
3527
|
type: line ? "insert" : "add",
|
|
3038
3528
|
kind,
|
|
3039
3529
|
lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id,
|
|
3040
|
-
position,
|
|
3530
|
+
position, placement: { source: 'point', center: { ...position } },
|
|
3041
3531
|
});
|
|
3042
3532
|
this.markDropLine(undefined);
|
|
3043
3533
|
};
|
|
@@ -3081,14 +3571,17 @@ export class ProcessModeler extends BaseElement {
|
|
|
3081
3571
|
checks.append(button);
|
|
3082
3572
|
});
|
|
3083
3573
|
}
|
|
3084
|
-
renderField(box, field) {
|
|
3574
|
+
renderField(box, field, helpOpen = false) {
|
|
3085
3575
|
const row = document.createElement('div');
|
|
3086
3576
|
row.setAttribute('part', 'field');
|
|
3087
3577
|
const label = document.createElement('label');
|
|
3088
3578
|
label.textContent = field.label;
|
|
3089
3579
|
let control;
|
|
3090
|
-
if (field.kind === 'multiline')
|
|
3580
|
+
if (field.kind === 'multiline' || (field.kind === 'expression' && (field.expression?.rows ?? 1) > 1)) {
|
|
3091
3581
|
control = document.createElement('textarea');
|
|
3582
|
+
if (field.expression?.rows)
|
|
3583
|
+
control.rows = Math.max(2, Math.floor(field.expression.rows));
|
|
3584
|
+
}
|
|
3092
3585
|
else if (field.kind === 'choice') {
|
|
3093
3586
|
const select = document.createElement('select');
|
|
3094
3587
|
const groups = new Map();
|
|
@@ -3117,14 +3610,20 @@ export class ProcessModeler extends BaseElement {
|
|
|
3117
3610
|
control = input;
|
|
3118
3611
|
}
|
|
3119
3612
|
control.dataset.field = field.key;
|
|
3613
|
+
control.dataset.fieldBox = box.id;
|
|
3120
3614
|
if (control instanceof HTMLInputElement && control.type === 'checkbox')
|
|
3121
3615
|
control.checked = Boolean(field.value);
|
|
3122
3616
|
else
|
|
3123
3617
|
control.value = field.kind === 'action' ? field.options?.find(option => option.value === field.value)?.label ?? String(field.value) : String(field.value);
|
|
3124
3618
|
control.disabled = Boolean(field.disabled || this.locked);
|
|
3125
3619
|
control.required = Boolean(field.required);
|
|
3126
|
-
if (field.placeholder && control instanceof HTMLInputElement)
|
|
3620
|
+
if (field.placeholder && (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement))
|
|
3127
3621
|
control.placeholder = field.placeholder;
|
|
3622
|
+
if (field.kind === 'expression') {
|
|
3623
|
+
control.spellcheck = false;
|
|
3624
|
+
control.setAttribute('autocomplete', 'off');
|
|
3625
|
+
control.setAttribute('part', 'expression-control');
|
|
3626
|
+
}
|
|
3128
3627
|
if (field.problem)
|
|
3129
3628
|
control.setAttribute('aria-invalid', 'true');
|
|
3130
3629
|
const submit = () => emit(this, 'process-field-change-request', { boxId: box.id, path: box.path, key: field.key, value: control instanceof HTMLInputElement && control.type === 'checkbox' ? control.checked : control instanceof HTMLInputElement && control.type === 'number' && control.value !== '' ? Number(control.value) : control.value });
|
|
@@ -3261,55 +3760,152 @@ export class ProcessModeler extends BaseElement {
|
|
|
3261
3760
|
control.setAttribute('list', options.id);
|
|
3262
3761
|
row.append(options);
|
|
3263
3762
|
}
|
|
3264
|
-
|
|
3763
|
+
const scope = field.expression?.variables ?? this.variables;
|
|
3764
|
+
if (field.kind === 'expression' && scope.length) {
|
|
3265
3765
|
const chips = document.createElement('div');
|
|
3266
3766
|
chips.setAttribute('part', 'variable-chips');
|
|
3267
|
-
chips.setAttribute('
|
|
3268
|
-
|
|
3767
|
+
chips.setAttribute('role', 'group');
|
|
3768
|
+
chips.setAttribute('aria-label', 'Insert a variable');
|
|
3769
|
+
for (const variable of scope) {
|
|
3269
3770
|
const chip = document.createElement('button');
|
|
3270
3771
|
chip.type = 'button';
|
|
3271
3772
|
chip.textContent = variable.name;
|
|
3272
3773
|
chip.disabled = control.disabled;
|
|
3273
|
-
|
|
3774
|
+
if (variable.description)
|
|
3775
|
+
chip.title = variable.description;
|
|
3776
|
+
chip.addEventListener('pointerdown', event => event.preventDefault());
|
|
3777
|
+
chip.onclick = () => {
|
|
3778
|
+
if (control.disabled || !control.isConnected || this.selected?.id !== box.id)
|
|
3779
|
+
return;
|
|
3780
|
+
const input = control;
|
|
3781
|
+
const session = this.layoutEditSession;
|
|
3782
|
+
const start = input.selectionStart ?? input.value.length, end = input.selectionEnd ?? start;
|
|
3783
|
+
input.focus();
|
|
3784
|
+
if (session !== this.layoutEditSession || !input.isConnected || input.disabled || this.locked || this.selected?.id !== box.id || this.shadowRoot?.activeElement !== input || input.closest('[inert]'))
|
|
3785
|
+
return;
|
|
3786
|
+
input.value = input.value.slice(0, start) + variable.name + input.value.slice(end);
|
|
3787
|
+
input.setSelectionRange(start + variable.name.length, start + variable.name.length);
|
|
3788
|
+
submit();
|
|
3789
|
+
};
|
|
3274
3790
|
chips.append(chip);
|
|
3275
3791
|
}
|
|
3276
3792
|
row.append(chips);
|
|
3277
3793
|
}
|
|
3794
|
+
const messageId = `process-field-${encodeURIComponent(box.id)}-${encodeURIComponent(field.key)}`;
|
|
3795
|
+
const describedBy = [];
|
|
3278
3796
|
if (field.description) {
|
|
3279
3797
|
const help = document.createElement('small');
|
|
3798
|
+
help.id = `${messageId}-description`;
|
|
3280
3799
|
help.textContent = field.description;
|
|
3281
3800
|
row.append(help);
|
|
3801
|
+
describedBy.push(help.id);
|
|
3282
3802
|
}
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3803
|
+
const feedback = field.problem || (field.kind === 'expression' ? field.expression?.feedback?.message : undefined);
|
|
3804
|
+
if (feedback) {
|
|
3805
|
+
const message = document.createElement('small');
|
|
3806
|
+
message.id = `${messageId}-feedback`;
|
|
3807
|
+
message.textContent = feedback;
|
|
3808
|
+
message.setAttribute('part', field.problem ? 'field-problem' : 'field-feedback');
|
|
3809
|
+
if (field.kind === 'expression')
|
|
3810
|
+
message.setAttribute('aria-live', 'polite');
|
|
3811
|
+
if (!field.problem && field.expression?.feedback?.tone)
|
|
3812
|
+
message.dataset.tone = field.expression.feedback.tone;
|
|
3813
|
+
const chips = row.querySelector('[part=variable-chips]');
|
|
3814
|
+
row.insertBefore(message, chips);
|
|
3815
|
+
describedBy.push(message.id);
|
|
3816
|
+
}
|
|
3817
|
+
if (describedBy.length)
|
|
3818
|
+
control.setAttribute('aria-describedby', describedBy.join(' '));
|
|
3819
|
+
if (field.kind === 'expression' && field.expression?.help) {
|
|
3820
|
+
const details = document.createElement('details');
|
|
3821
|
+
details.setAttribute('part', 'expression-help');
|
|
3822
|
+
details.dataset.helpField = field.key;
|
|
3823
|
+
details.dataset.session = String(this.layoutEditSession);
|
|
3824
|
+
details.open = helpOpen;
|
|
3825
|
+
const summary = document.createElement('summary');
|
|
3826
|
+
summary.textContent = field.expression.help.summary;
|
|
3827
|
+
details.append(summary);
|
|
3828
|
+
const list = document.createElement('ul');
|
|
3829
|
+
details.append(list);
|
|
3830
|
+
for (const example of field.expression.help.examples) {
|
|
3831
|
+
const paragraph = document.createElement('li'), code = document.createElement('code');
|
|
3832
|
+
code.textContent = example.expression;
|
|
3833
|
+
paragraph.append(code, document.createTextNode(' '));
|
|
3834
|
+
if (example.segments)
|
|
3835
|
+
for (const segment of example.segments) {
|
|
3836
|
+
const run = document.createElement(segment.format === 'code' ? 'code' : 'span');
|
|
3837
|
+
run.textContent = segment.text;
|
|
3838
|
+
paragraph.append(run);
|
|
3839
|
+
}
|
|
3840
|
+
else
|
|
3841
|
+
paragraph.append(document.createTextNode(example.description));
|
|
3842
|
+
list.append(paragraph);
|
|
3843
|
+
}
|
|
3844
|
+
row.append(details);
|
|
3288
3845
|
}
|
|
3289
3846
|
return row;
|
|
3290
3847
|
}
|
|
3291
3848
|
renderSelection() {
|
|
3849
|
+
this.shadowRoot?.querySelectorAll('[part=note]').forEach(note => { note.dataset.selected = String(this.selectedNoteIds.has(note.dataset.noteId)); note.setAttribute('aria-pressed', note.dataset.selected); });
|
|
3292
3850
|
if (!this.isRendered)
|
|
3293
3851
|
return;
|
|
3294
3852
|
const focusedControl = this.shadowRoot.activeElement;
|
|
3295
|
-
const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable;
|
|
3853
|
+
const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable ?? focusedControl?.dataset.localVariable;
|
|
3296
3854
|
const variableKey = focusedControl?.dataset.variableKey;
|
|
3297
|
-
const
|
|
3855
|
+
const fieldBox = focusedControl?.dataset.fieldBox;
|
|
3856
|
+
const localKey = focusedControl?.dataset.localKey, localName = focusedControl?.dataset.localName;
|
|
3857
|
+
const focusedType = focusedControl?.dataset.field !== undefined ? 'field' : focusedControl?.dataset.variable !== undefined ? 'variable' : focusedControl?.dataset.localVariable !== undefined ? 'localVariable' : null;
|
|
3298
3858
|
const caret = focusedControl && 'selectionStart' in focusedControl ? focusedControl.selectionStart : null;
|
|
3859
|
+
const selectionEnd = focusedControl && 'selectionEnd' in focusedControl ? focusedControl.selectionEnd : null;
|
|
3860
|
+
const direction = focusedControl && 'selectionDirection' in focusedControl ? focusedControl.selectionDirection : null;
|
|
3861
|
+
const scrollTop = focusedControl?.scrollTop, scrollLeft = focusedControl?.scrollLeft;
|
|
3299
3862
|
const restoreControlFocus = () => {
|
|
3300
3863
|
if (focusedKey === undefined || !focusedType)
|
|
3301
3864
|
return;
|
|
3865
|
+
if (focusedType === 'field' && (this.selected?.id !== fieldBox || this.locked))
|
|
3866
|
+
return;
|
|
3302
3867
|
const controls = Array.from(this.shadowRoot.querySelectorAll('input,textarea,select,button'));
|
|
3303
|
-
const control = controls.find(item => item.dataset[focusedType] === focusedKey && (focusedType !== 'variable' || item.dataset.variableKey === variableKey))
|
|
3868
|
+
const control = controls.find(item => item.dataset[focusedType] === focusedKey && (focusedType !== 'field' || item.dataset.fieldBox === fieldBox) && (focusedType !== 'variable' || item.dataset.variableKey === variableKey) && focusedType !== 'localVariable')
|
|
3304
3869
|
?? (focusedType === 'variable' ? controls.find(item => this.pendingVariableRename?.name === focusedKey && item.dataset.variable === this.pendingVariableRename.value && item.dataset.variableKey === variableKey) ?? controls.find(item => item.dataset.variableKey === 'add') : undefined);
|
|
3305
|
-
if (
|
|
3870
|
+
if (focusedType === 'localVariable') {
|
|
3871
|
+
if (focusedControl?.dataset.localAmbiguous === 'true') {
|
|
3872
|
+
focusedControl.blur();
|
|
3873
|
+
return;
|
|
3874
|
+
}
|
|
3875
|
+
const names = [localName];
|
|
3876
|
+
if (this.pendingLocalRename?.boxId === focusedKey && this.pendingLocalRename.name === localName && !this.pendingLocalRename.ambiguous)
|
|
3877
|
+
names.push(this.pendingLocalRename.value);
|
|
3878
|
+
let localControl = undefined;
|
|
3879
|
+
for (const name of names) {
|
|
3880
|
+
const matches = controls.filter(item => item.dataset.localVariable === focusedKey && item.dataset.localKey === localKey && item.dataset.localName === name);
|
|
3881
|
+
if (matches.length === 1) {
|
|
3882
|
+
localControl = matches[0];
|
|
3883
|
+
break;
|
|
3884
|
+
}
|
|
3885
|
+
if (matches.length > 1)
|
|
3886
|
+
break;
|
|
3887
|
+
}
|
|
3888
|
+
if (!localControl) {
|
|
3889
|
+
focusedControl?.blur();
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
localControl.focus();
|
|
3893
|
+
if (caret !== null && localControl instanceof HTMLInputElement)
|
|
3894
|
+
localControl.setSelectionRange(caret, caret);
|
|
3895
|
+
return;
|
|
3896
|
+
}
|
|
3897
|
+
if (!control || control.disabled || control.closest('[inert]'))
|
|
3306
3898
|
return;
|
|
3307
|
-
control.focus();
|
|
3899
|
+
control.focus({ preventScroll: true });
|
|
3308
3900
|
if (caret !== null && (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) && control.type !== 'number' && control.type !== 'checkbox')
|
|
3309
|
-
control.setSelectionRange(caret, caret);
|
|
3901
|
+
control.setSelectionRange(caret, selectionEnd ?? caret, direction ?? undefined);
|
|
3902
|
+
if (scrollTop !== undefined)
|
|
3903
|
+
control.scrollTop = scrollTop;
|
|
3904
|
+
if (scrollLeft !== undefined)
|
|
3905
|
+
control.scrollLeft = scrollLeft;
|
|
3310
3906
|
};
|
|
3311
3907
|
this.shadowRoot.querySelectorAll('[part=minimap] [data-map-box]').forEach(rect => rect.dataset.selected = String(this.selectedIds.has(rect.dataset.mapBox)));
|
|
3312
|
-
this.shadowRoot.querySelectorAll('[part=line]').forEach(line => { const selected = line.dataset.lineId
|
|
3908
|
+
this.shadowRoot.querySelectorAll('[part=line]').forEach(line => { const selected = this.selectedLineIds.has(line.dataset.lineId); line.dataset.selected = String(selected); line.setAttribute('marker-end', `url(#${selected ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`); });
|
|
3313
3909
|
this.shadowRoot.querySelectorAll("[data-box-id]").forEach((element) => {
|
|
3314
3910
|
element.setAttribute("aria-current", String(this.selectedIds.has(element.dataset.boxId)));
|
|
3315
3911
|
const box = this.projection.boxes.find((box) => box.id === element.dataset.boxId);
|
|
@@ -3319,9 +3915,10 @@ export class ProcessModeler extends BaseElement {
|
|
|
3319
3915
|
});
|
|
3320
3916
|
const editor = this.shadowRoot.querySelector("[part=editor]");
|
|
3321
3917
|
const selected = this.selected;
|
|
3322
|
-
const
|
|
3918
|
+
const fallbackScope = selected && (this.fieldsValue[selected.id] ?? []).some(field => field.kind === 'expression' && field.expression?.variables === undefined) ? this.variables.map(({ name, description }) => ({ name, description })) : undefined;
|
|
3919
|
+
const controlsKey = `${JSON.stringify(fallbackScope)}|${[...this.selectedIds].join(',')}|${this.locked}|${this.disableConnections}|${this.showLastRunValue}|${selected?.runMetrics}|${JSON.stringify([selected?.localVariables, selected?.localVariablesEditable, selected?.frame])}|${JSON.stringify(selected ? this.fieldsValue[selected.id] ?? [] : [])}`;
|
|
3323
3920
|
this.shadowRoot.querySelector("[part=palette]").hidden = false;
|
|
3324
|
-
this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.
|
|
3921
|
+
this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selection.length === 0;
|
|
3325
3922
|
this.renderSelectionToolbar();
|
|
3326
3923
|
this.renderPane();
|
|
3327
3924
|
this.positionSelectionToolbar();
|
|
@@ -3330,9 +3927,22 @@ export class ProcessModeler extends BaseElement {
|
|
|
3330
3927
|
this.inspectedControls === controlsKey &&
|
|
3331
3928
|
editor.querySelector(`[part=inspector-heading]`)?.tagName === `H${this.headingLevel}`) {
|
|
3332
3929
|
editor.querySelector("[part=inspector-heading]").textContent = selected?.title ?? "";
|
|
3930
|
+
const locals = editor.querySelector('[part=local-variables]');
|
|
3931
|
+
if (selected && locals && locals.dataset.localEchoRevision !== String(this.localEchoRevision)) {
|
|
3932
|
+
// Reconcile controlled local drafts without disposing the host's custom
|
|
3933
|
+
// inspector or unrelated focused controls on unchanged document echoes.
|
|
3934
|
+
locals.replaceWith(this.renderLocalVariables(selected));
|
|
3935
|
+
}
|
|
3333
3936
|
restoreControlFocus();
|
|
3334
3937
|
return;
|
|
3335
3938
|
}
|
|
3939
|
+
const expressionHelp = new Map();
|
|
3940
|
+
if (this.inspectedId === selected?.id && this.selectedIds.size === 1) {
|
|
3941
|
+
for (const help of Array.from(editor.querySelectorAll('[part=expression-help]'))) {
|
|
3942
|
+
if (help.dataset.session === String(this.layoutEditSession))
|
|
3943
|
+
expressionHelp.set(help.dataset.helpField, help.open);
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3336
3946
|
this.cleanupInspector?.();
|
|
3337
3947
|
this.cleanupInspector = undefined;
|
|
3338
3948
|
editor.replaceChildren();
|
|
@@ -3382,7 +3992,9 @@ export class ProcessModeler extends BaseElement {
|
|
|
3382
3992
|
editor.append(purpose);
|
|
3383
3993
|
}
|
|
3384
3994
|
for (const field of this.fieldsValue[selected.id] ?? [])
|
|
3385
|
-
editor.append(this.renderField(selected, field));
|
|
3995
|
+
editor.append(this.renderField(selected, field, expressionHelp.get(field.key)));
|
|
3996
|
+
if (selected.localVariables !== undefined)
|
|
3997
|
+
editor.append(this.renderLocalVariables(selected));
|
|
3386
3998
|
this.cleanupInspector =
|
|
3387
3999
|
this.renderer?.(selected.node, editor) || undefined;
|
|
3388
4000
|
const outgoing = this.projection.lines.filter(line => line.from === selected.id);
|
|
@@ -3469,14 +4081,118 @@ export class ProcessModeler extends BaseElement {
|
|
|
3469
4081
|
}
|
|
3470
4082
|
restoreControlFocus();
|
|
3471
4083
|
}
|
|
4084
|
+
renderLocalVariables(box) {
|
|
4085
|
+
const section = document.createElement('section');
|
|
4086
|
+
section.setAttribute('part', 'local-variables');
|
|
4087
|
+
section.dataset.localEchoRevision = String(this.localEchoRevision);
|
|
4088
|
+
const heading = document.createElement('h3');
|
|
4089
|
+
heading.textContent = box.frame ? 'Variables for the steps inside' : 'Variables for this step';
|
|
4090
|
+
const help = document.createElement('p');
|
|
4091
|
+
help.textContent = box.frame ? 'Every step inside can read these; they’re gone once the frame is done.' : 'Only this step’s inputs can read these; they’re gone once it’s done.';
|
|
4092
|
+
const collection = document.createElement('div');
|
|
4093
|
+
collection.setAttribute('part', 'local-variable-list');
|
|
4094
|
+
section.append(heading, help, collection);
|
|
4095
|
+
for (const [index, variable] of (box.localVariables ?? []).entries()) {
|
|
4096
|
+
const row = document.createElement('div');
|
|
4097
|
+
row.setAttribute('part', 'local-variable-row');
|
|
4098
|
+
row.setAttribute('role', 'group');
|
|
4099
|
+
row.setAttribute('aria-label', `Step variable ${index + 1}`);
|
|
4100
|
+
const identify = (control, key) => { control.dataset.localVariable = box.id; control.dataset.localIndex = String(index); control.dataset.localKey = key; control.dataset.localName = variable.name; control.dataset.localAmbiguous = String(box.localVariables?.filter(item => item.name === variable.name).length !== 1); };
|
|
4101
|
+
for (const [key, label, value] of [['rename', 'Name', variable.name], ['starting-value', 'Starts as', variable.startingValue ?? '']]) {
|
|
4102
|
+
const field = variableLabel(label);
|
|
4103
|
+
const input = document.createElement('input');
|
|
4104
|
+
input.type = 'text';
|
|
4105
|
+
input.placeholder = key === 'rename' ? 'name' : 'starts as';
|
|
4106
|
+
input.spellcheck = false;
|
|
4107
|
+
input.autocomplete = 'off';
|
|
4108
|
+
input.value = value;
|
|
4109
|
+
input.disabled = this.locked || !box.localVariablesEditable;
|
|
4110
|
+
identify(input, key);
|
|
4111
|
+
if (variable.problem) {
|
|
4112
|
+
input.setAttribute('aria-invalid', 'true');
|
|
4113
|
+
input.setAttribute('aria-describedby', `process-local-problem-${encodeURIComponent(box.id)}-${index}`);
|
|
4114
|
+
}
|
|
4115
|
+
input.oninput = () => {
|
|
4116
|
+
if (key === 'rename')
|
|
4117
|
+
this.pendingLocalRename = { boxId: box.id, name: variable.name, value: input.value, echoRevision: this.localEchoRevision, ambiguous: input.dataset.localAmbiguous === 'true' };
|
|
4118
|
+
this.requestLocalVariableEdit(box, { type: key, index, name: variable.name, value: input.value });
|
|
4119
|
+
};
|
|
4120
|
+
field.append(input);
|
|
4121
|
+
row.append(field);
|
|
4122
|
+
}
|
|
4123
|
+
if (box.localVariablesEditable) {
|
|
4124
|
+
const remove = document.createElement('button');
|
|
4125
|
+
remove.type = 'button';
|
|
4126
|
+
remove.append(variableGlyph());
|
|
4127
|
+
remove.setAttribute('aria-label', `Remove ${variable.name || 'this variable'}`);
|
|
4128
|
+
remove.disabled = this.locked;
|
|
4129
|
+
identify(remove, 'remove');
|
|
4130
|
+
remove.onclick = () => this.requestLocalVariableEdit(box, { type: 'remove', index, name: variable.name });
|
|
4131
|
+
row.append(remove);
|
|
4132
|
+
}
|
|
4133
|
+
if (variable.problem) {
|
|
4134
|
+
const problem = document.createElement('small');
|
|
4135
|
+
problem.setAttribute('part', 'field-problem');
|
|
4136
|
+
problem.id = `process-local-problem-${encodeURIComponent(box.id)}-${index}`;
|
|
4137
|
+
problem.textContent = variable.problem;
|
|
4138
|
+
row.append(problem);
|
|
4139
|
+
}
|
|
4140
|
+
collection.append(row);
|
|
4141
|
+
}
|
|
4142
|
+
if (box.localVariablesEditable) {
|
|
4143
|
+
const add = document.createElement('button');
|
|
4144
|
+
add.type = 'button';
|
|
4145
|
+
add.append(variableGlyph(true), document.createTextNode('Add a variable'));
|
|
4146
|
+
add.disabled = this.locked;
|
|
4147
|
+
add.dataset.localVariable = box.id;
|
|
4148
|
+
add.dataset.localKey = 'add';
|
|
4149
|
+
add.onclick = () => this.requestLocalVariableEdit(box, { type: 'add' });
|
|
4150
|
+
const actions = document.createElement('div');
|
|
4151
|
+
actions.setAttribute('part', 'local-variable-actions');
|
|
4152
|
+
actions.append(add);
|
|
4153
|
+
section.append(actions);
|
|
4154
|
+
}
|
|
4155
|
+
return section;
|
|
4156
|
+
}
|
|
4157
|
+
requestLocalVariableEdit(box, edit) {
|
|
4158
|
+
const owner = this.selected;
|
|
4159
|
+
if (this.locked || !owner?.localVariablesEditable || owner.id !== box.id)
|
|
4160
|
+
return;
|
|
4161
|
+
if (edit.type !== 'add' && owner.localVariables?.[edit.index]?.name !== edit.name)
|
|
4162
|
+
return;
|
|
4163
|
+
const request = { boxId: owner.id, ...(owner.path ? { path: [...owner.path] } : {}), edit };
|
|
4164
|
+
const session = this.layoutEditSession, beforeNames = (owner.localVariables ?? []).map(variable => variable.name), beforeCount = beforeNames.length;
|
|
4165
|
+
emit(this, 'process-local-variable-edit-request', request);
|
|
4166
|
+
if (session !== this.layoutEditSession || this.selected?.id !== box.id)
|
|
4167
|
+
return;
|
|
4168
|
+
const count = this.selected.localVariables?.length ?? 0;
|
|
4169
|
+
if (edit.type === 'add' && count > beforeCount) {
|
|
4170
|
+
const remaining = [...beforeNames];
|
|
4171
|
+
const added = (this.selected.localVariables ?? []).filter(variable => {
|
|
4172
|
+
const index = remaining.indexOf(variable.name);
|
|
4173
|
+
if (index < 0)
|
|
4174
|
+
return true;
|
|
4175
|
+
remaining.splice(index, 1);
|
|
4176
|
+
return false;
|
|
4177
|
+
});
|
|
4178
|
+
if (added.length === 1 && this.selected.localVariables?.filter(variable => variable.name === added[0].name).length === 1) {
|
|
4179
|
+
const name = Array.from(this.shadowRoot.querySelectorAll('[data-local-key=rename]')).find(input => input.dataset.localVariable === box.id && input.dataset.localName === added[0].name);
|
|
4180
|
+
name?.focus();
|
|
4181
|
+
name?.select();
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
else if (edit.type === 'remove' && count < beforeCount) {
|
|
4185
|
+
Array.from(this.shadowRoot.querySelectorAll('[data-local-key=add]')).find(button => button.dataset.localVariable === box.id)?.focus();
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
3472
4188
|
renderSelectionToolbar() {
|
|
3473
4189
|
const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
|
|
3474
|
-
const key = `${
|
|
4190
|
+
const key = `${JSON.stringify(this.selection)}|${this.graphSelection}|${this.selected?.kind}|${this.selected?.shape}|${this.selectedLineId}|${Boolean(this.selectedLine && (this.layoutValue.lines?.[this.selectedLine.id] || this.selectedLine.fromSide || this.selectedLine.toSide || this.selectedLine.points?.length))}|${this.locked}|${this.narrowValue}|${this.projection.lines.filter(line => line.from === this.selectedId).map(line => `${line.id}:${line.label}:${line.dashed}`).join(',')}`;
|
|
3475
4191
|
if (key === this.selectionToolbarKey)
|
|
3476
4192
|
return;
|
|
3477
4193
|
this.selectionToolbarKey = key;
|
|
3478
4194
|
toolbar.replaceChildren();
|
|
3479
|
-
if (!this.
|
|
4195
|
+
if (!this.selection.length)
|
|
3480
4196
|
return;
|
|
3481
4197
|
const add = (label, command, action, withPlus = false) => {
|
|
3482
4198
|
const button = document.createElement('button');
|
|
@@ -3497,7 +4213,12 @@ export class ProcessModeler extends BaseElement {
|
|
|
3497
4213
|
button.append(text);
|
|
3498
4214
|
toolbar.append(button);
|
|
3499
4215
|
};
|
|
3500
|
-
if (this.
|
|
4216
|
+
if (this.graphSelection && (this.selectedNoteIds.size || this.selectedLineIds.size > 1 || this.selectedLineIds.size && this.selectedIds.size)) {
|
|
4217
|
+
if (this.selectedIds.size > 1)
|
|
4218
|
+
add('Line up', 'align', () => this.selectionCommand('align'));
|
|
4219
|
+
add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
|
|
4220
|
+
}
|
|
4221
|
+
else if (this.selectedLine) {
|
|
3501
4222
|
const line = this.selectedLine;
|
|
3502
4223
|
add('Insert a step', 'insert', () => this.openLineChooser(line, toolbar.querySelector('[data-selection-command=insert]') ?? undefined), true);
|
|
3503
4224
|
if (this.layoutValue.lines?.[line.id] || line.fromSide || line.toSide || line.points?.length)
|
|
@@ -3513,7 +4234,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
3513
4234
|
if (!['end', 'finish', 'section', 'note'].includes(box.kind))
|
|
3514
4235
|
add(box.shape === 'gateway' ? 'Add a path' : 'Add next', 'add-next', () => this.openAnchoredChooser(this.nextEdit(box), this.nextChooserTitle(box), toolbar.querySelector('[data-selection-command=add-next]') ?? undefined), true);
|
|
3515
4236
|
if (failurePath)
|
|
3516
|
-
add('If it fails', 'add-failure', () => this.openAnchoredChooser({ type: 'add', from: box.id, routeLabel: 'If it fails', dashed: true }, `If a step in ${box.title} fails`, toolbar.querySelector('[data-selection-command=add-failure]') ?? undefined, Array.from(this.shadowRoot.querySelectorAll('[data-box-id]')).find(element => element.dataset.boxId === box.id)), true);
|
|
4237
|
+
add('If it fails', 'add-failure', () => this.openAnchoredChooser({ type: 'add', from: box.id, routeLabel: 'If it fails', dashed: true, placement: { source: 'next' } }, `If a step in ${box.title} fails`, toolbar.querySelector('[data-selection-command=add-failure]') ?? undefined, Array.from(this.shadowRoot.querySelectorAll('[data-box-id]')).find(element => element.dataset.boxId === box.id)), true);
|
|
3517
4238
|
if (!['start', 'timer', 'section'].includes(box.kind))
|
|
3518
4239
|
add('Duplicate', 'duplicate', () => this.duplicateBoxes([box.id], 48));
|
|
3519
4240
|
if (!['start', 'timer'].includes(box.kind))
|
|
@@ -3530,25 +4251,56 @@ export class ProcessModeler extends BaseElement {
|
|
|
3530
4251
|
button("undo").disabled = !this.history.canUndo || this.locked;
|
|
3531
4252
|
button("redo").disabled = !this.history.canRedo || this.locked;
|
|
3532
4253
|
button("tidy").disabled = this.locked;
|
|
4254
|
+
this.shadowRoot.querySelector('[part=toolbar-title]').textContent = this.processTitleValue;
|
|
4255
|
+
for (const command of ['undo', 'redo', 'tidy'])
|
|
4256
|
+
this.shadowRoot.querySelector(`[data-view-option=${command}]`).disabled = button(command).disabled;
|
|
3533
4257
|
button("lock").setAttribute("aria-pressed", String(this.locked));
|
|
3534
4258
|
button("snap").setAttribute("aria-pressed", String(this.snapToGrid));
|
|
3535
4259
|
this.shadowRoot.querySelectorAll('[data-detail]').forEach(control => {
|
|
3536
|
-
control.
|
|
4260
|
+
if (!control.closest('[part=view-menu]'))
|
|
4261
|
+
control.setAttribute("aria-pressed", String(control.dataset.detail === this.detailValue));
|
|
4262
|
+
if (control.closest('[part=view-menu]'))
|
|
4263
|
+
control.setAttribute('aria-checked', String(control.dataset.detail === this.detailValue));
|
|
4264
|
+
if (control.closest('[part=view-menu]'))
|
|
4265
|
+
control.textContent = `${control.dataset.detail === this.detailValue ? '✓ ' : ''}${control.dataset.detail === 'business' ? 'Business view' : 'Technical view'}`;
|
|
3537
4266
|
});
|
|
3538
4267
|
const runToggle = this.shadowRoot.querySelector('[part=run-toggle]');
|
|
3539
4268
|
runToggle.hidden = !this.lastRunValue;
|
|
3540
4269
|
runToggle.querySelector('input').checked = this.showLastRunValue;
|
|
3541
4270
|
const mobileRun = this.shadowRoot.querySelector('[data-view-option=last-run]');
|
|
3542
4271
|
mobileRun.hidden = !this.lastRunValue;
|
|
3543
|
-
mobileRun.
|
|
4272
|
+
mobileRun.removeAttribute('aria-pressed');
|
|
4273
|
+
mobileRun.textContent = this.showLastRunValue ? 'Hide last run' : 'Show last run';
|
|
3544
4274
|
const checksStatus = button('checks-status');
|
|
3545
4275
|
const count = this.allChecks.length;
|
|
3546
|
-
|
|
4276
|
+
const glyph = svgElement('svg');
|
|
4277
|
+
glyph.setAttribute('viewBox', '0 0 16 16');
|
|
4278
|
+
glyph.setAttribute('aria-hidden', 'true');
|
|
4279
|
+
const circle = svgElement('circle');
|
|
4280
|
+
circle.setAttribute('cx', '8');
|
|
4281
|
+
circle.setAttribute('cy', '8');
|
|
4282
|
+
circle.setAttribute('r', '7');
|
|
4283
|
+
circle.setAttribute('fill', 'currentColor');
|
|
4284
|
+
const mark = svgElement('path');
|
|
4285
|
+
mark.setAttribute('d', count ? 'm5 5 6 6m0-6-6 6' : 'm4.5 8 2.5 2.5 4.5-5');
|
|
4286
|
+
mark.setAttribute('stroke', 'var(--boe-token-surface-surface, #fff)');
|
|
4287
|
+
mark.setAttribute('stroke-width', '1.5');
|
|
4288
|
+
mark.setAttribute('fill', 'none');
|
|
4289
|
+
mark.setAttribute('stroke-linecap', 'round');
|
|
4290
|
+
mark.setAttribute('stroke-linejoin', 'round');
|
|
4291
|
+
glyph.append(circle, mark);
|
|
4292
|
+
const label = document.createElement('span');
|
|
4293
|
+
label.textContent = count ? `${count} ${count === 1 ? 'thing needs' : 'things need'} attention` : 'Ready to run';
|
|
4294
|
+
checksStatus.replaceChildren(glyph, label);
|
|
3547
4295
|
checksStatus.dataset.state = count ? 'bad' : 'ready';
|
|
4296
|
+
this.shadowRoot.querySelector('[data-view-option=checks]').textContent = count ? `Checks (${count})` : 'Checks: ready to run';
|
|
3548
4297
|
}
|
|
3549
4298
|
paintViewport() {
|
|
3550
4299
|
const world = this.shadowRoot.querySelector("[part=world]");
|
|
3551
4300
|
world.style.setProperty('--boe-process-inverse-zoom', String(1 / this.viewport.zoom));
|
|
4301
|
+
// A small screen-space margin keeps targets above 24px after fractional
|
|
4302
|
+
// layout rounding without assuming a browser's CSS-pixel quantum.
|
|
4303
|
+
world.style.setProperty('--boe-process-hit-target', `${24.1 / this.viewport.zoom}px`);
|
|
3552
4304
|
world.style.transform =
|
|
3553
4305
|
`translate(${this.viewport.x}px,${this.viewport.y}px) scale(${this.viewport.zoom})`;
|
|
3554
4306
|
const canvas = this.shadowRoot.querySelector("[part=canvas]");
|
|
@@ -3596,17 +4348,27 @@ export class ProcessModeler extends BaseElement {
|
|
|
3596
4348
|
minimap.append(viewport);
|
|
3597
4349
|
}
|
|
3598
4350
|
positionSelectionToolbar() {
|
|
3599
|
-
if (!this.isRendered ||
|
|
4351
|
+
if (!this.isRendered || !this.selection.length)
|
|
3600
4352
|
return;
|
|
3601
4353
|
const canvas = this.shadowRoot.querySelector('[part=canvas]');
|
|
3602
4354
|
const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
|
|
3603
4355
|
const positions = [...this.selectedIds].map(id => this.layoutValue.boxes[id]).filter(Boolean);
|
|
4356
|
+
positions.push(...(this.layoutValue.notes ?? []).filter(note => this.selectedNoteIds.has(note.id)).map(note => ({ ...note, width: 208, height: 80 })));
|
|
3604
4357
|
if (!positions.length && this.selectedLine) {
|
|
3605
4358
|
const point = lineMidpoint(this.routedLines.get(this.selectedLine.id) ?? []);
|
|
3606
4359
|
toolbar.style.left = `${Math.max(8, Math.min(point.x * this.viewport.zoom + this.viewport.x, Math.max(8, canvas.clientWidth - toolbar.offsetWidth - 8)))}px`;
|
|
3607
4360
|
toolbar.style.top = `${Math.max(8, point.y * this.viewport.zoom + this.viewport.y - 52)}px`;
|
|
3608
4361
|
return;
|
|
3609
4362
|
}
|
|
4363
|
+
if (!positions.length) {
|
|
4364
|
+
for (const id of this.selectedLineIds) {
|
|
4365
|
+
const points = this.routedLines.get(id) ?? [];
|
|
4366
|
+
if (!points.length)
|
|
4367
|
+
continue;
|
|
4368
|
+
const x = Math.min(...points.map(point => point.x)), y = Math.min(...points.map(point => point.y));
|
|
4369
|
+
positions.push({ x, y, width: Math.max(...points.map(point => point.x)) - x, height: Math.max(...points.map(point => point.y)) - y });
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
3610
4372
|
if (!positions.length)
|
|
3611
4373
|
return;
|
|
3612
4374
|
const left = Math.min(...positions.map(position => position.x * this.viewport.zoom + this.viewport.x));
|