@unofficialbox/box-open-elements 0.32.0 → 0.33.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 +39 -2
- package/dist/patterns/process-modeler/model.d.ts +91 -1
- package/dist/patterns/process-modeler/process-modeler.d.ts +55 -1
- package/dist/patterns/process-modeler/process-modeler.js +649 -109
- 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,6 +214,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
167
214
|
};
|
|
168
215
|
}
|
|
169
216
|
load(document, options = {}) {
|
|
217
|
+
this.pendingLocalRename = undefined;
|
|
218
|
+
this.layoutEditSession++;
|
|
170
219
|
this.cancelPalettePointer();
|
|
171
220
|
const projection = this.model.project(document);
|
|
172
221
|
validateProjection(projection);
|
|
@@ -177,6 +226,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
177
226
|
this.lastReadableProjection = "";
|
|
178
227
|
this.readableValue = null;
|
|
179
228
|
this.layoutValue = restoreProcessPositions(projection, options.positions ?? []);
|
|
229
|
+
this.clearMixedSelection();
|
|
180
230
|
this.history.clear();
|
|
181
231
|
this.selectedId = null;
|
|
182
232
|
this.selectedLineId = null;
|
|
@@ -192,15 +242,54 @@ export class ProcessModeler extends BaseElement {
|
|
|
192
242
|
validateProjection(this.projection);
|
|
193
243
|
}
|
|
194
244
|
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)))
|
|
245
|
+
if (!this.graphSelection && !this.selectedLineIds.size && !this.selectedNoteIds.size && next.size === this.selectedIds.size && [...next].every(id => this.selectedIds.has(id)))
|
|
196
246
|
return;
|
|
247
|
+
this.clearMixedSelection();
|
|
197
248
|
this.selectedIds = next;
|
|
198
249
|
this.selectedId = this.selectedIds.values().next().value ?? null;
|
|
199
250
|
this.selectedLineId = null;
|
|
200
251
|
this.renderSelection();
|
|
201
252
|
if (this.isRendered)
|
|
202
253
|
this.updateToolbar();
|
|
203
|
-
|
|
254
|
+
this.notify("selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, ...(this.graphSelection ? { selection: this.selection } : {}) });
|
|
255
|
+
}
|
|
256
|
+
/** Detached mixed selection. Invalid and duplicate IDs are ignored by selectItems. */
|
|
257
|
+
get selection() {
|
|
258
|
+
return [...[...this.selectedIds].map(id => ({ type: 'box', id })),
|
|
259
|
+
...[...this.selectedLineIds].map(id => ({ type: 'line', id })),
|
|
260
|
+
...[...this.selectedNoteIds].map(id => ({ type: 'note', id }))];
|
|
261
|
+
}
|
|
262
|
+
selectItems(items) {
|
|
263
|
+
if (!this.isRendered && this.documentValue !== undefined) {
|
|
264
|
+
this.projection = this.model.project(this.documentValue);
|
|
265
|
+
validateProjection(this.projection);
|
|
266
|
+
}
|
|
267
|
+
const boxes = new Set(), lines = new Set(), notes = new Set();
|
|
268
|
+
for (const item of items) {
|
|
269
|
+
if (item.type === 'box' && this.projection.boxes.some(box => box.id === item.id))
|
|
270
|
+
boxes.add(item.id);
|
|
271
|
+
if (item.type === 'line' && this.projection.lines.some(line => line.id === item.id))
|
|
272
|
+
lines.add(item.id);
|
|
273
|
+
if (item.type === 'note' && this.layoutValue.notes?.some(note => note.id === item.id))
|
|
274
|
+
notes.add(item.id);
|
|
275
|
+
}
|
|
276
|
+
const before = JSON.stringify(this.selection);
|
|
277
|
+
this.selectedIds = boxes;
|
|
278
|
+
this.selectedLineIds = lines;
|
|
279
|
+
this.selectedNoteIds = notes;
|
|
280
|
+
this.selectedId = boxes.values().next().value ?? null;
|
|
281
|
+
this.selectedLineId = !boxes.size && !notes.size && lines.size === 1 ? lines.values().next().value : null;
|
|
282
|
+
this.graphSelection = true;
|
|
283
|
+
this.renderSelection();
|
|
284
|
+
if (this.isRendered)
|
|
285
|
+
this.updateToolbar();
|
|
286
|
+
if (before !== JSON.stringify(this.selection))
|
|
287
|
+
this.notify('selection-changed', { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, selection: this.selection });
|
|
288
|
+
}
|
|
289
|
+
clearMixedSelection() {
|
|
290
|
+
this.selectedLineIds.clear();
|
|
291
|
+
this.selectedNoteIds.clear();
|
|
292
|
+
this.graphSelection = false;
|
|
204
293
|
}
|
|
205
294
|
get headingLevel() { const level = Number(this.getAttribute("heading-level") ?? 2); return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2; }
|
|
206
295
|
set headingLevel(value) { this.setAttribute("heading-level", String(value)); }
|
|
@@ -224,8 +313,13 @@ export class ProcessModeler extends BaseElement {
|
|
|
224
313
|
this.refresh();
|
|
225
314
|
}
|
|
226
315
|
set document(value) {
|
|
316
|
+
this.localEchoRevision++;
|
|
317
|
+
const rename = this.pendingLocalRename;
|
|
227
318
|
this.documentValue = value;
|
|
228
319
|
this.refresh();
|
|
320
|
+
// One explicit echo settles this intent, including a rejected unchanged echo.
|
|
321
|
+
if (rename === this.pendingLocalRename && rename && this.localEchoRevision > rename.echoRevision)
|
|
322
|
+
this.pendingLocalRename = undefined;
|
|
229
323
|
}
|
|
230
324
|
get model() {
|
|
231
325
|
return this.modelValue;
|
|
@@ -293,6 +387,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
293
387
|
this.refresh();
|
|
294
388
|
}
|
|
295
389
|
disconnectedCallback() {
|
|
390
|
+
this.layoutEditSession++;
|
|
296
391
|
this.resizeObserver?.disconnect();
|
|
297
392
|
this.clearClipboard();
|
|
298
393
|
this.cancelPalettePointer();
|
|
@@ -327,8 +422,9 @@ export class ProcessModeler extends BaseElement {
|
|
|
327
422
|
}
|
|
328
423
|
if (id !== null && !this.projection.boxes.some((box) => box.id === id))
|
|
329
424
|
return;
|
|
330
|
-
if (id === this.selectedId && this.selectedIds.size === (id ? 1 : 0) && !this.selectedLineId)
|
|
425
|
+
if (id === this.selectedId && this.selectedIds.size === (id ? 1 : 0) && !this.selectedLineId && !this.graphSelection)
|
|
331
426
|
return;
|
|
427
|
+
this.clearMixedSelection();
|
|
332
428
|
this.selectedId = id;
|
|
333
429
|
this.selectedIds = new Set(id ? [id] : []);
|
|
334
430
|
this.selectedLineId = null;
|
|
@@ -336,18 +432,20 @@ export class ProcessModeler extends BaseElement {
|
|
|
336
432
|
if (this.isRendered)
|
|
337
433
|
this.updateToolbar();
|
|
338
434
|
if (notify)
|
|
339
|
-
|
|
435
|
+
this.notify("selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, ...(this.graphSelection ? { selection: this.selection } : {}) });
|
|
340
436
|
}
|
|
341
437
|
selectLine(id) {
|
|
342
438
|
if (id !== null && !this.projection.lines.some(line => line.id === id))
|
|
343
439
|
return;
|
|
344
|
-
if (this.selectedLineId === id && !this.selectedIds.size)
|
|
440
|
+
if (!this.graphSelection && this.selectedLineId === id && !this.selectedIds.size)
|
|
345
441
|
return;
|
|
442
|
+
this.clearMixedSelection();
|
|
443
|
+
this.selectedLineIds = new Set(id ? [id] : []);
|
|
346
444
|
this.selectedLineId = id;
|
|
347
445
|
this.selectedId = null;
|
|
348
446
|
this.selectedIds.clear();
|
|
349
447
|
this.refresh();
|
|
350
|
-
|
|
448
|
+
this.notify('selection-changed', { box: null, boxes: [], line: this.selectedLine, path: null });
|
|
351
449
|
}
|
|
352
450
|
setValidation(checks) {
|
|
353
451
|
this.checks = checks;
|
|
@@ -373,67 +471,136 @@ export class ProcessModeler extends BaseElement {
|
|
|
373
471
|
requestEdit(edit) {
|
|
374
472
|
if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect" || edit.type === 'reattach')))
|
|
375
473
|
return;
|
|
474
|
+
if (edit.type === 'delete-selection' && (!edit.selection?.length || edit.selection.some(item => !['box', 'line', 'note'].includes(item.type) || typeof item.id !== 'string' || !item.id))) {
|
|
475
|
+
this.setStatus('The selected graph items are invalid', true);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (edit.selection)
|
|
479
|
+
edit = { ...edit, selection: Object.freeze(edit.selection.map(item => Object.freeze({ type: item.type, id: item.id }))) };
|
|
480
|
+
if (this.disableConnections && edit.type === 'delete-selection' && edit.selection?.some(item => item.type === 'line')) {
|
|
481
|
+
this.setStatus('Deleting selected lines is disabled');
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const placement = edit.placement;
|
|
485
|
+
if (placement) {
|
|
486
|
+
const source = placement.source;
|
|
487
|
+
let normalized;
|
|
488
|
+
if (edit.type === 'add' || edit.type === 'insert') {
|
|
489
|
+
if (source === 'next')
|
|
490
|
+
normalized = { source };
|
|
491
|
+
else if (source === 'direction') {
|
|
492
|
+
const side = placement.side;
|
|
493
|
+
if (['north', 'east', 'south', 'west'].includes(side))
|
|
494
|
+
normalized = { source, side };
|
|
495
|
+
}
|
|
496
|
+
else if (source === 'point' || source === 'line') {
|
|
497
|
+
const { x, y } = placement.center ?? {};
|
|
498
|
+
if (Number.isFinite(x) && Number.isFinite(y))
|
|
499
|
+
normalized = { source, center: { x, y } };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (!normalized) {
|
|
503
|
+
this.setStatus('The insertion placement is invalid', true);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
edit = { ...edit, placement: normalized };
|
|
507
|
+
}
|
|
376
508
|
const problem = this.connectionProblem(edit);
|
|
377
509
|
if (problem) {
|
|
378
510
|
this.setStatus(problem, true);
|
|
379
511
|
return;
|
|
380
512
|
}
|
|
381
513
|
const request = { ...edit, accept: this.editAcceptance(edit) };
|
|
382
|
-
|
|
514
|
+
if (edit.type !== 'delete-selection') {
|
|
515
|
+
emit(this, 'process-edit-request', request);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
// A synchronous host echo can prune selected lines before accept records
|
|
519
|
+
// history. Keep those observers behind the complete atomic transaction.
|
|
520
|
+
this.acceptanceNotifications++;
|
|
521
|
+
try {
|
|
522
|
+
emit(this, 'process-edit-request', request);
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
this.acceptanceNotifications--;
|
|
526
|
+
this.flushAcceptanceNotifications();
|
|
527
|
+
}
|
|
383
528
|
}
|
|
384
529
|
editAcceptance(edit) {
|
|
385
530
|
const before = this.layout;
|
|
386
531
|
const beforeSelection = [...this.selectedIds];
|
|
532
|
+
const beforeItems = this.selection;
|
|
387
533
|
const previousIds = new Set(this.projection.boxes.map((box) => box.id));
|
|
534
|
+
const session = this.layoutEditSession;
|
|
388
535
|
let accepted = false;
|
|
389
536
|
return (command) => {
|
|
390
|
-
if (accepted)
|
|
537
|
+
if (accepted || session !== this.layoutEditSession)
|
|
391
538
|
return;
|
|
392
539
|
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);
|
|
540
|
+
// Projection/readability/selection observers may synchronously edit or
|
|
541
|
+
// replace the document. Publish only after the whole history entry exists.
|
|
542
|
+
this.acceptanceNotifications++;
|
|
543
|
+
try {
|
|
544
|
+
if (command.layout)
|
|
545
|
+
this.layoutValue = structuredClone(command.layout);
|
|
546
|
+
if (edit.type === 'reset-line' && edit.lineId)
|
|
547
|
+
delete this.layoutValue.lines?.[edit.lineId];
|
|
414
548
|
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
|
-
|
|
549
|
+
if (session !== this.layoutEditSession)
|
|
550
|
+
return;
|
|
551
|
+
const inserted = edit.type === 'insert' && !edit.boxId && !command.layout
|
|
552
|
+
? this.projection.boxes.find(box => !previousIds.has(box.id)) : undefined;
|
|
553
|
+
const reflowed = inserted ? this.reflowStraightInsert(edit, inserted.id, before) : false;
|
|
554
|
+
if (!reflowed &&
|
|
555
|
+
!command.layout &&
|
|
556
|
+
edit.position &&
|
|
557
|
+
Number.isFinite(edit.position.x) &&
|
|
558
|
+
Number.isFinite(edit.position.y)) {
|
|
559
|
+
const added = this.projection.boxes.find(box => box.id === edit.boxId) ?? this.projection.boxes.find((box) => !previousIds.has(box.id));
|
|
560
|
+
if (added)
|
|
561
|
+
this.layoutValue = this.translatedLayout(added.id, edit.position.x, edit.position.y);
|
|
562
|
+
}
|
|
563
|
+
if (session !== this.layoutEditSession)
|
|
564
|
+
return;
|
|
565
|
+
const after = this.layout;
|
|
566
|
+
const acceptedItems = command.selection ? command.selection.map(item => ({ type: item.type, id: item.id })) : undefined;
|
|
567
|
+
const acceptedSelection = command.selectionIds ? [...command.selectionIds] : undefined;
|
|
568
|
+
const restore = (layout) => {
|
|
569
|
+
this.layoutValue = structuredClone(layout);
|
|
570
|
+
this.refresh();
|
|
571
|
+
this.emitPositions();
|
|
572
|
+
};
|
|
573
|
+
this.history.record({
|
|
574
|
+
undo: () => {
|
|
575
|
+
command.undo();
|
|
576
|
+
restore(before);
|
|
577
|
+
if (acceptedItems)
|
|
578
|
+
this.selectItems(beforeItems);
|
|
579
|
+
else if (acceptedSelection)
|
|
580
|
+
this.selectMany(beforeSelection);
|
|
581
|
+
},
|
|
582
|
+
redo: () => {
|
|
583
|
+
command.redo();
|
|
584
|
+
restore(after);
|
|
585
|
+
if (acceptedItems)
|
|
586
|
+
this.selectItems(acceptedItems);
|
|
587
|
+
else if (acceptedSelection)
|
|
588
|
+
this.selectMany(acceptedSelection);
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
if (acceptedItems)
|
|
592
|
+
this.selectItems(acceptedItems);
|
|
593
|
+
else if (acceptedSelection)
|
|
594
|
+
this.selectMany(acceptedSelection);
|
|
595
|
+
this.refresh();
|
|
596
|
+
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
597
|
+
this.emitPositions();
|
|
598
|
+
announce("Process updated", "polite", this.ownerDocument);
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
this.acceptanceNotifications--;
|
|
602
|
+
this.flushAcceptanceNotifications();
|
|
603
|
+
}
|
|
437
604
|
};
|
|
438
605
|
}
|
|
439
606
|
undo() {
|
|
@@ -448,6 +615,53 @@ export class ProcessModeler extends BaseElement {
|
|
|
448
615
|
this.history.redo();
|
|
449
616
|
this.refresh();
|
|
450
617
|
}
|
|
618
|
+
requestLayoutEdit(type, sourceIds, title, onAccepted) {
|
|
619
|
+
const capability = this.model.layoutEdit;
|
|
620
|
+
if (!capability || this.documentValue === undefined)
|
|
621
|
+
return false;
|
|
622
|
+
if (this.locked)
|
|
623
|
+
return true;
|
|
624
|
+
if (!this.isRendered) {
|
|
625
|
+
this.projection = this.model.project(this.documentValue);
|
|
626
|
+
validateProjection(this.projection);
|
|
627
|
+
}
|
|
628
|
+
const session = this.layoutEditSession;
|
|
629
|
+
const accept = this.editAcceptance({ type });
|
|
630
|
+
let active = true, settled = false, acceptedLayout = false;
|
|
631
|
+
const valid = () => active && !settled && session === this.layoutEditSession && !this.locked;
|
|
632
|
+
const request = {
|
|
633
|
+
type, sourceIds: sourceIds ? Object.freeze([...sourceIds]) : null, ...(title !== undefined ? { title } : {}), layout: this.layout,
|
|
634
|
+
accept: command => {
|
|
635
|
+
if (!valid())
|
|
636
|
+
return;
|
|
637
|
+
settled = true;
|
|
638
|
+
if (!command.layout) {
|
|
639
|
+
this.setStatus('The host must supply the complete accepted layout', true);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
accept(command);
|
|
643
|
+
acceptedLayout = session === this.layoutEditSession;
|
|
644
|
+
},
|
|
645
|
+
refuse: message => { if (!valid())
|
|
646
|
+
return; settled = true; this.setStatus(message ?? 'The host refused this layout edit', true); },
|
|
647
|
+
};
|
|
648
|
+
// Hosts may assign a fresh document before accepting the request. Its
|
|
649
|
+
// notifications must follow the same atomic history boundary.
|
|
650
|
+
this.acceptanceNotifications++;
|
|
651
|
+
try {
|
|
652
|
+
capability.call(this.model, this.documentValue, request, this.projection);
|
|
653
|
+
}
|
|
654
|
+
finally {
|
|
655
|
+
active = false;
|
|
656
|
+
this.acceptanceNotifications--;
|
|
657
|
+
this.flushAcceptanceNotifications();
|
|
658
|
+
}
|
|
659
|
+
if (acceptedLayout && session === this.layoutEditSession)
|
|
660
|
+
onAccepted?.();
|
|
661
|
+
if (!settled && session === this.layoutEditSession)
|
|
662
|
+
this.setStatus('This layout edit was not accepted by the host', true);
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
451
665
|
commitLayout(next) {
|
|
452
666
|
const before = this.layout;
|
|
453
667
|
const after = structuredClone(next);
|
|
@@ -476,8 +690,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
476
690
|
this.commitLayout(next);
|
|
477
691
|
}
|
|
478
692
|
emitPositions() {
|
|
479
|
-
|
|
480
|
-
|
|
693
|
+
this.notify("layout-changed", { layout: this.layout });
|
|
694
|
+
this.notify("positions-changed", { positions: this.positions, version: this.versionValue });
|
|
481
695
|
}
|
|
482
696
|
translatedLayout(id, x, y) {
|
|
483
697
|
const snap = (n) => (this.snapToGrid ? Math.round(n / 16) * 16 : n);
|
|
@@ -543,6 +757,9 @@ export class ProcessModeler extends BaseElement {
|
|
|
543
757
|
tidy() {
|
|
544
758
|
if (this.locked)
|
|
545
759
|
return;
|
|
760
|
+
if (this.requestLayoutEdit('tidy', null, undefined, () => { if (this.isRendered)
|
|
761
|
+
this.fitTo(this.narrowValue ? 0.55 : 0.7); }))
|
|
762
|
+
return;
|
|
546
763
|
const before = this.layout.boxes;
|
|
547
764
|
this.commitLayout(this.arrangedLayout({ ...this.layout, boxes: {} }));
|
|
548
765
|
if (!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
|
@@ -1026,6 +1243,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
1026
1243
|
});
|
|
1027
1244
|
const content = root.querySelector('[part=pane-content]');
|
|
1028
1245
|
content.hidden = Boolean(selected);
|
|
1246
|
+
content.dataset.pane = this.activePane;
|
|
1029
1247
|
content.setAttribute("aria-labelledby", `process-tab-${this.activePane.toLowerCase()}`);
|
|
1030
1248
|
const checks = root.querySelector('[part=checks]');
|
|
1031
1249
|
checks.hidden = this.activePane !== "Checks";
|
|
@@ -1084,29 +1302,50 @@ export class ProcessModeler extends BaseElement {
|
|
|
1084
1302
|
content.append(checks);
|
|
1085
1303
|
}
|
|
1086
1304
|
else if (this.activePane === "Variables") {
|
|
1087
|
-
|
|
1088
|
-
|
|
1305
|
+
const intro = document.createElement('p');
|
|
1306
|
+
intro.setAttribute('part', 'variables-intro');
|
|
1307
|
+
intro.textContent = 'Values the process keeps while it runs. Decisions and step inputs read them by name, written in CEL.';
|
|
1308
|
+
content.append(intro);
|
|
1309
|
+
const scopes = document.createElement('dl');
|
|
1310
|
+
scopes.setAttribute('part', 'variable-scopes');
|
|
1311
|
+
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.']]) {
|
|
1312
|
+
const term = document.createElement('dt');
|
|
1313
|
+
term.textContent = name;
|
|
1314
|
+
const definition = document.createElement('dd');
|
|
1315
|
+
definition.textContent = meaning;
|
|
1316
|
+
scopes.append(term, definition);
|
|
1317
|
+
}
|
|
1318
|
+
content.append(scopes);
|
|
1319
|
+
const collection = document.createElement('div');
|
|
1320
|
+
collection.setAttribute('part', 'variable-list');
|
|
1321
|
+
content.append(collection);
|
|
1322
|
+
if (!this.variables.length) {
|
|
1323
|
+
const empty = document.createElement('p');
|
|
1324
|
+
empty.textContent = 'None yet.';
|
|
1325
|
+
collection.append(empty);
|
|
1326
|
+
}
|
|
1089
1327
|
for (const [index, variable] of this.variables.entries()) {
|
|
1090
1328
|
const row = document.createElement('div');
|
|
1091
1329
|
row.setAttribute('part', 'variable-row');
|
|
1092
1330
|
const identify = (control, key) => { control.dataset.variable = variable.name; control.dataset.variableKey = key; control.dataset.variableIndex = String(index); };
|
|
1093
1331
|
if (this.variablesEditable) {
|
|
1094
|
-
const label =
|
|
1095
|
-
label.textContent = 'Name';
|
|
1332
|
+
const label = variableLabel('Name');
|
|
1096
1333
|
const name = document.createElement('input');
|
|
1097
1334
|
name.type = 'text';
|
|
1335
|
+
name.placeholder = 'name';
|
|
1336
|
+
name.spellcheck = false;
|
|
1337
|
+
name.autocomplete = 'off';
|
|
1098
1338
|
name.value = variable.name;
|
|
1099
1339
|
name.disabled = this.locked;
|
|
1100
1340
|
identify(name, 'name');
|
|
1101
1341
|
name.onchange = () => this.requestVariableEdit({ type: 'rename', name: variable.name, value: name.value });
|
|
1102
1342
|
label.append(name);
|
|
1103
1343
|
row.append(label);
|
|
1104
|
-
const scopeLabel =
|
|
1105
|
-
scopeLabel.textContent = 'Scope';
|
|
1344
|
+
const scopeLabel = variableLabel('Kept for');
|
|
1106
1345
|
const scope = document.createElement('select');
|
|
1107
1346
|
identify(scope, 'scope');
|
|
1108
1347
|
scope.disabled = this.locked;
|
|
1109
|
-
for (const [value, text] of [['iteration', '
|
|
1348
|
+
for (const [value, text] of [['iteration', 'Iteration'], ['process', 'Process']]) {
|
|
1110
1349
|
const option = document.createElement('option');
|
|
1111
1350
|
option.value = value;
|
|
1112
1351
|
option.textContent = text;
|
|
@@ -1128,16 +1367,32 @@ export class ProcessModeler extends BaseElement {
|
|
|
1128
1367
|
row.append(scope);
|
|
1129
1368
|
}
|
|
1130
1369
|
}
|
|
1131
|
-
if (
|
|
1370
|
+
if (this.variablesEditable) {
|
|
1371
|
+
const label = variableLabel('What it’s for');
|
|
1372
|
+
const about = document.createElement('input');
|
|
1373
|
+
about.type = 'text';
|
|
1374
|
+
about.placeholder = 'What it’s for';
|
|
1375
|
+
about.value = variable.description ?? '';
|
|
1376
|
+
about.disabled = this.locked;
|
|
1377
|
+
identify(about, 'description');
|
|
1378
|
+
about.oninput = () => this.requestVariableEdit({ type: 'description', name: variable.name, value: about.value });
|
|
1379
|
+
label.append(about);
|
|
1380
|
+
row.append(label);
|
|
1381
|
+
}
|
|
1382
|
+
else if (variable.description) {
|
|
1132
1383
|
const description = document.createElement('p');
|
|
1133
1384
|
description.textContent = variable.description;
|
|
1134
1385
|
row.append(description);
|
|
1135
1386
|
}
|
|
1136
1387
|
if (variable.startingValue !== undefined) {
|
|
1137
|
-
const label = document.createElement('label');
|
|
1138
|
-
|
|
1388
|
+
const label = this.variablesEditable ? variableLabel('Starts as') : document.createElement('label');
|
|
1389
|
+
if (!this.variablesEditable)
|
|
1390
|
+
label.textContent = 'Starting value';
|
|
1139
1391
|
const input = document.createElement('input');
|
|
1140
1392
|
input.type = 'text';
|
|
1393
|
+
input.placeholder = 'starts as, like 0';
|
|
1394
|
+
input.spellcheck = false;
|
|
1395
|
+
input.autocomplete = 'off';
|
|
1141
1396
|
identify(input, 'value');
|
|
1142
1397
|
input.value = variable.startingValue;
|
|
1143
1398
|
input.disabled = this.locked;
|
|
@@ -1160,13 +1415,26 @@ export class ProcessModeler extends BaseElement {
|
|
|
1160
1415
|
remove.type = 'button';
|
|
1161
1416
|
remove.setAttribute('part', 'variable-remove');
|
|
1162
1417
|
remove.setAttribute('aria-label', `Remove ${variable.name || 'this variable'}`);
|
|
1163
|
-
remove.
|
|
1418
|
+
remove.append(variableGlyph());
|
|
1164
1419
|
remove.disabled = this.locked;
|
|
1165
1420
|
identify(remove, 'remove');
|
|
1166
1421
|
remove.onclick = () => this.requestVariableEdit({ type: 'remove', name: variable.name });
|
|
1167
1422
|
row.append(remove);
|
|
1168
1423
|
}
|
|
1169
|
-
|
|
1424
|
+
if (this.variablesEditable) {
|
|
1425
|
+
// The grid's visual ordering must also be the keyboard/read order.
|
|
1426
|
+
for (const key of ['name', 'value', 'remove', 'description', 'scope']) {
|
|
1427
|
+
const control = row.querySelector(`[data-variable-key=${key}]`);
|
|
1428
|
+
if (control)
|
|
1429
|
+
row.append(control.closest('label') ?? control);
|
|
1430
|
+
}
|
|
1431
|
+
const problem = row.querySelector('[part=field-problem]');
|
|
1432
|
+
if (problem)
|
|
1433
|
+
row.append(problem);
|
|
1434
|
+
}
|
|
1435
|
+
row.setAttribute('role', 'group');
|
|
1436
|
+
row.setAttribute('aria-label', `Variable ${index + 1}`);
|
|
1437
|
+
collection.append(row);
|
|
1170
1438
|
}
|
|
1171
1439
|
if (this.variablesEditable) {
|
|
1172
1440
|
const add = document.createElement('button');
|
|
@@ -1174,11 +1442,45 @@ export class ProcessModeler extends BaseElement {
|
|
|
1174
1442
|
add.setAttribute('part', 'variable-add');
|
|
1175
1443
|
add.dataset.variable = '@add';
|
|
1176
1444
|
add.dataset.variableKey = 'add';
|
|
1177
|
-
add.
|
|
1445
|
+
add.append(variableGlyph(true), document.createTextNode('Add a variable'));
|
|
1178
1446
|
add.disabled = this.locked;
|
|
1179
1447
|
add.onclick = () => this.requestVariableEdit({ type: 'add' });
|
|
1180
|
-
|
|
1448
|
+
const actions = document.createElement('div');
|
|
1449
|
+
actions.setAttribute('part', 'variable-actions');
|
|
1450
|
+
actions.append(add);
|
|
1451
|
+
content.append(actions);
|
|
1181
1452
|
}
|
|
1453
|
+
const localRows = this.projection.boxes.flatMap(box => (box.localVariables ?? []).filter(variable => variable.name).map(variable => ({ name: variable.name, connective: 'only in', title: box.title })));
|
|
1454
|
+
const savedRows = this.projection.boxes.filter(box => box.savedResult).map(box => ({ name: box.savedResult, connective: 'from', title: box.title }));
|
|
1455
|
+
const summary = (part, heading, rows, empty) => {
|
|
1456
|
+
if (!rows.length && !empty)
|
|
1457
|
+
return;
|
|
1458
|
+
const section = document.createElement('section');
|
|
1459
|
+
section.setAttribute('part', part);
|
|
1460
|
+
const title = document.createElement('h3');
|
|
1461
|
+
title.textContent = heading;
|
|
1462
|
+
section.append(title);
|
|
1463
|
+
if (rows.length) {
|
|
1464
|
+
const list = document.createElement('ul');
|
|
1465
|
+
for (const item of rows) {
|
|
1466
|
+
const row = document.createElement('li'), text = document.createElement('span'), name = document.createElement('code'), connective = document.createElement('em');
|
|
1467
|
+
name.textContent = item.name;
|
|
1468
|
+
connective.textContent = item.connective;
|
|
1469
|
+
text.append(name, document.createTextNode(' '), connective, document.createTextNode(` ${item.title}`));
|
|
1470
|
+
row.append(text);
|
|
1471
|
+
list.append(row);
|
|
1472
|
+
}
|
|
1473
|
+
section.append(list);
|
|
1474
|
+
}
|
|
1475
|
+
else {
|
|
1476
|
+
const help = document.createElement('p');
|
|
1477
|
+
help.textContent = empty;
|
|
1478
|
+
section.append(help);
|
|
1479
|
+
}
|
|
1480
|
+
content.append(section);
|
|
1481
|
+
};
|
|
1482
|
+
summary('local-variable-summary', 'Only in one step', localRows);
|
|
1483
|
+
summary('saved-result-summary', 'Saved by steps', savedRows, 'No step saves its result yet. Set “Save the result as” on a step.');
|
|
1182
1484
|
}
|
|
1183
1485
|
else if (this.activePane === "Connections") {
|
|
1184
1486
|
if (!this.connections.length)
|
|
@@ -1220,6 +1522,14 @@ export class ProcessModeler extends BaseElement {
|
|
|
1220
1522
|
if (this.locked)
|
|
1221
1523
|
return;
|
|
1222
1524
|
const boxes = this.selectedBoxes;
|
|
1525
|
+
if (command === "delete-many" && this.graphSelection) {
|
|
1526
|
+
if (this.disableConnections && this.selectedLineIds.size) {
|
|
1527
|
+
this.setStatus('Deleting selected lines is disabled');
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
this.requestEdit({ type: 'delete-selection', selection: this.selection });
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1223
1533
|
if (command === "delete-many") {
|
|
1224
1534
|
for (const box of boxes)
|
|
1225
1535
|
this.requestEdit({ type: "delete", boxId: box.id });
|
|
@@ -1237,6 +1547,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
1237
1547
|
tidySelection() {
|
|
1238
1548
|
if (this.locked || this.selectedBoxes.length < 2)
|
|
1239
1549
|
return;
|
|
1550
|
+
if (this.requestLayoutEdit('tidy', [...this.selectedIds]))
|
|
1551
|
+
return;
|
|
1240
1552
|
const next = this.layout;
|
|
1241
1553
|
const roots = this.selectedBoxes.filter(box => {
|
|
1242
1554
|
let parent = box.parentId;
|
|
@@ -1276,11 +1588,13 @@ export class ProcessModeler extends BaseElement {
|
|
|
1276
1588
|
const arrangedY = Math.min(...group.map(box => arranged.boxes[box.id]?.y ?? Infinity));
|
|
1277
1589
|
if (![arrangedX, arrangedY].every(Number.isFinite))
|
|
1278
1590
|
continue;
|
|
1591
|
+
const dx = Math.round((startX - arrangedX) / 16) * 16;
|
|
1592
|
+
const dy = Math.round((startY - arrangedY) / 16) * 16;
|
|
1279
1593
|
for (const box of subset.boxes) {
|
|
1280
1594
|
const position = arranged.boxes[box.id];
|
|
1281
1595
|
if (!position || !next.boxes[box.id])
|
|
1282
1596
|
continue;
|
|
1283
|
-
next.boxes[box.id] = { ...next.boxes[box.id], x:
|
|
1597
|
+
next.boxes[box.id] = { ...next.boxes[box.id], x: position.x + dx, y: position.y + dy };
|
|
1284
1598
|
}
|
|
1285
1599
|
}
|
|
1286
1600
|
this.commitLayout(next);
|
|
@@ -1373,7 +1687,11 @@ export class ProcessModeler extends BaseElement {
|
|
|
1373
1687
|
this.commitLayout(next);
|
|
1374
1688
|
}
|
|
1375
1689
|
makeSection(title = 'New section') {
|
|
1376
|
-
if (this.locked || this.selectedBoxes.length
|
|
1690
|
+
if (this.locked || !this.selectedBoxes.length)
|
|
1691
|
+
return;
|
|
1692
|
+
if (this.requestLayoutEdit('make-section', [...this.selectedIds], title))
|
|
1693
|
+
return;
|
|
1694
|
+
if (this.selectedBoxes.length < 2)
|
|
1377
1695
|
return;
|
|
1378
1696
|
const positions = this.selectedBoxes.map(box => this.layoutValue.boxes[box.id]);
|
|
1379
1697
|
const x = Math.min(...positions.map(position => position.x)) - 24;
|
|
@@ -1536,6 +1854,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
1536
1854
|
let claimed = false;
|
|
1537
1855
|
const request = {
|
|
1538
1856
|
sourceIds: Object.freeze([...this.selectedIds]),
|
|
1857
|
+
selection: Object.freeze(this.selection.map(item => Object.freeze(item))),
|
|
1539
1858
|
capture: clipboard => {
|
|
1540
1859
|
if (!active || claimed || session !== this.clipboardSession || !this.isConnected)
|
|
1541
1860
|
return;
|
|
@@ -1551,7 +1870,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
1551
1870
|
return; claimed = true; this.setStatus(message ?? 'Copy is not supported by this host'); },
|
|
1552
1871
|
};
|
|
1553
1872
|
try {
|
|
1554
|
-
emit(this, 'process-copy-request', request);
|
|
1873
|
+
emit(this, this.graphSelection && (this.selectedLineIds.size || this.selectedNoteIds.size) ? 'process-selection-copy-request' : 'process-copy-request', request);
|
|
1555
1874
|
}
|
|
1556
1875
|
finally {
|
|
1557
1876
|
active = false;
|
|
@@ -1632,11 +1951,15 @@ export class ProcessModeler extends BaseElement {
|
|
|
1632
1951
|
}
|
|
1633
1952
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
|
1634
1953
|
event.preventDefault();
|
|
1635
|
-
this.
|
|
1636
|
-
|
|
1954
|
+
this.selectItems([
|
|
1955
|
+
...this.projection.boxes.filter(box => box.kind !== 'section').map(box => ({ type: 'box', id: box.id })),
|
|
1956
|
+
...this.projection.lines.map(line => ({ type: 'line', id: line.id })),
|
|
1957
|
+
...(this.layoutValue.notes ?? []).map(note => ({ type: 'note', id: note.id })),
|
|
1958
|
+
]);
|
|
1959
|
+
this.setStatus(`${this.selection.length} items selected`);
|
|
1637
1960
|
return;
|
|
1638
1961
|
}
|
|
1639
|
-
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && this.selectedIds.size) {
|
|
1962
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && (this.selectedIds.size || this.selectedNoteIds.size)) {
|
|
1640
1963
|
if (this.copySelection())
|
|
1641
1964
|
event.preventDefault();
|
|
1642
1965
|
return;
|
|
@@ -1674,6 +1997,11 @@ export class ProcessModeler extends BaseElement {
|
|
|
1674
1997
|
this.setStatus("Whole process fitted to the canvas");
|
|
1675
1998
|
return;
|
|
1676
1999
|
}
|
|
2000
|
+
if ((event.key === 'Delete' || event.key === 'Backspace') && this.graphSelection && this.selection.length && !this.locked) {
|
|
2001
|
+
event.preventDefault();
|
|
2002
|
+
this.selectionCommand('delete-many');
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
1677
2005
|
const box = this.selected;
|
|
1678
2006
|
if (this.selectedLine && (event.key === 'Delete' || event.key === 'Backspace') && !this.locked && !this.disableConnections) {
|
|
1679
2007
|
event.preventDefault();
|
|
@@ -1691,6 +2019,32 @@ export class ProcessModeler extends BaseElement {
|
|
|
1691
2019
|
ArrowUp: [0, -1],
|
|
1692
2020
|
ArrowDown: [0, 1],
|
|
1693
2021
|
}[event.key];
|
|
2022
|
+
if (direction && this.graphSelection && !this.locked && !event.altKey && !event.ctrlKey && !event.metaKey && (this.selectedIds.size || this.selectedNoteIds.size)) {
|
|
2023
|
+
event.preventDefault();
|
|
2024
|
+
const distance = event.shiftKey ? 64 : 16, dx = direction[0] * distance, dy = direction[1] * distance;
|
|
2025
|
+
const next = this.layout, moved = new Set(this.selectedIds);
|
|
2026
|
+
for (let changed = true; changed;) {
|
|
2027
|
+
changed = false;
|
|
2028
|
+
for (const box of this.projection.boxes)
|
|
2029
|
+
if (box.parentId && moved.has(box.parentId) && !moved.has(box.id)) {
|
|
2030
|
+
moved.add(box.id);
|
|
2031
|
+
changed = true;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
for (const id of moved) {
|
|
2035
|
+
const p = next.boxes[id];
|
|
2036
|
+
if (p)
|
|
2037
|
+
next.boxes[id] = { ...p, x: p.x + dx, y: p.y + dy };
|
|
2038
|
+
}
|
|
2039
|
+
next.notes = next.notes?.map(note => this.selectedNoteIds.has(note.id) ? { ...note, x: note.x + dx, y: note.y + dy } : note);
|
|
2040
|
+
const primary = this.selectedId;
|
|
2041
|
+
const allowed = primary
|
|
2042
|
+
? emit(this, 'move-request', { boxId: primary, position: next.boxes[primary], boxIds: [...moved], positions: next.boxes, noteIds: [...this.selectedNoteIds], notes: next.notes ?? [] })
|
|
2043
|
+
: emit(this, 'note-move-request', { noteIds: [...this.selectedNoteIds], notes: next.notes ?? [] });
|
|
2044
|
+
if (allowed)
|
|
2045
|
+
this.commitLayout(next);
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
1694
2048
|
const canInsertDirection = event.ctrlKey && event.altKey && box && !this.locked && this.catalog.length && !this.disableConnections;
|
|
1695
2049
|
if (direction && !canInsertDirection && (event.altKey || !box || this.locked)) {
|
|
1696
2050
|
event.preventDefault();
|
|
@@ -2033,7 +2387,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2033
2387
|
if ((frame?.id ?? null) !== (source.parentId ?? null))
|
|
2034
2388
|
this.setStatus('Drop inside the same frame to add a connected step');
|
|
2035
2389
|
else
|
|
2036
|
-
this.openChooserAtPoint({ type: 'add', from: port.id, fromSide: port.side, parentId: source.parentId, position: point }, `Add after ${source.title}`, point);
|
|
2390
|
+
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
2391
|
}
|
|
2038
2392
|
}
|
|
2039
2393
|
}
|
|
@@ -2093,8 +2447,11 @@ export class ProcessModeler extends BaseElement {
|
|
|
2093
2447
|
const position = this.alignedPoint(drag.id, drag.position.x + dx / this.viewport.zoom, drag.position.y + dy / this.viewport.zoom);
|
|
2094
2448
|
const line = this.lineAt(point, drag.id);
|
|
2095
2449
|
const frame = this.boxAt(point, drag.id, true);
|
|
2096
|
-
if (line)
|
|
2097
|
-
this.
|
|
2450
|
+
if (line) {
|
|
2451
|
+
const box = this.projection.boxes.find(box => box.id === drag.id);
|
|
2452
|
+
const { width, height } = this.selectionDimensions(box);
|
|
2453
|
+
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 } } });
|
|
2454
|
+
}
|
|
2098
2455
|
else if (this.projection.boxes.find(box => box.id === drag.id)?.parentId !== frame?.id)
|
|
2099
2456
|
this.requestEdit({ type: "reparent", boxId: drag.id, parentId: frame?.id, position });
|
|
2100
2457
|
else if (this.selectedIds.size > 1 && this.selectedIds.has(drag.id)) {
|
|
@@ -2219,9 +2576,10 @@ export class ProcessModeler extends BaseElement {
|
|
|
2219
2576
|
nextEdit(box, side, position) {
|
|
2220
2577
|
const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
|
|
2221
2578
|
const outgoing = this.projection.lines.filter(line => line.from === box.id);
|
|
2579
|
+
const placement = side ? { source: 'direction', side } : { source: 'next' };
|
|
2222
2580
|
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 } : {}) };
|
|
2581
|
+
return { type: 'insert', lineId: outgoing[0].id, from: box.id, to: outgoing[0].to, placement };
|
|
2582
|
+
return { type: 'add', from: box.id, placement, ...(side ? { fromSide: side } : {}), parentId: box.parentId, ...(position ? { position } : {}) };
|
|
2225
2583
|
}
|
|
2226
2584
|
nextChooserTitle(box, side) {
|
|
2227
2585
|
const edit = this.nextEdit(box, side);
|
|
@@ -2247,7 +2605,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2247
2605
|
const source = this.projection.boxes.find(box => box.id === line.from);
|
|
2248
2606
|
const target = this.projection.boxes.find(box => box.id === line.to);
|
|
2249
2607
|
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);
|
|
2608
|
+
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
2609
|
}
|
|
2252
2610
|
closeKeyboardChooser(returnFocus = false) {
|
|
2253
2611
|
const chooser = this.shadowRoot?.querySelector('[part=keyboard-chooser]');
|
|
@@ -2275,8 +2633,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
2275
2633
|
const anchor = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
|
|
2276
2634
|
.find(element => element.dataset.boxId === box.id);
|
|
2277
2635
|
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}`
|
|
2636
|
+
? { type: 'insert', lineId: outgoing[0].id, from: box.id, to: target.id, placement: { source: 'next' } }
|
|
2637
|
+
: { type: 'add', from: box.id, placement: { source: 'next' } }, insert ? `Insert between ${box.title} and ${target.title}`
|
|
2280
2638
|
: shape === 'gateway' ? `Add a path from ${box.title}` : `Add after ${box.title}`, anchor);
|
|
2281
2639
|
}
|
|
2282
2640
|
openAnchoredChooser(edit, title, anchor, returnFocus) {
|
|
@@ -2325,7 +2683,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2325
2683
|
tooltip.style.top = `${point.y + 16}px`;
|
|
2326
2684
|
}
|
|
2327
2685
|
update() {
|
|
2328
|
-
const selectionBefore = JSON.stringify({
|
|
2686
|
+
const selectionBefore = JSON.stringify({ items: this.selection, path: this.selectedPath });
|
|
2329
2687
|
if (this.documentValue !== undefined) {
|
|
2330
2688
|
this.projection = this.model.project(this.documentValue);
|
|
2331
2689
|
validateProjection(this.projection);
|
|
@@ -2340,10 +2698,12 @@ export class ProcessModeler extends BaseElement {
|
|
|
2340
2698
|
if (this.selectedLineId && !this.selectedLine)
|
|
2341
2699
|
this.selectedLineId = null;
|
|
2342
2700
|
this.selectedIds = new Set([...this.selectedIds].filter(id => this.projection.boxes.some(box => box.id === id)));
|
|
2701
|
+
this.selectedLineIds = new Set([...this.selectedLineIds].filter(id => this.projection.lines.some(line => line.id === id)));
|
|
2702
|
+
this.selectedNoteIds = new Set([...this.selectedNoteIds].filter(id => this.layoutValue.notes?.some(note => note.id === id)));
|
|
2343
2703
|
if (!this.selectedId)
|
|
2344
2704
|
this.selectedId = this.selectedIds.values().next().value ?? null;
|
|
2345
2705
|
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 })),
|
|
2706
|
+
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
2707
|
lines: this.projection.lines,
|
|
2348
2708
|
});
|
|
2349
2709
|
if (this.documentValue !== undefined && (serialized !== this.lastProjection || this.documentValue !== this.lastDocument)) {
|
|
@@ -2351,7 +2711,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2351
2711
|
this.versionValue = typeof this.versionValue === "number" ? this.versionValue + 1 : `${this.versionValue}+1`;
|
|
2352
2712
|
this.lastProjection = serialized;
|
|
2353
2713
|
this.lastDocument = this.documentValue;
|
|
2354
|
-
|
|
2714
|
+
this.notify("projection-changed", { projection: snapshotProcessProjection(this.projection), version: this.versionValue, checks: this.allChecks });
|
|
2355
2715
|
}
|
|
2356
2716
|
// Riptide or another host owns conversion. It supplies validate() and
|
|
2357
2717
|
// receives this event only when that conversion can read the drawing.
|
|
@@ -2361,7 +2721,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2361
2721
|
if (this.documentValue !== undefined && !this.allChecks.length && readableKey !== this.lastReadableProjection) {
|
|
2362
2722
|
this.lastReadableProjection = readableKey;
|
|
2363
2723
|
this.readableValue = snapshotProcessProjection(this.projection);
|
|
2364
|
-
|
|
2724
|
+
this.notify("readable-projection-changed", { projection: snapshotProcessProjection(this.readableValue), version: this.versionValue });
|
|
2365
2725
|
}
|
|
2366
2726
|
if (this.documentValue === undefined) {
|
|
2367
2727
|
this.lastProjection = "";
|
|
@@ -2369,16 +2729,16 @@ export class ProcessModeler extends BaseElement {
|
|
|
2369
2729
|
this.readableValue = null;
|
|
2370
2730
|
this.lastDocument = undefined;
|
|
2371
2731
|
}
|
|
2372
|
-
const
|
|
2373
|
-
|
|
2732
|
+
const active = this.shadowRoot.activeElement;
|
|
2733
|
+
const focused = active?.dataset.boxId, focusedNote = active?.dataset.noteId;
|
|
2374
2734
|
this.renderWorld();
|
|
2375
2735
|
this.renderPalette();
|
|
2376
2736
|
this.renderChecks();
|
|
2377
2737
|
this.renderSelection();
|
|
2378
2738
|
this.paintViewport();
|
|
2379
2739
|
this.updateToolbar();
|
|
2380
|
-
if (selectionBefore !== JSON.stringify({
|
|
2381
|
-
|
|
2740
|
+
if (selectionBefore !== JSON.stringify({ items: this.selection, path: this.selectedPath }))
|
|
2741
|
+
this.notify("selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, ...(this.graphSelection ? { selection: this.selection } : {}) });
|
|
2382
2742
|
if (focused) {
|
|
2383
2743
|
this.restoringBoxFocus = true;
|
|
2384
2744
|
try {
|
|
@@ -2388,6 +2748,10 @@ export class ProcessModeler extends BaseElement {
|
|
|
2388
2748
|
this.restoringBoxFocus = false;
|
|
2389
2749
|
}
|
|
2390
2750
|
}
|
|
2751
|
+
else if (focusedNote) {
|
|
2752
|
+
const note = Array.from(this.shadowRoot.querySelectorAll('[data-note-id]')).find(note => note.dataset.noteId === focusedNote);
|
|
2753
|
+
(note ?? this.shadowRoot.querySelector('[part=canvas]'))?.focus({ preventScroll: true });
|
|
2754
|
+
}
|
|
2391
2755
|
}
|
|
2392
2756
|
get allChecks() { return this.computedChecks; }
|
|
2393
2757
|
collectChecks() {
|
|
@@ -2434,7 +2798,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2434
2798
|
const problem = this.allChecks.find(check => check.boxId === box.id || (check.path && JSON.stringify(check.path) === JSON.stringify(box.path)));
|
|
2435
2799
|
const incoming = this.projection.lines.filter(line => line.to === box.id).length;
|
|
2436
2800
|
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}` : ""]
|
|
2801
|
+
element.setAttribute("aria-label", [`${kind?.label ?? box.kind}: ${box.title}. ${incoming} in, ${outgoing} out`, problem ? `Needs attention: ${problem.title || problem.message}` : ""]
|
|
2438
2802
|
.filter(Boolean)
|
|
2439
2803
|
.join(". "));
|
|
2440
2804
|
element.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
|
|
@@ -2551,7 +2915,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2551
2915
|
if (problem) {
|
|
2552
2916
|
const message = document.createElement("span");
|
|
2553
2917
|
message.setAttribute("part", "problem");
|
|
2554
|
-
message.append(checkGlyph(), document.createTextNode(problem.title
|
|
2918
|
+
message.append(checkGlyph(), document.createTextNode(problem.title || problem.message));
|
|
2555
2919
|
element.append(message);
|
|
2556
2920
|
}
|
|
2557
2921
|
element.addEventListener("keydown", event => {
|
|
@@ -2660,8 +3024,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
2660
3024
|
const path = svgElement("path");
|
|
2661
3025
|
path.setAttribute("part", "line");
|
|
2662
3026
|
path.dataset.lineId = line.id;
|
|
2663
|
-
path.dataset.selected = String(this.
|
|
2664
|
-
path.setAttribute('marker-end', `url(#${this.
|
|
3027
|
+
path.dataset.selected = String(this.selectedLineIds.has(line.id));
|
|
3028
|
+
path.setAttribute('marker-end', `url(#${this.selectedLineIds.has(line.id) ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`);
|
|
2665
3029
|
path.setAttribute("vector-effect", "non-scaling-stroke");
|
|
2666
3030
|
path.setAttribute("d", roundedProcessPath(points));
|
|
2667
3031
|
if (line.dashed)
|
|
@@ -2760,6 +3124,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2760
3124
|
lineId: line.id,
|
|
2761
3125
|
from: line.from,
|
|
2762
3126
|
to: line.to,
|
|
3127
|
+
...(type === 'insert' ? { placement: { source: 'line', center: lineMidpoint(points) } } : {}),
|
|
2763
3128
|
};
|
|
2764
3129
|
if (type === "insert" && this.catalog.length) {
|
|
2765
3130
|
this.openLineChooser(line, button);
|
|
@@ -2789,6 +3154,18 @@ export class ProcessModeler extends BaseElement {
|
|
|
2789
3154
|
const element = document.createElement("div");
|
|
2790
3155
|
element.setAttribute("part", "note");
|
|
2791
3156
|
element.textContent = note.text;
|
|
3157
|
+
element.dataset.noteId = note.id;
|
|
3158
|
+
element.addEventListener('pointerdown', event => event.stopPropagation());
|
|
3159
|
+
element.tabIndex = 0;
|
|
3160
|
+
element.setAttribute('role', 'button');
|
|
3161
|
+
element.setAttribute('aria-label', note.text || 'Diagram note');
|
|
3162
|
+
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))
|
|
3163
|
+
items.push({ type: 'note', id: note.id }); this.selectItems(items); });
|
|
3164
|
+
element.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') {
|
|
3165
|
+
event.preventDefault();
|
|
3166
|
+
event.stopPropagation();
|
|
3167
|
+
this.selectItems([{ type: 'note', id: note.id }]);
|
|
3168
|
+
} });
|
|
2792
3169
|
this.place(element, { ...note, width: 208, height: 80 });
|
|
2793
3170
|
world.append(element);
|
|
2794
3171
|
}
|
|
@@ -2814,9 +3191,13 @@ export class ProcessModeler extends BaseElement {
|
|
|
2814
3191
|
}
|
|
2815
3192
|
const selected = this.selectedIds.size === 1 ? this.selected : null;
|
|
2816
3193
|
const from = selected && !selected.frame && selected.kind !== 'section' && !['end', 'finish'].includes(selected.kind) ? selected.id : undefined;
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
3194
|
+
if (from && selected)
|
|
3195
|
+
this.requestEdit({ ...this.nextEdit(selected), kind });
|
|
3196
|
+
else {
|
|
3197
|
+
const canvas = this.shadowRoot.querySelector('[part=canvas]');
|
|
3198
|
+
const center = { x: (canvas.clientWidth / 2 - this.viewport.x) / this.viewport.zoom, y: (canvas.clientHeight / 2 - this.viewport.y) / this.viewport.zoom };
|
|
3199
|
+
this.requestEdit({ type: 'add', kind, placement: { source: 'point', center } });
|
|
3200
|
+
}
|
|
2820
3201
|
}
|
|
2821
3202
|
paletteLineAt(point) {
|
|
2822
3203
|
let nearest;
|
|
@@ -2938,7 +3319,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
2938
3319
|
}
|
|
2939
3320
|
const line = drag.kind.kind === 'end' ? undefined : this.paletteLineAt(point);
|
|
2940
3321
|
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 });
|
|
3322
|
+
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
3323
|
}
|
|
2943
3324
|
renderPalette() {
|
|
2944
3325
|
const choices = this.shadowRoot.querySelector("[part=choices]");
|
|
@@ -3037,7 +3418,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
3037
3418
|
type: line ? "insert" : "add",
|
|
3038
3419
|
kind,
|
|
3039
3420
|
lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id,
|
|
3040
|
-
position,
|
|
3421
|
+
position, placement: { source: 'point', center: { ...position } },
|
|
3041
3422
|
});
|
|
3042
3423
|
this.markDropLine(undefined);
|
|
3043
3424
|
};
|
|
@@ -3289,19 +3670,48 @@ export class ProcessModeler extends BaseElement {
|
|
|
3289
3670
|
return row;
|
|
3290
3671
|
}
|
|
3291
3672
|
renderSelection() {
|
|
3673
|
+
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
3674
|
if (!this.isRendered)
|
|
3293
3675
|
return;
|
|
3294
3676
|
const focusedControl = this.shadowRoot.activeElement;
|
|
3295
|
-
const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable;
|
|
3677
|
+
const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable ?? focusedControl?.dataset.localVariable;
|
|
3296
3678
|
const variableKey = focusedControl?.dataset.variableKey;
|
|
3297
|
-
const
|
|
3679
|
+
const localKey = focusedControl?.dataset.localKey, localName = focusedControl?.dataset.localName;
|
|
3680
|
+
const focusedType = focusedControl?.dataset.field !== undefined ? 'field' : focusedControl?.dataset.variable !== undefined ? 'variable' : focusedControl?.dataset.localVariable !== undefined ? 'localVariable' : null;
|
|
3298
3681
|
const caret = focusedControl && 'selectionStart' in focusedControl ? focusedControl.selectionStart : null;
|
|
3299
3682
|
const restoreControlFocus = () => {
|
|
3300
3683
|
if (focusedKey === undefined || !focusedType)
|
|
3301
3684
|
return;
|
|
3302
3685
|
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))
|
|
3686
|
+
const control = controls.find(item => item.dataset[focusedType] === focusedKey && (focusedType !== 'variable' || item.dataset.variableKey === variableKey) && focusedType !== 'localVariable')
|
|
3304
3687
|
?? (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);
|
|
3688
|
+
if (focusedType === 'localVariable') {
|
|
3689
|
+
if (focusedControl?.dataset.localAmbiguous === 'true') {
|
|
3690
|
+
focusedControl.blur();
|
|
3691
|
+
return;
|
|
3692
|
+
}
|
|
3693
|
+
const names = [localName];
|
|
3694
|
+
if (this.pendingLocalRename?.boxId === focusedKey && this.pendingLocalRename.name === localName && !this.pendingLocalRename.ambiguous)
|
|
3695
|
+
names.push(this.pendingLocalRename.value);
|
|
3696
|
+
let localControl = undefined;
|
|
3697
|
+
for (const name of names) {
|
|
3698
|
+
const matches = controls.filter(item => item.dataset.localVariable === focusedKey && item.dataset.localKey === localKey && item.dataset.localName === name);
|
|
3699
|
+
if (matches.length === 1) {
|
|
3700
|
+
localControl = matches[0];
|
|
3701
|
+
break;
|
|
3702
|
+
}
|
|
3703
|
+
if (matches.length > 1)
|
|
3704
|
+
break;
|
|
3705
|
+
}
|
|
3706
|
+
if (!localControl) {
|
|
3707
|
+
focusedControl?.blur();
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
localControl.focus();
|
|
3711
|
+
if (caret !== null && localControl instanceof HTMLInputElement)
|
|
3712
|
+
localControl.setSelectionRange(caret, caret);
|
|
3713
|
+
return;
|
|
3714
|
+
}
|
|
3305
3715
|
if (!control)
|
|
3306
3716
|
return;
|
|
3307
3717
|
control.focus();
|
|
@@ -3309,7 +3719,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
3309
3719
|
control.setSelectionRange(caret, caret);
|
|
3310
3720
|
};
|
|
3311
3721
|
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
|
|
3722
|
+
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
3723
|
this.shadowRoot.querySelectorAll("[data-box-id]").forEach((element) => {
|
|
3314
3724
|
element.setAttribute("aria-current", String(this.selectedIds.has(element.dataset.boxId)));
|
|
3315
3725
|
const box = this.projection.boxes.find((box) => box.id === element.dataset.boxId);
|
|
@@ -3319,9 +3729,9 @@ export class ProcessModeler extends BaseElement {
|
|
|
3319
3729
|
});
|
|
3320
3730
|
const editor = this.shadowRoot.querySelector("[part=editor]");
|
|
3321
3731
|
const selected = this.selected;
|
|
3322
|
-
const controlsKey = `${[...this.selectedIds].join(',')}|${this.locked}|${this.disableConnections}|${this.showLastRunValue}|${selected?.runMetrics}|${JSON.stringify(selected ? this.fieldsValue[selected.id] ?? [] : [])}`;
|
|
3732
|
+
const controlsKey = `${[...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
3733
|
this.shadowRoot.querySelector("[part=palette]").hidden = false;
|
|
3324
|
-
this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.
|
|
3734
|
+
this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selection.length === 0;
|
|
3325
3735
|
this.renderSelectionToolbar();
|
|
3326
3736
|
this.renderPane();
|
|
3327
3737
|
this.positionSelectionToolbar();
|
|
@@ -3330,6 +3740,12 @@ export class ProcessModeler extends BaseElement {
|
|
|
3330
3740
|
this.inspectedControls === controlsKey &&
|
|
3331
3741
|
editor.querySelector(`[part=inspector-heading]`)?.tagName === `H${this.headingLevel}`) {
|
|
3332
3742
|
editor.querySelector("[part=inspector-heading]").textContent = selected?.title ?? "";
|
|
3743
|
+
const locals = editor.querySelector('[part=local-variables]');
|
|
3744
|
+
if (selected && locals && locals.dataset.localEchoRevision !== String(this.localEchoRevision)) {
|
|
3745
|
+
// Reconcile controlled local drafts without disposing the host's custom
|
|
3746
|
+
// inspector or unrelated focused controls on unchanged document echoes.
|
|
3747
|
+
locals.replaceWith(this.renderLocalVariables(selected));
|
|
3748
|
+
}
|
|
3333
3749
|
restoreControlFocus();
|
|
3334
3750
|
return;
|
|
3335
3751
|
}
|
|
@@ -3383,6 +3799,8 @@ export class ProcessModeler extends BaseElement {
|
|
|
3383
3799
|
}
|
|
3384
3800
|
for (const field of this.fieldsValue[selected.id] ?? [])
|
|
3385
3801
|
editor.append(this.renderField(selected, field));
|
|
3802
|
+
if (selected.localVariables !== undefined)
|
|
3803
|
+
editor.append(this.renderLocalVariables(selected));
|
|
3386
3804
|
this.cleanupInspector =
|
|
3387
3805
|
this.renderer?.(selected.node, editor) || undefined;
|
|
3388
3806
|
const outgoing = this.projection.lines.filter(line => line.from === selected.id);
|
|
@@ -3469,14 +3887,118 @@ export class ProcessModeler extends BaseElement {
|
|
|
3469
3887
|
}
|
|
3470
3888
|
restoreControlFocus();
|
|
3471
3889
|
}
|
|
3890
|
+
renderLocalVariables(box) {
|
|
3891
|
+
const section = document.createElement('section');
|
|
3892
|
+
section.setAttribute('part', 'local-variables');
|
|
3893
|
+
section.dataset.localEchoRevision = String(this.localEchoRevision);
|
|
3894
|
+
const heading = document.createElement('h3');
|
|
3895
|
+
heading.textContent = box.frame ? 'Variables for the steps inside' : 'Variables for this step';
|
|
3896
|
+
const help = document.createElement('p');
|
|
3897
|
+
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.';
|
|
3898
|
+
const collection = document.createElement('div');
|
|
3899
|
+
collection.setAttribute('part', 'local-variable-list');
|
|
3900
|
+
section.append(heading, help, collection);
|
|
3901
|
+
for (const [index, variable] of (box.localVariables ?? []).entries()) {
|
|
3902
|
+
const row = document.createElement('div');
|
|
3903
|
+
row.setAttribute('part', 'local-variable-row');
|
|
3904
|
+
row.setAttribute('role', 'group');
|
|
3905
|
+
row.setAttribute('aria-label', `Step variable ${index + 1}`);
|
|
3906
|
+
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); };
|
|
3907
|
+
for (const [key, label, value] of [['rename', 'Name', variable.name], ['starting-value', 'Starts as', variable.startingValue ?? '']]) {
|
|
3908
|
+
const field = variableLabel(label);
|
|
3909
|
+
const input = document.createElement('input');
|
|
3910
|
+
input.type = 'text';
|
|
3911
|
+
input.placeholder = key === 'rename' ? 'name' : 'starts as';
|
|
3912
|
+
input.spellcheck = false;
|
|
3913
|
+
input.autocomplete = 'off';
|
|
3914
|
+
input.value = value;
|
|
3915
|
+
input.disabled = this.locked || !box.localVariablesEditable;
|
|
3916
|
+
identify(input, key);
|
|
3917
|
+
if (variable.problem) {
|
|
3918
|
+
input.setAttribute('aria-invalid', 'true');
|
|
3919
|
+
input.setAttribute('aria-describedby', `process-local-problem-${encodeURIComponent(box.id)}-${index}`);
|
|
3920
|
+
}
|
|
3921
|
+
input.oninput = () => {
|
|
3922
|
+
if (key === 'rename')
|
|
3923
|
+
this.pendingLocalRename = { boxId: box.id, name: variable.name, value: input.value, echoRevision: this.localEchoRevision, ambiguous: input.dataset.localAmbiguous === 'true' };
|
|
3924
|
+
this.requestLocalVariableEdit(box, { type: key, index, name: variable.name, value: input.value });
|
|
3925
|
+
};
|
|
3926
|
+
field.append(input);
|
|
3927
|
+
row.append(field);
|
|
3928
|
+
}
|
|
3929
|
+
if (box.localVariablesEditable) {
|
|
3930
|
+
const remove = document.createElement('button');
|
|
3931
|
+
remove.type = 'button';
|
|
3932
|
+
remove.append(variableGlyph());
|
|
3933
|
+
remove.setAttribute('aria-label', `Remove ${variable.name || 'this variable'}`);
|
|
3934
|
+
remove.disabled = this.locked;
|
|
3935
|
+
identify(remove, 'remove');
|
|
3936
|
+
remove.onclick = () => this.requestLocalVariableEdit(box, { type: 'remove', index, name: variable.name });
|
|
3937
|
+
row.append(remove);
|
|
3938
|
+
}
|
|
3939
|
+
if (variable.problem) {
|
|
3940
|
+
const problem = document.createElement('small');
|
|
3941
|
+
problem.setAttribute('part', 'field-problem');
|
|
3942
|
+
problem.id = `process-local-problem-${encodeURIComponent(box.id)}-${index}`;
|
|
3943
|
+
problem.textContent = variable.problem;
|
|
3944
|
+
row.append(problem);
|
|
3945
|
+
}
|
|
3946
|
+
collection.append(row);
|
|
3947
|
+
}
|
|
3948
|
+
if (box.localVariablesEditable) {
|
|
3949
|
+
const add = document.createElement('button');
|
|
3950
|
+
add.type = 'button';
|
|
3951
|
+
add.append(variableGlyph(true), document.createTextNode('Add a variable'));
|
|
3952
|
+
add.disabled = this.locked;
|
|
3953
|
+
add.dataset.localVariable = box.id;
|
|
3954
|
+
add.dataset.localKey = 'add';
|
|
3955
|
+
add.onclick = () => this.requestLocalVariableEdit(box, { type: 'add' });
|
|
3956
|
+
const actions = document.createElement('div');
|
|
3957
|
+
actions.setAttribute('part', 'local-variable-actions');
|
|
3958
|
+
actions.append(add);
|
|
3959
|
+
section.append(actions);
|
|
3960
|
+
}
|
|
3961
|
+
return section;
|
|
3962
|
+
}
|
|
3963
|
+
requestLocalVariableEdit(box, edit) {
|
|
3964
|
+
const owner = this.selected;
|
|
3965
|
+
if (this.locked || !owner?.localVariablesEditable || owner.id !== box.id)
|
|
3966
|
+
return;
|
|
3967
|
+
if (edit.type !== 'add' && owner.localVariables?.[edit.index]?.name !== edit.name)
|
|
3968
|
+
return;
|
|
3969
|
+
const request = { boxId: owner.id, ...(owner.path ? { path: [...owner.path] } : {}), edit };
|
|
3970
|
+
const session = this.layoutEditSession, beforeNames = (owner.localVariables ?? []).map(variable => variable.name), beforeCount = beforeNames.length;
|
|
3971
|
+
emit(this, 'process-local-variable-edit-request', request);
|
|
3972
|
+
if (session !== this.layoutEditSession || this.selected?.id !== box.id)
|
|
3973
|
+
return;
|
|
3974
|
+
const count = this.selected.localVariables?.length ?? 0;
|
|
3975
|
+
if (edit.type === 'add' && count > beforeCount) {
|
|
3976
|
+
const remaining = [...beforeNames];
|
|
3977
|
+
const added = (this.selected.localVariables ?? []).filter(variable => {
|
|
3978
|
+
const index = remaining.indexOf(variable.name);
|
|
3979
|
+
if (index < 0)
|
|
3980
|
+
return true;
|
|
3981
|
+
remaining.splice(index, 1);
|
|
3982
|
+
return false;
|
|
3983
|
+
});
|
|
3984
|
+
if (added.length === 1 && this.selected.localVariables?.filter(variable => variable.name === added[0].name).length === 1) {
|
|
3985
|
+
const name = Array.from(this.shadowRoot.querySelectorAll('[data-local-key=rename]')).find(input => input.dataset.localVariable === box.id && input.dataset.localName === added[0].name);
|
|
3986
|
+
name?.focus();
|
|
3987
|
+
name?.select();
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
else if (edit.type === 'remove' && count < beforeCount) {
|
|
3991
|
+
Array.from(this.shadowRoot.querySelectorAll('[data-local-key=add]')).find(button => button.dataset.localVariable === box.id)?.focus();
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3472
3994
|
renderSelectionToolbar() {
|
|
3473
3995
|
const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
|
|
3474
|
-
const key = `${
|
|
3996
|
+
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
3997
|
if (key === this.selectionToolbarKey)
|
|
3476
3998
|
return;
|
|
3477
3999
|
this.selectionToolbarKey = key;
|
|
3478
4000
|
toolbar.replaceChildren();
|
|
3479
|
-
if (!this.
|
|
4001
|
+
if (!this.selection.length)
|
|
3480
4002
|
return;
|
|
3481
4003
|
const add = (label, command, action, withPlus = false) => {
|
|
3482
4004
|
const button = document.createElement('button');
|
|
@@ -3497,7 +4019,12 @@ export class ProcessModeler extends BaseElement {
|
|
|
3497
4019
|
button.append(text);
|
|
3498
4020
|
toolbar.append(button);
|
|
3499
4021
|
};
|
|
3500
|
-
if (this.
|
|
4022
|
+
if (this.graphSelection && (this.selectedNoteIds.size || this.selectedLineIds.size > 1 || this.selectedLineIds.size && this.selectedIds.size)) {
|
|
4023
|
+
if (this.selectedIds.size > 1)
|
|
4024
|
+
add('Line up', 'align', () => this.selectionCommand('align'));
|
|
4025
|
+
add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
|
|
4026
|
+
}
|
|
4027
|
+
else if (this.selectedLine) {
|
|
3501
4028
|
const line = this.selectedLine;
|
|
3502
4029
|
add('Insert a step', 'insert', () => this.openLineChooser(line, toolbar.querySelector('[data-selection-command=insert]') ?? undefined), true);
|
|
3503
4030
|
if (this.layoutValue.lines?.[line.id] || line.fromSide || line.toSide || line.points?.length)
|
|
@@ -3513,7 +4040,7 @@ export class ProcessModeler extends BaseElement {
|
|
|
3513
4040
|
if (!['end', 'finish', 'section', 'note'].includes(box.kind))
|
|
3514
4041
|
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
4042
|
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);
|
|
4043
|
+
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
4044
|
if (!['start', 'timer', 'section'].includes(box.kind))
|
|
3518
4045
|
add('Duplicate', 'duplicate', () => this.duplicateBoxes([box.id], 48));
|
|
3519
4046
|
if (!['start', 'timer'].includes(box.kind))
|
|
@@ -3549,6 +4076,9 @@ export class ProcessModeler extends BaseElement {
|
|
|
3549
4076
|
paintViewport() {
|
|
3550
4077
|
const world = this.shadowRoot.querySelector("[part=world]");
|
|
3551
4078
|
world.style.setProperty('--boe-process-inverse-zoom', String(1 / this.viewport.zoom));
|
|
4079
|
+
// A small screen-space margin keeps targets above 24px after fractional
|
|
4080
|
+
// layout rounding without assuming a browser's CSS-pixel quantum.
|
|
4081
|
+
world.style.setProperty('--boe-process-hit-target', `${24.1 / this.viewport.zoom}px`);
|
|
3552
4082
|
world.style.transform =
|
|
3553
4083
|
`translate(${this.viewport.x}px,${this.viewport.y}px) scale(${this.viewport.zoom})`;
|
|
3554
4084
|
const canvas = this.shadowRoot.querySelector("[part=canvas]");
|
|
@@ -3596,17 +4126,27 @@ export class ProcessModeler extends BaseElement {
|
|
|
3596
4126
|
minimap.append(viewport);
|
|
3597
4127
|
}
|
|
3598
4128
|
positionSelectionToolbar() {
|
|
3599
|
-
if (!this.isRendered ||
|
|
4129
|
+
if (!this.isRendered || !this.selection.length)
|
|
3600
4130
|
return;
|
|
3601
4131
|
const canvas = this.shadowRoot.querySelector('[part=canvas]');
|
|
3602
4132
|
const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
|
|
3603
4133
|
const positions = [...this.selectedIds].map(id => this.layoutValue.boxes[id]).filter(Boolean);
|
|
4134
|
+
positions.push(...(this.layoutValue.notes ?? []).filter(note => this.selectedNoteIds.has(note.id)).map(note => ({ ...note, width: 208, height: 80 })));
|
|
3604
4135
|
if (!positions.length && this.selectedLine) {
|
|
3605
4136
|
const point = lineMidpoint(this.routedLines.get(this.selectedLine.id) ?? []);
|
|
3606
4137
|
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
4138
|
toolbar.style.top = `${Math.max(8, point.y * this.viewport.zoom + this.viewport.y - 52)}px`;
|
|
3608
4139
|
return;
|
|
3609
4140
|
}
|
|
4141
|
+
if (!positions.length) {
|
|
4142
|
+
for (const id of this.selectedLineIds) {
|
|
4143
|
+
const points = this.routedLines.get(id) ?? [];
|
|
4144
|
+
if (!points.length)
|
|
4145
|
+
continue;
|
|
4146
|
+
const x = Math.min(...points.map(point => point.x)), y = Math.min(...points.map(point => point.y));
|
|
4147
|
+
positions.push({ x, y, width: Math.max(...points.map(point => point.x)) - x, height: Math.max(...points.map(point => point.y)) - y });
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
3610
4150
|
if (!positions.length)
|
|
3611
4151
|
return;
|
|
3612
4152
|
const left = Math.min(...positions.map(position => position.x * this.viewport.zoom + this.viewport.x));
|