@unofficialbox/box-open-elements 0.31.2 → 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.
@@ -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();
@@ -74,7 +94,36 @@ export class ProcessModeler extends BaseElement {
74
94
  activeDrawer;
75
95
  drawerReturn;
76
96
  selectedIds = new Set();
77
- clipboardIds = [];
97
+ clipboard = null;
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
+ }
126
+ restoringBoxFocus = false;
78
127
  selectionToolbarKey = "";
79
128
  sectionSequence = 0;
80
129
  versionValue = 0;
@@ -99,6 +148,14 @@ export class ProcessModeler extends BaseElement {
99
148
  spacePressed = false;
100
149
  dropLine;
101
150
  paletteDragKind = null;
151
+ palettePointer;
152
+ suppressPaletteClick = false;
153
+ escapePalettePointer = (event) => {
154
+ if (event.key !== 'Escape' || !this.palettePointer)
155
+ return;
156
+ this.cancelPalettePointer();
157
+ event.preventDefault();
158
+ };
102
159
  narrowValue = false;
103
160
  suppressClick = false;
104
161
  resizeObserver;
@@ -157,6 +214,9 @@ export class ProcessModeler extends BaseElement {
157
214
  };
158
215
  }
159
216
  load(document, options = {}) {
217
+ this.pendingLocalRename = undefined;
218
+ this.layoutEditSession++;
219
+ this.cancelPalettePointer();
160
220
  const projection = this.model.project(document);
161
221
  validateProjection(projection);
162
222
  this.documentValue = document;
@@ -166,11 +226,12 @@ export class ProcessModeler extends BaseElement {
166
226
  this.lastReadableProjection = "";
167
227
  this.readableValue = null;
168
228
  this.layoutValue = restoreProcessPositions(projection, options.positions ?? []);
229
+ this.clearMixedSelection();
169
230
  this.history.clear();
170
231
  this.selectedId = null;
171
232
  this.selectedLineId = null;
172
233
  this.selectedIds.clear();
173
- this.clipboardIds = [];
234
+ this.clearClipboard();
174
235
  this.refresh();
175
236
  if (options.selectedPath)
176
237
  this.selectedPath = options.selectedPath;
@@ -181,15 +242,54 @@ export class ProcessModeler extends BaseElement {
181
242
  validateProjection(this.projection);
182
243
  }
183
244
  const next = new Set(ids.filter(id => this.projection.boxes.some(box => box.id === id)));
184
- 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)))
185
246
  return;
247
+ this.clearMixedSelection();
186
248
  this.selectedIds = next;
187
249
  this.selectedId = this.selectedIds.values().next().value ?? null;
188
250
  this.selectedLineId = null;
189
251
  this.renderSelection();
190
252
  if (this.isRendered)
191
253
  this.updateToolbar();
192
- emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
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;
193
293
  }
194
294
  get headingLevel() { const level = Number(this.getAttribute("heading-level") ?? 2); return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2; }
195
295
  set headingLevel(value) { this.setAttribute("heading-level", String(value)); }
@@ -213,8 +313,13 @@ export class ProcessModeler extends BaseElement {
213
313
  this.refresh();
214
314
  }
215
315
  set document(value) {
316
+ this.localEchoRevision++;
317
+ const rename = this.pendingLocalRename;
216
318
  this.documentValue = value;
217
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;
218
323
  }
219
324
  get model() {
220
325
  return this.modelValue;
@@ -259,6 +364,8 @@ export class ProcessModeler extends BaseElement {
259
364
  }
260
365
  set locked(value) {
261
366
  this.toggleAttribute("locked", value);
367
+ if (value)
368
+ this.cancelPalettePointer();
262
369
  }
263
370
  get snapToGrid() {
264
371
  return this.hasAttribute("snap-to-grid");
@@ -280,7 +387,10 @@ export class ProcessModeler extends BaseElement {
280
387
  this.refresh();
281
388
  }
282
389
  disconnectedCallback() {
390
+ this.layoutEditSession++;
283
391
  this.resizeObserver?.disconnect();
392
+ this.clearClipboard();
393
+ this.cancelPalettePointer();
284
394
  this.closeDrawer();
285
395
  const chooser = this.shadowRoot?.querySelector("[part=insert-chooser]");
286
396
  if (chooser)
@@ -312,8 +422,9 @@ export class ProcessModeler extends BaseElement {
312
422
  }
313
423
  if (id !== null && !this.projection.boxes.some((box) => box.id === id))
314
424
  return;
315
- 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)
316
426
  return;
427
+ this.clearMixedSelection();
317
428
  this.selectedId = id;
318
429
  this.selectedIds = new Set(id ? [id] : []);
319
430
  this.selectedLineId = null;
@@ -321,18 +432,20 @@ export class ProcessModeler extends BaseElement {
321
432
  if (this.isRendered)
322
433
  this.updateToolbar();
323
434
  if (notify)
324
- emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
435
+ this.notify("selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, ...(this.graphSelection ? { selection: this.selection } : {}) });
325
436
  }
326
437
  selectLine(id) {
327
438
  if (id !== null && !this.projection.lines.some(line => line.id === id))
328
439
  return;
329
- if (this.selectedLineId === id && !this.selectedIds.size)
440
+ if (!this.graphSelection && this.selectedLineId === id && !this.selectedIds.size)
330
441
  return;
442
+ this.clearMixedSelection();
443
+ this.selectedLineIds = new Set(id ? [id] : []);
331
444
  this.selectedLineId = id;
332
445
  this.selectedId = null;
333
446
  this.selectedIds.clear();
334
447
  this.refresh();
335
- emit(this, 'selection-changed', { box: null, boxes: [], line: this.selectedLine, path: null });
448
+ this.notify('selection-changed', { box: null, boxes: [], line: this.selectedLine, path: null });
336
449
  }
337
450
  setValidation(checks) {
338
451
  this.checks = checks;
@@ -341,21 +454,100 @@ export class ProcessModeler extends BaseElement {
341
454
  setValidationAtPath(message, path) {
342
455
  this.setValidation(message ? [{ message, path }] : []);
343
456
  }
457
+ connectionProblem(edit) {
458
+ if (!this.model.connectionProblem || this.documentValue === undefined || !['connect', 'reattach'].includes(edit.type))
459
+ return null;
460
+ if (!this.isRendered) {
461
+ this.projection = this.model.project(this.documentValue);
462
+ validateProjection(this.projection);
463
+ }
464
+ const line = edit.type === 'reattach' ? this.projection.lines.find(line => line.id === edit.lineId) : undefined;
465
+ const from = edit.from ?? line?.from;
466
+ const to = edit.to ?? line?.to;
467
+ if (!from || !to)
468
+ return null;
469
+ return this.model.connectionProblem(this.documentValue, { from, to, ...(line ? { ignoreLineId: line.id } : {}) }, this.projection) || null;
470
+ }
344
471
  requestEdit(edit) {
345
472
  if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect" || edit.type === 'reattach')))
346
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
+ }
508
+ const problem = this.connectionProblem(edit);
509
+ if (problem) {
510
+ this.setStatus(problem, true);
511
+ return;
512
+ }
513
+ const request = { ...edit, accept: this.editAcceptance(edit) };
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
+ }
528
+ }
529
+ editAcceptance(edit) {
347
530
  const before = this.layout;
531
+ const beforeSelection = [...this.selectedIds];
532
+ const beforeItems = this.selection;
348
533
  const previousIds = new Set(this.projection.boxes.map((box) => box.id));
534
+ const session = this.layoutEditSession;
349
535
  let accepted = false;
350
- const request = {
351
- ...edit,
352
- accept: (command) => {
353
- if (accepted)
354
- return;
355
- accepted = true;
536
+ return (command) => {
537
+ if (accepted || session !== this.layoutEditSession)
538
+ return;
539
+ accepted = true;
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 {
356
544
  if (command.layout)
357
545
  this.layoutValue = structuredClone(command.layout);
546
+ if (edit.type === 'reset-line' && edit.lineId)
547
+ delete this.layoutValue.lines?.[edit.lineId];
358
548
  this.refresh();
549
+ if (session !== this.layoutEditSession)
550
+ return;
359
551
  const inserted = edit.type === 'insert' && !edit.boxId && !command.layout
360
552
  ? this.projection.boxes.find(box => !previousIds.has(box.id)) : undefined;
361
553
  const reflowed = inserted ? this.reflowStraightInsert(edit, inserted.id, before) : false;
@@ -368,7 +560,11 @@ export class ProcessModeler extends BaseElement {
368
560
  if (added)
369
561
  this.layoutValue = this.translatedLayout(added.id, edit.position.x, edit.position.y);
370
562
  }
563
+ if (session !== this.layoutEditSession)
564
+ return;
371
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;
372
568
  const restore = (layout) => {
373
569
  this.layoutValue = structuredClone(layout);
374
570
  this.refresh();
@@ -378,19 +574,34 @@ export class ProcessModeler extends BaseElement {
378
574
  undo: () => {
379
575
  command.undo();
380
576
  restore(before);
577
+ if (acceptedItems)
578
+ this.selectItems(beforeItems);
579
+ else if (acceptedSelection)
580
+ this.selectMany(beforeSelection);
381
581
  },
382
582
  redo: () => {
383
583
  command.redo();
384
584
  restore(after);
585
+ if (acceptedItems)
586
+ this.selectItems(acceptedItems);
587
+ else if (acceptedSelection)
588
+ this.selectMany(acceptedSelection);
385
589
  },
386
590
  });
591
+ if (acceptedItems)
592
+ this.selectItems(acceptedItems);
593
+ else if (acceptedSelection)
594
+ this.selectMany(acceptedSelection);
387
595
  this.refresh();
388
596
  if (JSON.stringify(before) !== JSON.stringify(after))
389
597
  this.emitPositions();
390
598
  announce("Process updated", "polite", this.ownerDocument);
391
- },
599
+ }
600
+ finally {
601
+ this.acceptanceNotifications--;
602
+ this.flushAcceptanceNotifications();
603
+ }
392
604
  };
393
- emit(this, "process-edit-request", request);
394
605
  }
395
606
  undo() {
396
607
  if (this.locked)
@@ -404,6 +615,53 @@ export class ProcessModeler extends BaseElement {
404
615
  this.history.redo();
405
616
  this.refresh();
406
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
+ }
407
665
  commitLayout(next) {
408
666
  const before = this.layout;
409
667
  const after = structuredClone(next);
@@ -432,8 +690,8 @@ export class ProcessModeler extends BaseElement {
432
690
  this.commitLayout(next);
433
691
  }
434
692
  emitPositions() {
435
- emit(this, "layout-changed", { layout: this.layout });
436
- emit(this, "positions-changed", { positions: this.positions, version: this.versionValue });
693
+ this.notify("layout-changed", { layout: this.layout });
694
+ this.notify("positions-changed", { positions: this.positions, version: this.versionValue });
437
695
  }
438
696
  translatedLayout(id, x, y) {
439
697
  const snap = (n) => (this.snapToGrid ? Math.round(n / 16) * 16 : n);
@@ -499,6 +757,9 @@ export class ProcessModeler extends BaseElement {
499
757
  tidy() {
500
758
  if (this.locked)
501
759
  return;
760
+ if (this.requestLayoutEdit('tidy', null, undefined, () => { if (this.isRendered)
761
+ this.fitTo(this.narrowValue ? 0.55 : 0.7); }))
762
+ return;
502
763
  const before = this.layout.boxes;
503
764
  this.commitLayout(this.arrangedLayout({ ...this.layout, boxes: {} }));
504
765
  if (!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
@@ -541,6 +802,13 @@ export class ProcessModeler extends BaseElement {
541
802
  });
542
803
  }
543
804
  resetLine(id) {
805
+ const line = this.projection.lines.find(line => line.id === id);
806
+ if (this.locked || !line)
807
+ return;
808
+ if (line.fromSide || line.toSide || line.points?.length) {
809
+ this.requestEdit({ type: 'reset-line', lineId: id });
810
+ return;
811
+ }
544
812
  if (!this.layoutValue.lines?.[id])
545
813
  return;
546
814
  const next = this.layout;
@@ -763,7 +1031,7 @@ export class ProcessModeler extends BaseElement {
763
1031
  palette.prepend(skip, paletteHeading);
764
1032
  const hint = document.createElement("p");
765
1033
  hint.setAttribute("part", "palette-hint");
766
- hint.textContent = "Drag onto the canvas, a connection, or a frame. Select a step first to add after it.";
1034
+ hint.textContent = "Drag onto the canvas, onto a line to insert, or into a frame. Choosing one with a step selected adds it after that step. Or click a dot beside any step to add one that way.";
767
1035
  palette.append(hint);
768
1036
  for (const [part, label] of [["palette", "Building blocks"], ["inspector", "Process details"]]) {
769
1037
  const pane = root.querySelector(`[part=${part}]`);
@@ -864,6 +1132,12 @@ export class ProcessModeler extends BaseElement {
864
1132
  controls.setAttribute("role", "toolbar");
865
1133
  controls.setAttribute("aria-label", "Canvas view controls");
866
1134
  for (const command of ["zoom-out", "reset", "zoom-in", "fit", "snap", "lock"]) {
1135
+ if (command === 'snap') {
1136
+ const divider = document.createElement('span');
1137
+ divider.setAttribute('part', 'controls-divider');
1138
+ divider.setAttribute('aria-hidden', 'true');
1139
+ controls.append(divider);
1140
+ }
867
1141
  const button = root.querySelector(`[data-command=${command}]`);
868
1142
  if (button)
869
1143
  controls.append(button);
@@ -896,6 +1170,7 @@ export class ProcessModeler extends BaseElement {
896
1170
  this.narrowValue = narrow;
897
1171
  this.toggleAttribute("data-narrow", narrow);
898
1172
  this.toggleAttribute('data-phone', entries[0].contentRect.width < 560);
1173
+ this.toggleAttribute('data-hide-tidy', entries[0].contentRect.width <= 560);
899
1174
  this.renderSelectionToolbar();
900
1175
  this.positionSelectionToolbar();
901
1176
  if (!narrow)
@@ -968,6 +1243,7 @@ export class ProcessModeler extends BaseElement {
968
1243
  });
969
1244
  const content = root.querySelector('[part=pane-content]');
970
1245
  content.hidden = Boolean(selected);
1246
+ content.dataset.pane = this.activePane;
971
1247
  content.setAttribute("aria-labelledby", `process-tab-${this.activePane.toLowerCase()}`);
972
1248
  const checks = root.querySelector('[part=checks]');
973
1249
  checks.hidden = this.activePane !== "Checks";
@@ -1026,29 +1302,50 @@ export class ProcessModeler extends BaseElement {
1026
1302
  content.append(checks);
1027
1303
  }
1028
1304
  else if (this.activePane === "Variables") {
1029
- if (!this.variables.length)
1030
- content.textContent = 'No variables supplied by the host.';
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
+ }
1031
1327
  for (const [index, variable] of this.variables.entries()) {
1032
1328
  const row = document.createElement('div');
1033
1329
  row.setAttribute('part', 'variable-row');
1034
1330
  const identify = (control, key) => { control.dataset.variable = variable.name; control.dataset.variableKey = key; control.dataset.variableIndex = String(index); };
1035
1331
  if (this.variablesEditable) {
1036
- const label = document.createElement('label');
1037
- label.textContent = 'Name';
1332
+ const label = variableLabel('Name');
1038
1333
  const name = document.createElement('input');
1039
1334
  name.type = 'text';
1335
+ name.placeholder = 'name';
1336
+ name.spellcheck = false;
1337
+ name.autocomplete = 'off';
1040
1338
  name.value = variable.name;
1041
1339
  name.disabled = this.locked;
1042
1340
  identify(name, 'name');
1043
1341
  name.onchange = () => this.requestVariableEdit({ type: 'rename', name: variable.name, value: name.value });
1044
1342
  label.append(name);
1045
1343
  row.append(label);
1046
- const scopeLabel = document.createElement('label');
1047
- scopeLabel.textContent = 'Scope';
1344
+ const scopeLabel = variableLabel('Kept for');
1048
1345
  const scope = document.createElement('select');
1049
1346
  identify(scope, 'scope');
1050
1347
  scope.disabled = this.locked;
1051
- for (const [value, text] of [['iteration', 'Each iteration'], ['process', 'The whole process']]) {
1348
+ for (const [value, text] of [['iteration', 'Iteration'], ['process', 'Process']]) {
1052
1349
  const option = document.createElement('option');
1053
1350
  option.value = value;
1054
1351
  option.textContent = text;
@@ -1070,16 +1367,32 @@ export class ProcessModeler extends BaseElement {
1070
1367
  row.append(scope);
1071
1368
  }
1072
1369
  }
1073
- if (variable.description) {
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) {
1074
1383
  const description = document.createElement('p');
1075
1384
  description.textContent = variable.description;
1076
1385
  row.append(description);
1077
1386
  }
1078
1387
  if (variable.startingValue !== undefined) {
1079
- const label = document.createElement('label');
1080
- label.textContent = 'Starting value';
1388
+ const label = this.variablesEditable ? variableLabel('Starts as') : document.createElement('label');
1389
+ if (!this.variablesEditable)
1390
+ label.textContent = 'Starting value';
1081
1391
  const input = document.createElement('input');
1082
1392
  input.type = 'text';
1393
+ input.placeholder = 'starts as, like 0';
1394
+ input.spellcheck = false;
1395
+ input.autocomplete = 'off';
1083
1396
  identify(input, 'value');
1084
1397
  input.value = variable.startingValue;
1085
1398
  input.disabled = this.locked;
@@ -1102,13 +1415,26 @@ export class ProcessModeler extends BaseElement {
1102
1415
  remove.type = 'button';
1103
1416
  remove.setAttribute('part', 'variable-remove');
1104
1417
  remove.setAttribute('aria-label', `Remove ${variable.name || 'this variable'}`);
1105
- remove.textContent = '×';
1418
+ remove.append(variableGlyph());
1106
1419
  remove.disabled = this.locked;
1107
1420
  identify(remove, 'remove');
1108
1421
  remove.onclick = () => this.requestVariableEdit({ type: 'remove', name: variable.name });
1109
1422
  row.append(remove);
1110
1423
  }
1111
- content.append(row);
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);
1112
1438
  }
1113
1439
  if (this.variablesEditable) {
1114
1440
  const add = document.createElement('button');
@@ -1116,11 +1442,45 @@ export class ProcessModeler extends BaseElement {
1116
1442
  add.setAttribute('part', 'variable-add');
1117
1443
  add.dataset.variable = '@add';
1118
1444
  add.dataset.variableKey = 'add';
1119
- add.textContent = 'Add a variable';
1445
+ add.append(variableGlyph(true), document.createTextNode('Add a variable'));
1120
1446
  add.disabled = this.locked;
1121
1447
  add.onclick = () => this.requestVariableEdit({ type: 'add' });
1122
- content.append(add);
1448
+ const actions = document.createElement('div');
1449
+ actions.setAttribute('part', 'variable-actions');
1450
+ actions.append(add);
1451
+ content.append(actions);
1123
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.');
1124
1484
  }
1125
1485
  else if (this.activePane === "Connections") {
1126
1486
  if (!this.connections.length)
@@ -1162,6 +1522,14 @@ export class ProcessModeler extends BaseElement {
1162
1522
  if (this.locked)
1163
1523
  return;
1164
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
+ }
1165
1533
  if (command === "delete-many") {
1166
1534
  for (const box of boxes)
1167
1535
  this.requestEdit({ type: "delete", boxId: box.id });
@@ -1179,6 +1547,8 @@ export class ProcessModeler extends BaseElement {
1179
1547
  tidySelection() {
1180
1548
  if (this.locked || this.selectedBoxes.length < 2)
1181
1549
  return;
1550
+ if (this.requestLayoutEdit('tidy', [...this.selectedIds]))
1551
+ return;
1182
1552
  const next = this.layout;
1183
1553
  const roots = this.selectedBoxes.filter(box => {
1184
1554
  let parent = box.parentId;
@@ -1218,11 +1588,13 @@ export class ProcessModeler extends BaseElement {
1218
1588
  const arrangedY = Math.min(...group.map(box => arranged.boxes[box.id]?.y ?? Infinity));
1219
1589
  if (![arrangedX, arrangedY].every(Number.isFinite))
1220
1590
  continue;
1591
+ const dx = Math.round((startX - arrangedX) / 16) * 16;
1592
+ const dy = Math.round((startY - arrangedY) / 16) * 16;
1221
1593
  for (const box of subset.boxes) {
1222
1594
  const position = arranged.boxes[box.id];
1223
1595
  if (!position || !next.boxes[box.id])
1224
1596
  continue;
1225
- next.boxes[box.id] = { ...next.boxes[box.id], x: startX + position.x - arrangedX, y: startY + position.y - arrangedY };
1597
+ next.boxes[box.id] = { ...next.boxes[box.id], x: position.x + dx, y: position.y + dy };
1226
1598
  }
1227
1599
  }
1228
1600
  this.commitLayout(next);
@@ -1315,7 +1687,11 @@ export class ProcessModeler extends BaseElement {
1315
1687
  this.commitLayout(next);
1316
1688
  }
1317
1689
  makeSection(title = 'New section') {
1318
- if (this.locked || this.selectedBoxes.length < 2)
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)
1319
1695
  return;
1320
1696
  const positions = this.selectedBoxes.map(box => this.layoutValue.boxes[box.id]);
1321
1697
  const x = Math.min(...positions.map(position => position.x)) - 24;
@@ -1326,6 +1702,16 @@ export class ProcessModeler extends BaseElement {
1326
1702
  this.setSection({ id, title, x, y, width: right - x, height: bottom - y });
1327
1703
  }
1328
1704
  setupListeners() {
1705
+ this.addEventListener('pointermove', event => this.movePalettePointer(event));
1706
+ this.addEventListener('pointerup', event => this.finishPalettePointer(event));
1707
+ this.addEventListener('pointercancel', event => { if (event.pointerId === this.palettePointer?.id)
1708
+ this.cancelPalettePointer(); });
1709
+ this.addEventListener('lostpointercapture', event => { if (event.pointerId === this.palettePointer?.id)
1710
+ this.cancelPalettePointer(); });
1711
+ this.shadowRoot.addEventListener('keydown', event => { if (event.key === 'Escape' && this.palettePointer) {
1712
+ this.cancelPalettePointer();
1713
+ event.preventDefault();
1714
+ } }, true);
1329
1715
  this.shadowRoot.addEventListener("click", (event) => {
1330
1716
  const command = event.target.closest("button")?.dataset.command;
1331
1717
  if (command === "zoom-in")
@@ -1455,6 +1841,93 @@ export class ProcessModeler extends BaseElement {
1455
1841
  this.paintViewport();
1456
1842
  });
1457
1843
  }
1844
+ clearClipboard() {
1845
+ const previous = this.clipboard;
1846
+ this.clipboard = null;
1847
+ this.clipboardSession++;
1848
+ previous?.dispose?.();
1849
+ }
1850
+ copySelection() {
1851
+ this.clearClipboard();
1852
+ const session = this.clipboardSession;
1853
+ let active = true;
1854
+ let claimed = false;
1855
+ const request = {
1856
+ sourceIds: Object.freeze([...this.selectedIds]),
1857
+ selection: Object.freeze(this.selection.map(item => Object.freeze(item))),
1858
+ capture: clipboard => {
1859
+ if (!active || claimed || session !== this.clipboardSession || !this.isConnected)
1860
+ return;
1861
+ claimed = true;
1862
+ if (!Number.isInteger(clipboard?.itemCount) || clipboard.itemCount < 1 || typeof clipboard.paste !== 'function' || (clipboard.dispose !== undefined && typeof clipboard.dispose !== 'function')) {
1863
+ this.setStatus('The host did not supply a valid clipboard');
1864
+ return;
1865
+ }
1866
+ this.clipboard = Object.freeze({ itemCount: clipboard.itemCount, paste: clipboard.paste.bind(clipboard), ...(clipboard.dispose ? { dispose: clipboard.dispose.bind(clipboard) } : {}) });
1867
+ this.setStatus(`${clipboard.itemCount} steps copied`);
1868
+ },
1869
+ refuse: message => { if (!active || claimed || session !== this.clipboardSession || !this.isConnected)
1870
+ return; claimed = true; this.setStatus(message ?? 'Copy is not supported by this host'); },
1871
+ };
1872
+ try {
1873
+ emit(this, this.graphSelection && (this.selectedLineIds.size || this.selectedNoteIds.size) ? 'process-selection-copy-request' : 'process-copy-request', request);
1874
+ }
1875
+ finally {
1876
+ active = false;
1877
+ }
1878
+ if (!claimed && session === this.clipboardSession && this.isConnected)
1879
+ this.setStatus('Copy is not supported by this host');
1880
+ return claimed;
1881
+ }
1882
+ pasteClipboard() {
1883
+ const clipboard = this.clipboard;
1884
+ if (!clipboard || this.locked)
1885
+ return;
1886
+ const session = this.clipboardSession;
1887
+ const accept = this.editAcceptance({ type: 'duplicate' });
1888
+ let active = true;
1889
+ let settled = false;
1890
+ const request = {
1891
+ offset: Object.freeze({ x: 32, y: 32 }),
1892
+ accept: command => {
1893
+ if (!active || settled || session !== this.clipboardSession || this.locked)
1894
+ return;
1895
+ settled = true;
1896
+ accept(command);
1897
+ },
1898
+ refuse: message => { if (!active || settled || session !== this.clipboardSession || !this.isConnected)
1899
+ return; settled = true; this.setStatus(message ?? 'Paste was refused by this host'); },
1900
+ };
1901
+ try {
1902
+ clipboard.paste(request);
1903
+ }
1904
+ finally {
1905
+ active = false;
1906
+ }
1907
+ if (!settled && session === this.clipboardSession)
1908
+ this.setStatus('Paste was not accepted by this host');
1909
+ }
1910
+ duplicateBoxes(ids, distance) {
1911
+ const selected = new Set(ids);
1912
+ if (!selected.size || [...selected].some(id => !this.projection.boxes.some(box => box.id === id) || !this.layoutValue.boxes[id]))
1913
+ return;
1914
+ const sources = [...selected].filter(id => {
1915
+ let parent = this.projection.boxes.find(box => box.id === id)?.parentId;
1916
+ while (parent) {
1917
+ if (selected.has(parent))
1918
+ return false;
1919
+ parent = this.projection.boxes.find(box => box.id === parent)?.parentId;
1920
+ }
1921
+ return true;
1922
+ });
1923
+ if (sources.length === 1) {
1924
+ const position = this.layoutValue.boxes[sources[0]];
1925
+ this.requestEdit({ type: 'duplicate', sourceId: sources[0], position: { x: position.x + distance, y: position.y + distance } });
1926
+ }
1927
+ else {
1928
+ this.requestEdit({ type: 'duplicate', sourceIds: sources, offset: { x: distance, y: distance } });
1929
+ }
1930
+ }
1458
1931
  onKey(event) {
1459
1932
  if (event.defaultPrevented)
1460
1933
  return;
@@ -1478,25 +1951,27 @@ export class ProcessModeler extends BaseElement {
1478
1951
  }
1479
1952
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
1480
1953
  event.preventDefault();
1481
- this.selectMany(this.projection.boxes.map(box => box.id));
1482
- this.setStatus(`${this.projection.boxes.length} steps selected`);
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`);
1483
1960
  return;
1484
1961
  }
1485
- if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && this.selectedIds.size) {
1486
- event.preventDefault();
1487
- this.clipboardIds = [...this.selectedIds];
1488
- this.setStatus(`${this.clipboardIds.length} steps copied`);
1962
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && (this.selectedIds.size || this.selectedNoteIds.size)) {
1963
+ if (this.copySelection())
1964
+ event.preventDefault();
1489
1965
  return;
1490
1966
  }
1491
- if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v' && this.clipboardIds.length && !this.locked) {
1967
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v' && this.clipboard && !this.locked) {
1492
1968
  event.preventDefault();
1493
- this.clipboardIds.forEach((id, index) => { const position = this.layoutValue.boxes[id]; if (position)
1494
- this.requestEdit({ type: 'duplicate', sourceId: id, position: { x: position.x + 32 * (index + 1), y: position.y + 32 * (index + 1) } }); });
1969
+ this.pasteClipboard();
1495
1970
  return;
1496
1971
  }
1497
1972
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd' && this.selectedIds.size && !this.locked) {
1498
1973
  event.preventDefault();
1499
- [...this.selectedIds].forEach(id => { const position = this.layoutValue.boxes[id]; this.requestEdit({ type: 'duplicate', sourceId: id, position: { x: position.x + 32, y: position.y + 32 } }); });
1974
+ this.duplicateBoxes([...this.selectedIds], 48);
1500
1975
  return;
1501
1976
  }
1502
1977
  if (event.key === "Escape") {
@@ -1506,6 +1981,7 @@ export class ProcessModeler extends BaseElement {
1506
1981
  if (this.selectedLineId)
1507
1982
  this.selectLine(null);
1508
1983
  this.portDrag = undefined;
1984
+ this.endDrag = undefined;
1509
1985
  this.marquee = undefined;
1510
1986
  this.segmentDrag = undefined;
1511
1987
  this.drag = undefined;
@@ -1521,6 +1997,11 @@ export class ProcessModeler extends BaseElement {
1521
1997
  this.setStatus("Whole process fitted to the canvas");
1522
1998
  return;
1523
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
+ }
1524
2005
  const box = this.selected;
1525
2006
  if (this.selectedLine && (event.key === 'Delete' || event.key === 'Backspace') && !this.locked && !this.disableConnections) {
1526
2007
  event.preventDefault();
@@ -1532,6 +2013,44 @@ export class ProcessModeler extends BaseElement {
1532
2013
  this.openKeyboardChooser(box);
1533
2014
  return;
1534
2015
  }
2016
+ const direction = {
2017
+ ArrowLeft: [-1, 0],
2018
+ ArrowRight: [1, 0],
2019
+ ArrowUp: [0, -1],
2020
+ ArrowDown: [0, 1],
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
+ }
2048
+ const canInsertDirection = event.ctrlKey && event.altKey && box && !this.locked && this.catalog.length && !this.disableConnections;
2049
+ if (direction && !canInsertDirection && (event.altKey || !box || this.locked)) {
2050
+ event.preventDefault();
2051
+ this.navigateSelection(direction);
2052
+ return;
2053
+ }
1535
2054
  if (!box || this.locked)
1536
2055
  return;
1537
2056
  if (event.key === "Enter") {
@@ -1560,12 +2079,6 @@ export class ProcessModeler extends BaseElement {
1560
2079
  event.preventDefault();
1561
2080
  this.selectionCommand("delete-many");
1562
2081
  }
1563
- const direction = {
1564
- ArrowLeft: [-1, 0],
1565
- ArrowRight: [1, 0],
1566
- ArrowUp: [0, -1],
1567
- ArrowDown: [0, 1],
1568
- }[event.key];
1569
2082
  if (direction) {
1570
2083
  event.preventDefault();
1571
2084
  if (event.ctrlKey && event.altKey && this.catalog.length && !this.disableConnections) {
@@ -1578,31 +2091,52 @@ export class ProcessModeler extends BaseElement {
1578
2091
  this.openAnchoredChooser(this.nextEdit(box, side, { x: pos.x + direction[0] * 320, y: pos.y + direction[1] * 170 }), this.nextChooserTitle(box, side), anchor, returnFocus);
1579
2092
  return;
1580
2093
  }
1581
- if (event.altKey) {
1582
- const origin = this.layoutValue.boxes[box.id];
1583
- const center = { x: origin.x + (origin.width ?? 224) / 2, y: origin.y + (origin.height ?? 64) / 2 };
1584
- const candidates = this.projection.boxes.filter(candidate => candidate.id !== box.id).map(candidate => {
1585
- const position = this.layoutValue.boxes[candidate.id];
1586
- const dx = position.x + (position.width ?? 224) / 2 - center.x;
1587
- const dy = position.y + (position.height ?? 64) / 2 - center.y;
1588
- const forward = dx * direction[0] + dy * direction[1];
1589
- const sideways = Math.abs(dx * direction[1] - dy * direction[0]);
1590
- return { candidate, forward, score: forward + sideways * 2 };
1591
- }).filter(item => item.forward > 0).sort((a, b) => a.score - b.score || a.candidate.id.localeCompare(b.candidate.id));
1592
- const next = candidates[0]?.candidate;
1593
- if (next) {
1594
- this.select(next.id);
1595
- this.focusBox(next.id);
1596
- this.setStatus(`${next.title} selected`);
1597
- }
1598
- return;
1599
- }
1600
2094
  const pos = this.layoutValue.boxes[box.id];
1601
2095
  const distance = event.shiftKey ? 64 : 16;
1602
2096
  this.move(box.id, pos.x + direction[0] * distance, pos.y + direction[1] * distance);
1603
2097
  this.focusBox(box.id);
1604
2098
  }
1605
2099
  }
2100
+ navigateSelection(direction) {
2101
+ const canvas = this.shadowRoot.querySelector('[part=canvas]');
2102
+ const box = this.selected;
2103
+ const origin = box ? this.layoutValue.boxes[box.id] : undefined;
2104
+ const originSize = box ? this.selectionDimensions(box) : undefined;
2105
+ const center = origin && originSize
2106
+ ? { x: origin.x + originSize.width / 2, y: origin.y + originSize.height / 2 }
2107
+ : { x: (canvas.clientWidth / 2 - this.viewport.x) / this.viewport.zoom, y: (canvas.clientHeight / 2 - this.viewport.y) / this.viewport.zoom };
2108
+ const candidates = this.projection.boxes.filter(candidate => candidate.id !== box?.id && candidate.kind !== 'section').map(candidate => {
2109
+ const position = this.layoutValue.boxes[candidate.id];
2110
+ const size = this.selectionDimensions(candidate);
2111
+ const dx = position.x + size.width / 2 - center.x;
2112
+ const dy = position.y + size.height / 2 - center.y;
2113
+ const forward = dx * direction[0] + dy * direction[1];
2114
+ const sideways = Math.abs(dx * direction[1] - dy * direction[0]);
2115
+ return { candidate, forward, score: forward + sideways * 2.5 };
2116
+ }).filter(item => item.forward > 4).sort((a, b) => a.score - b.score);
2117
+ const next = candidates[0]?.candidate;
2118
+ if (!next) {
2119
+ this.setStatus('Nothing further that way');
2120
+ return;
2121
+ }
2122
+ this.select(next.id);
2123
+ this.revealBox(next);
2124
+ this.focusBox(next.id);
2125
+ this.setStatus(`${next.title} selected`);
2126
+ }
2127
+ revealBox(box) {
2128
+ const canvas = this.shadowRoot.querySelector('[part=canvas]');
2129
+ if (!canvas.clientWidth || !canvas.clientHeight)
2130
+ return;
2131
+ const position = this.layoutValue.boxes[box.id];
2132
+ const size = this.selectionDimensions(box);
2133
+ const left = position.x * this.viewport.zoom + this.viewport.x;
2134
+ const top = position.y * this.viewport.zoom + this.viewport.y;
2135
+ if (left > 48 && top > 48 && left + size.width * this.viewport.zoom < canvas.clientWidth - 48 && top + size.height * this.viewport.zoom < canvas.clientHeight - 48)
2136
+ return;
2137
+ this.setView({ x: canvas.clientWidth / 2 - (position.x + size.width / 2) * this.viewport.zoom,
2138
+ y: canvas.clientHeight / 2 - (position.y + size.height / 2) * this.viewport.zoom, zoom: this.viewport.zoom });
2139
+ }
1606
2140
  pointerDown(event) {
1607
2141
  if (event.button !== 0)
1608
2142
  return;
@@ -1706,10 +2240,12 @@ export class ProcessModeler extends BaseElement {
1706
2240
  const start = this.portDrag.start;
1707
2241
  ghost.setAttribute("points", `${start.x},${start.y} ${point.x},${start.y} ${point.x},${point.y}`);
1708
2242
  const target = this.boxAt(point, this.portDrag.id);
1709
- const pinned = target ? this.sideAt(target, point) : undefined;
1710
- this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); });
2243
+ const problem = target ? this.connectionProblem({ type: 'connect', from: this.portDrag.id, to: target.id }) : null;
2244
+ const pinned = target && !problem ? this.sideAt(target, point) : undefined;
2245
+ ghost.dataset.invalid = String(Boolean(problem));
2246
+ this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); box.dataset.connectInvalid = String(box.dataset.boxId === target?.id && Boolean(problem)); });
1711
2247
  this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { port.dataset.hot = String(port.dataset.owner === target?.id && port.dataset.side === pinned); });
1712
- this.showConnectTooltip(point, target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Connect to ${target.title}` : 'Drop here to add a connected step');
2248
+ this.showConnectTooltip(point, problem ?? (target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Connect to ${target.title}` : 'Drop here to add a connected step'), Boolean(problem));
1713
2249
  return;
1714
2250
  }
1715
2251
  if (this.endDrag) {
@@ -1725,11 +2261,13 @@ export class ProcessModeler extends BaseElement {
1725
2261
  if (fixed)
1726
2262
  ghost.setAttribute('points', `${fixed.x},${fixed.y} ${point.x},${fixed.y} ${point.x},${point.y}`);
1727
2263
  const target = this.boxAt(point);
1728
- const pinned = target ? this.sideAt(target, point) : undefined;
1729
- this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); });
2264
+ const problem = target ? this.connectionProblem({ type: 'reattach', lineId: this.endDrag.lineId, [this.endDrag.end]: target.id }) : null;
2265
+ const pinned = target && !problem ? this.sideAt(target, point) : undefined;
2266
+ ghost.dataset.invalid = String(Boolean(problem) || !target);
2267
+ this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); box.dataset.connectInvalid = String(box.dataset.boxId === target?.id && Boolean(problem)); });
1730
2268
  this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { port.dataset.hot = String(port.dataset.owner === target?.id && port.dataset.side === pinned); });
1731
- this.showConnectTooltip(point, target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Attach to ${target.title}` : 'Drop on a step to reattach', !target);
1732
- this.setStatus(target ? `Release to attach the connection to ${target.title}` : 'Move over a step to attach this connection');
2269
+ this.showConnectTooltip(point, problem ?? (target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Attach to ${target.title}` : 'Drop on a step to reattach'), Boolean(problem) || !target);
2270
+ this.setStatus(problem ?? (target ? `Release to attach the connection to ${target.title}` : 'Move over a step to attach this connection'));
1733
2271
  return;
1734
2272
  }
1735
2273
  if (this.segmentDrag) {
@@ -1828,7 +2366,7 @@ export class ProcessModeler extends BaseElement {
1828
2366
  this.portDrag = undefined;
1829
2367
  this.shadowRoot.querySelector('[part=connection-preview]')?.remove();
1830
2368
  this.shadowRoot.querySelector('[part=connect-tooltip]')?.remove();
1831
- this.shadowRoot.querySelectorAll('[data-connect-target]').forEach(box => { delete box.dataset.connectTarget; });
2369
+ this.shadowRoot.querySelectorAll('[data-connect-target]').forEach(box => { delete box.dataset.connectTarget; delete box.dataset.connectInvalid; });
1832
2370
  this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { delete port.dataset.hot; });
1833
2371
  if (commit) {
1834
2372
  if (Math.hypot(point.x - port.start.x, point.y - port.start.y) < 8) {
@@ -1849,7 +2387,7 @@ export class ProcessModeler extends BaseElement {
1849
2387
  if ((frame?.id ?? null) !== (source.parentId ?? null))
1850
2388
  this.setStatus('Drop inside the same frame to add a connected step');
1851
2389
  else
1852
- 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);
1853
2391
  }
1854
2392
  }
1855
2393
  }
@@ -1860,7 +2398,7 @@ export class ProcessModeler extends BaseElement {
1860
2398
  this.endDrag = undefined;
1861
2399
  this.shadowRoot.querySelector('[part=connection-preview]')?.remove();
1862
2400
  this.shadowRoot.querySelector('[part=connect-tooltip]')?.remove();
1863
- this.shadowRoot.querySelectorAll('[data-connect-target]').forEach(box => { delete box.dataset.connectTarget; });
2401
+ this.shadowRoot.querySelectorAll('[data-connect-target]').forEach(box => { delete box.dataset.connectTarget; delete box.dataset.connectInvalid; });
1864
2402
  this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { delete port.dataset.hot; });
1865
2403
  if (commit) {
1866
2404
  if (Math.hypot(point.x - drag.start.x, point.y - drag.start.y) < 8) {
@@ -1909,8 +2447,11 @@ export class ProcessModeler extends BaseElement {
1909
2447
  const position = this.alignedPoint(drag.id, drag.position.x + dx / this.viewport.zoom, drag.position.y + dy / this.viewport.zoom);
1910
2448
  const line = this.lineAt(point, drag.id);
1911
2449
  const frame = this.boxAt(point, drag.id, true);
1912
- if (line)
1913
- this.requestEdit({ type: "insert", boxId: drag.id, lineId: line.id, from: line.from, to: line.to, position });
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
+ }
1914
2455
  else if (this.projection.boxes.find(box => box.id === drag.id)?.parentId !== frame?.id)
1915
2456
  this.requestEdit({ type: "reparent", boxId: drag.id, parentId: frame?.id, position });
1916
2457
  else if (this.selectedIds.size > 1 && this.selectedIds.has(drag.id)) {
@@ -1976,6 +2517,9 @@ export class ProcessModeler extends BaseElement {
1976
2517
  }
1977
2518
  markDropLine(point, except) {
1978
2519
  this.dropLine = point ? this.lineAt(point, except)?.id : undefined;
2520
+ this.paintDropLine();
2521
+ }
2522
+ paintDropLine() {
1979
2523
  this.shadowRoot.querySelectorAll('[part=line]').forEach(path => { const active = path.dataset.lineId === this.dropLine; path.dataset.drop = String(active); path.setAttribute('marker-end', `url(#${active || path.dataset.selected === 'true' ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`); });
1980
2524
  }
1981
2525
  alignedPoint(id, x, y) {
@@ -2032,9 +2576,10 @@ export class ProcessModeler extends BaseElement {
2032
2576
  nextEdit(box, side, position) {
2033
2577
  const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
2034
2578
  const outgoing = this.projection.lines.filter(line => line.from === box.id);
2579
+ const placement = side ? { source: 'direction', side } : { source: 'next' };
2035
2580
  if ((!side || side === 'east') && shape !== 'gateway' && outgoing.length === 1)
2036
- return { type: 'insert', lineId: outgoing[0].id, from: box.id, to: outgoing[0].to };
2037
- 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 } : {}) };
2038
2583
  }
2039
2584
  nextChooserTitle(box, side) {
2040
2585
  const edit = this.nextEdit(box, side);
@@ -2060,7 +2605,7 @@ export class ProcessModeler extends BaseElement {
2060
2605
  const source = this.projection.boxes.find(box => box.id === line.from);
2061
2606
  const target = this.projection.boxes.find(box => box.id === line.to);
2062
2607
  const points = this.routedLines.get(line.id) ?? routeProcessLine(line, this.layoutValue, this.projection);
2063
- 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);
2064
2609
  }
2065
2610
  closeKeyboardChooser(returnFocus = false) {
2066
2611
  const chooser = this.shadowRoot?.querySelector('[part=keyboard-chooser]');
@@ -2088,8 +2633,8 @@ export class ProcessModeler extends BaseElement {
2088
2633
  const anchor = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
2089
2634
  .find(element => element.dataset.boxId === box.id);
2090
2635
  this.openAnchoredChooser(insert
2091
- ? { type: 'insert', lineId: outgoing[0].id, from: box.id, to: target.id }
2092
- : { 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}`
2093
2638
  : shape === 'gateway' ? `Add a path from ${box.title}` : `Add after ${box.title}`, anchor);
2094
2639
  }
2095
2640
  openAnchoredChooser(edit, title, anchor, returnFocus) {
@@ -2138,7 +2683,7 @@ export class ProcessModeler extends BaseElement {
2138
2683
  tooltip.style.top = `${point.y + 16}px`;
2139
2684
  }
2140
2685
  update() {
2141
- const selectionBefore = JSON.stringify({ ids: [...this.selectedIds], path: this.selectedPath });
2686
+ const selectionBefore = JSON.stringify({ items: this.selection, path: this.selectedPath });
2142
2687
  if (this.documentValue !== undefined) {
2143
2688
  this.projection = this.model.project(this.documentValue);
2144
2689
  validateProjection(this.projection);
@@ -2153,10 +2698,12 @@ export class ProcessModeler extends BaseElement {
2153
2698
  if (this.selectedLineId && !this.selectedLine)
2154
2699
  this.selectedLineId = null;
2155
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)));
2156
2703
  if (!this.selectedId)
2157
2704
  this.selectedId = this.selectedIds.values().next().value ?? null;
2158
2705
  const serialized = JSON.stringify({
2159
- 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 })),
2160
2707
  lines: this.projection.lines,
2161
2708
  });
2162
2709
  if (this.documentValue !== undefined && (serialized !== this.lastProjection || this.documentValue !== this.lastDocument)) {
@@ -2164,7 +2711,7 @@ export class ProcessModeler extends BaseElement {
2164
2711
  this.versionValue = typeof this.versionValue === "number" ? this.versionValue + 1 : `${this.versionValue}+1`;
2165
2712
  this.lastProjection = serialized;
2166
2713
  this.lastDocument = this.documentValue;
2167
- emit(this, "projection-changed", { projection: snapshotProcessProjection(this.projection), version: this.versionValue, checks: this.allChecks });
2714
+ this.notify("projection-changed", { projection: snapshotProcessProjection(this.projection), version: this.versionValue, checks: this.allChecks });
2168
2715
  }
2169
2716
  // Riptide or another host owns conversion. It supplies validate() and
2170
2717
  // receives this event only when that conversion can read the drawing.
@@ -2174,7 +2721,7 @@ export class ProcessModeler extends BaseElement {
2174
2721
  if (this.documentValue !== undefined && !this.allChecks.length && readableKey !== this.lastReadableProjection) {
2175
2722
  this.lastReadableProjection = readableKey;
2176
2723
  this.readableValue = snapshotProcessProjection(this.projection);
2177
- emit(this, "readable-projection-changed", { projection: snapshotProcessProjection(this.readableValue), version: this.versionValue });
2724
+ this.notify("readable-projection-changed", { projection: snapshotProcessProjection(this.readableValue), version: this.versionValue });
2178
2725
  }
2179
2726
  if (this.documentValue === undefined) {
2180
2727
  this.lastProjection = "";
@@ -2182,18 +2729,29 @@ export class ProcessModeler extends BaseElement {
2182
2729
  this.readableValue = null;
2183
2730
  this.lastDocument = undefined;
2184
2731
  }
2185
- const focused = this.shadowRoot.activeElement
2186
- ?.dataset.boxId;
2732
+ const active = this.shadowRoot.activeElement;
2733
+ const focused = active?.dataset.boxId, focusedNote = active?.dataset.noteId;
2187
2734
  this.renderWorld();
2188
2735
  this.renderPalette();
2189
2736
  this.renderChecks();
2190
2737
  this.renderSelection();
2191
2738
  this.paintViewport();
2192
2739
  this.updateToolbar();
2193
- if (selectionBefore !== JSON.stringify({ ids: [...this.selectedIds], path: this.selectedPath }))
2194
- emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
2195
- if (focused)
2196
- this.focusBox(focused);
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 } : {}) });
2742
+ if (focused) {
2743
+ this.restoringBoxFocus = true;
2744
+ try {
2745
+ this.focusBox(focused);
2746
+ }
2747
+ finally {
2748
+ this.restoringBoxFocus = false;
2749
+ }
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
+ }
2197
2755
  }
2198
2756
  get allChecks() { return this.computedChecks; }
2199
2757
  collectChecks() {
@@ -2222,6 +2780,13 @@ export class ProcessModeler extends BaseElement {
2222
2780
  for (const box of [...this.projection.boxes].sort((a, b) => Number(Boolean(b.frame)) - Number(Boolean(a.frame)))) {
2223
2781
  const element = document.createElement("div");
2224
2782
  element.tabIndex = 0;
2783
+ element.addEventListener('focus', () => {
2784
+ if (!this.restoringBoxFocus && !this.pointers.size && !this.portDrag && !this.endDrag && !this.segmentDrag && !this.marquee && !this.frameResize) {
2785
+ this.select(box.id);
2786
+ if (box.kind !== 'section')
2787
+ this.revealBox(box);
2788
+ }
2789
+ });
2225
2790
  element.setAttribute("role", "group");
2226
2791
  element.setAttribute("part", box.frame ? "frame" : "box");
2227
2792
  element.dataset.boxId = box.id;
@@ -2233,7 +2798,7 @@ export class ProcessModeler extends BaseElement {
2233
2798
  const problem = this.allChecks.find(check => check.boxId === box.id || (check.path && JSON.stringify(check.path) === JSON.stringify(box.path)));
2234
2799
  const incoming = this.projection.lines.filter(line => line.to === box.id).length;
2235
2800
  const outgoing = this.projection.lines.filter(line => line.from === box.id).length;
2236
- 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}` : ""]
2237
2802
  .filter(Boolean)
2238
2803
  .join(". "));
2239
2804
  element.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
@@ -2350,7 +2915,7 @@ export class ProcessModeler extends BaseElement {
2350
2915
  if (problem) {
2351
2916
  const message = document.createElement("span");
2352
2917
  message.setAttribute("part", "problem");
2353
- message.append(checkGlyph(), document.createTextNode(problem.title ?? problem.message));
2918
+ message.append(checkGlyph(), document.createTextNode(problem.title || problem.message));
2354
2919
  element.append(message);
2355
2920
  }
2356
2921
  element.addEventListener("keydown", event => {
@@ -2394,6 +2959,7 @@ export class ProcessModeler extends BaseElement {
2394
2959
  for (const side of ["north", "east", "south", "west"]) {
2395
2960
  const port = document.createElement("button");
2396
2961
  port.type = "button";
2962
+ port.tabIndex = -1;
2397
2963
  port.setAttribute("part", "port");
2398
2964
  port.dataset.side = side;
2399
2965
  port.dataset.owner = box.id;
@@ -2458,8 +3024,8 @@ export class ProcessModeler extends BaseElement {
2458
3024
  const path = svgElement("path");
2459
3025
  path.setAttribute("part", "line");
2460
3026
  path.dataset.lineId = line.id;
2461
- path.dataset.selected = String(this.selectedLineId === line.id);
2462
- path.setAttribute('marker-end', `url(#${this.selectedLineId === line.id ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`);
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'})`);
2463
3029
  path.setAttribute("vector-effect", "non-scaling-stroke");
2464
3030
  path.setAttribute("d", roundedProcessPath(points));
2465
3031
  if (line.dashed)
@@ -2558,6 +3124,7 @@ export class ProcessModeler extends BaseElement {
2558
3124
  lineId: line.id,
2559
3125
  from: line.from,
2560
3126
  to: line.to,
3127
+ ...(type === 'insert' ? { placement: { source: 'line', center: lineMidpoint(points) } } : {}),
2561
3128
  };
2562
3129
  if (type === "insert" && this.catalog.length) {
2563
3130
  this.openLineChooser(line, button);
@@ -2587,6 +3154,18 @@ export class ProcessModeler extends BaseElement {
2587
3154
  const element = document.createElement("div");
2588
3155
  element.setAttribute("part", "note");
2589
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
+ } });
2590
3169
  this.place(element, { ...note, width: 208, height: 80 });
2591
3170
  world.append(element);
2592
3171
  }
@@ -2599,6 +3178,149 @@ export class ProcessModeler extends BaseElement {
2599
3178
  height: `${position.height ?? 64}px`,
2600
3179
  });
2601
3180
  }
3181
+ activatePaletteKind(kind) {
3182
+ if (this.locked)
3183
+ return;
3184
+ if (this.narrowValue) {
3185
+ this.closeDrawer();
3186
+ this.shadowRoot.querySelector('[part=canvas]').focus({ preventScroll: true });
3187
+ }
3188
+ if (kind.placement) {
3189
+ this.addLayoutKind(kind);
3190
+ return;
3191
+ }
3192
+ const selected = this.selectedIds.size === 1 ? this.selected : null;
3193
+ const from = selected && !selected.frame && selected.kind !== 'section' && !['end', 'finish'].includes(selected.kind) ? selected.id : undefined;
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
+ }
3201
+ }
3202
+ paletteLineAt(point) {
3203
+ let nearest;
3204
+ let best = 20 / this.viewport.zoom;
3205
+ for (const line of this.projection.lines) {
3206
+ const points = this.routedLines.get(line.id) ?? [];
3207
+ for (let i = 1; i < points.length; i++) {
3208
+ const a = points[i - 1], b = points[i], dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy;
3209
+ const t = length ? Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / length)) : 0;
3210
+ const distance = Math.hypot(point.x - a.x - t * dx, point.y - a.y - t * dy);
3211
+ if (distance < best) {
3212
+ best = distance;
3213
+ nearest = line;
3214
+ }
3215
+ }
3216
+ }
3217
+ return nearest;
3218
+ }
3219
+ paletteFrameAt(point) {
3220
+ let nearest;
3221
+ let depth = -1;
3222
+ for (const box of this.projection.boxes) {
3223
+ if (!box.frame)
3224
+ continue;
3225
+ const p = this.layoutValue.boxes[box.id];
3226
+ if (!p)
3227
+ continue;
3228
+ const { width, height } = this.selectionDimensions(box);
3229
+ if (point.x < p.x || point.x > p.x + width || point.y < p.y || point.y > p.y + height)
3230
+ continue;
3231
+ let level = 0, parent = box.parentId;
3232
+ while (parent) {
3233
+ level++;
3234
+ parent = this.projection.boxes.find(box => box.id === parent)?.parentId;
3235
+ }
3236
+ if (level > depth) {
3237
+ depth = level;
3238
+ nearest = box;
3239
+ }
3240
+ }
3241
+ return nearest;
3242
+ }
3243
+ markPaletteDrop(point) {
3244
+ this.dropLine = point ? this.paletteLineAt(point)?.id : undefined;
3245
+ const frame = point && !this.dropLine ? this.paletteFrameAt(point) : undefined;
3246
+ this.shadowRoot.querySelectorAll('[part=frame]').forEach(element => { element.dataset.paletteDrop = String(element.dataset.boxId === frame?.id); });
3247
+ this.paintDropLine();
3248
+ }
3249
+ paletteCanvasPoint(event) {
3250
+ const rect = this.shadowRoot.querySelector('[part=canvas]').getBoundingClientRect();
3251
+ if (event.clientX <= rect.left || event.clientX >= rect.right || event.clientY <= rect.top || event.clientY >= rect.bottom)
3252
+ return;
3253
+ return this.canvasPoint(event);
3254
+ }
3255
+ movePalettePointer(event) {
3256
+ const drag = this.palettePointer;
3257
+ if (!drag || drag.id !== event.pointerId)
3258
+ return;
3259
+ if (this.locked) {
3260
+ this.cancelPalettePointer();
3261
+ return;
3262
+ }
3263
+ if (!drag.moved && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 5)
3264
+ return;
3265
+ if (!drag.moved) {
3266
+ drag.moved = true;
3267
+ if (this.narrowValue)
3268
+ this.closeDrawer();
3269
+ }
3270
+ let ghost = this.shadowRoot.querySelector('[part=palette-ghost]');
3271
+ if (!ghost) {
3272
+ ghost = document.createElement('div');
3273
+ ghost.setAttribute('part', 'palette-ghost');
3274
+ ghost.dataset.pointer = 'true';
3275
+ ghost.setAttribute('aria-hidden', 'true');
3276
+ if (drag.kind.icon)
3277
+ ghost.append(drag.kind.icon());
3278
+ ghost.append(document.createTextNode(drag.kind.label));
3279
+ this.shadowRoot.append(ghost);
3280
+ }
3281
+ ghost.style.left = `${event.clientX}px`;
3282
+ ghost.style.top = `${event.clientY}px`;
3283
+ this.markPaletteDrop(!drag.kind.placement && drag.kind.kind !== 'end' ? this.paletteCanvasPoint(event) : undefined);
3284
+ event.preventDefault();
3285
+ }
3286
+ cancelPalettePointer() {
3287
+ const drag = this.palettePointer;
3288
+ this.palettePointer = undefined;
3289
+ this.ownerDocument.removeEventListener('keydown', this.escapePalettePointer, true);
3290
+ if (!drag)
3291
+ return;
3292
+ this.suppressPaletteClick = true;
3293
+ this.shadowRoot?.querySelector('[part=palette-ghost]')?.remove();
3294
+ this.markPaletteDrop(undefined);
3295
+ try {
3296
+ this.releasePointerCapture(drag.id);
3297
+ }
3298
+ catch { /* Capture may already have been lost. */ }
3299
+ }
3300
+ finishPalettePointer(event) {
3301
+ const drag = this.palettePointer;
3302
+ if (!drag || drag.id !== event.pointerId)
3303
+ return;
3304
+ const point = this.paletteCanvasPoint(event);
3305
+ this.cancelPalettePointer();
3306
+ if (this.locked)
3307
+ return;
3308
+ if (!drag.moved) {
3309
+ this.activatePaletteKind(drag.kind);
3310
+ if (this.narrowValue)
3311
+ this.closeDrawer();
3312
+ return;
3313
+ }
3314
+ if (!point)
3315
+ return;
3316
+ if (drag.kind.placement) {
3317
+ this.addLayoutKind(drag.kind, point);
3318
+ return;
3319
+ }
3320
+ const line = drag.kind.kind === 'end' ? undefined : this.paletteLineAt(point);
3321
+ const frame = line ? undefined : this.paletteFrameAt(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 } } });
3323
+ }
2602
3324
  renderPalette() {
2603
3325
  const choices = this.shadowRoot.querySelector("[part=choices]");
2604
3326
  const query = this.shadowRoot.querySelector("[part=search]").value.toLowerCase();
@@ -2631,19 +3353,30 @@ export class ProcessModeler extends BaseElement {
2631
3353
  description.textContent = kind.description;
2632
3354
  button.append(icon, name, description);
2633
3355
  button.disabled = this.locked;
2634
- button.draggable = !this.locked;
2635
- button.addEventListener("click", () => {
2636
- if (kind.placement) {
2637
- this.addLayoutKind(kind);
3356
+ button.draggable = false;
3357
+ button.addEventListener('pointerdown', event => {
3358
+ if (this.locked || event.button !== 0 || event.isPrimary === false)
3359
+ return;
3360
+ this.cancelPalettePointer();
3361
+ this.suppressPaletteClick = false;
3362
+ this.palettePointer = { kind, id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
3363
+ this.ownerDocument.addEventListener('keydown', this.escapePalettePointer, true);
3364
+ try {
3365
+ this.setPointerCapture(event.pointerId);
3366
+ }
3367
+ catch { /* Detached synthetic pointers cannot be captured. */ }
3368
+ event.preventDefault();
3369
+ event.stopPropagation();
3370
+ });
3371
+ button.addEventListener('click', event => {
3372
+ if (event.detail > 0 && this.suppressPaletteClick) {
3373
+ this.suppressPaletteClick = false;
2638
3374
  return;
2639
3375
  }
2640
- const from = this.selectedId;
2641
- const outgoing = from ? this.projection.lines.filter(line => line.from === from) : [];
2642
- const line = outgoing.length === 1 ? outgoing[0] : undefined;
2643
- this.requestEdit(line ? { type: "insert", kind, lineId: line.id, from: line.from, to: line.to } : { type: "add", kind, ...(from ? { from } : {}) });
3376
+ this.activatePaletteKind(kind);
2644
3377
  });
2645
- button.addEventListener("dragstart", event => { this.paletteDragKind = kind; event.dataTransfer?.setData("application/boe-process-kind", kind.kind); });
2646
- button.addEventListener("dragend", () => { this.paletteDragKind = null; this.shadowRoot?.querySelector('[part=palette-ghost]')?.remove(); this.markDropLine(undefined); });
3378
+ button.addEventListener('dragstart', event => { this.paletteDragKind = kind; event.dataTransfer?.setData('application/boe-process-kind', kind.kind); });
3379
+ button.addEventListener('dragend', () => { this.paletteDragKind = null; this.shadowRoot?.querySelector('[part=palette-ghost]')?.remove(); this.markDropLine(undefined); });
2647
3380
  choices.append(button);
2648
3381
  }
2649
3382
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
@@ -2685,7 +3418,7 @@ export class ProcessModeler extends BaseElement {
2685
3418
  type: line ? "insert" : "add",
2686
3419
  kind,
2687
3420
  lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id,
2688
- position,
3421
+ position, placement: { source: 'point', center: { ...position } },
2689
3422
  });
2690
3423
  this.markDropLine(undefined);
2691
3424
  };
@@ -2937,19 +3670,48 @@ export class ProcessModeler extends BaseElement {
2937
3670
  return row;
2938
3671
  }
2939
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); });
2940
3674
  if (!this.isRendered)
2941
3675
  return;
2942
3676
  const focusedControl = this.shadowRoot.activeElement;
2943
- const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable;
3677
+ const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable ?? focusedControl?.dataset.localVariable;
2944
3678
  const variableKey = focusedControl?.dataset.variableKey;
2945
- const focusedType = focusedControl?.dataset.field !== undefined ? 'field' : focusedControl?.dataset.variable !== undefined ? 'variable' : null;
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;
2946
3681
  const caret = focusedControl && 'selectionStart' in focusedControl ? focusedControl.selectionStart : null;
2947
3682
  const restoreControlFocus = () => {
2948
3683
  if (focusedKey === undefined || !focusedType)
2949
3684
  return;
2950
3685
  const controls = Array.from(this.shadowRoot.querySelectorAll('input,textarea,select,button'));
2951
- 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')
2952
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
+ }
2953
3715
  if (!control)
2954
3716
  return;
2955
3717
  control.focus();
@@ -2957,7 +3719,7 @@ export class ProcessModeler extends BaseElement {
2957
3719
  control.setSelectionRange(caret, caret);
2958
3720
  };
2959
3721
  this.shadowRoot.querySelectorAll('[part=minimap] [data-map-box]').forEach(rect => rect.dataset.selected = String(this.selectedIds.has(rect.dataset.mapBox)));
2960
- this.shadowRoot.querySelectorAll('[part=line]').forEach(line => { const selected = line.dataset.lineId === this.selectedLineId; line.dataset.selected = String(selected); line.setAttribute('marker-end', `url(#${selected ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`); });
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'})`); });
2961
3723
  this.shadowRoot.querySelectorAll("[data-box-id]").forEach((element) => {
2962
3724
  element.setAttribute("aria-current", String(this.selectedIds.has(element.dataset.boxId)));
2963
3725
  const box = this.projection.boxes.find((box) => box.id === element.dataset.boxId);
@@ -2967,9 +3729,9 @@ export class ProcessModeler extends BaseElement {
2967
3729
  });
2968
3730
  const editor = this.shadowRoot.querySelector("[part=editor]");
2969
3731
  const selected = this.selected;
2970
- 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] ?? [] : [])}`;
2971
3733
  this.shadowRoot.querySelector("[part=palette]").hidden = false;
2972
- this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selectedIds.size === 0 && !this.selectedLineId;
3734
+ this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selection.length === 0;
2973
3735
  this.renderSelectionToolbar();
2974
3736
  this.renderPane();
2975
3737
  this.positionSelectionToolbar();
@@ -2978,6 +3740,12 @@ export class ProcessModeler extends BaseElement {
2978
3740
  this.inspectedControls === controlsKey &&
2979
3741
  editor.querySelector(`[part=inspector-heading]`)?.tagName === `H${this.headingLevel}`) {
2980
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
+ }
2981
3749
  restoreControlFocus();
2982
3750
  return;
2983
3751
  }
@@ -3031,6 +3799,8 @@ export class ProcessModeler extends BaseElement {
3031
3799
  }
3032
3800
  for (const field of this.fieldsValue[selected.id] ?? [])
3033
3801
  editor.append(this.renderField(selected, field));
3802
+ if (selected.localVariables !== undefined)
3803
+ editor.append(this.renderLocalVariables(selected));
3034
3804
  this.cleanupInspector =
3035
3805
  this.renderer?.(selected.node, editor) || undefined;
3036
3806
  const outgoing = this.projection.lines.filter(line => line.from === selected.id);
@@ -3106,7 +3876,7 @@ export class ProcessModeler extends BaseElement {
3106
3876
  duplicate.type = 'button';
3107
3877
  duplicate.textContent = 'Duplicate';
3108
3878
  duplicate.disabled = this.locked;
3109
- duplicate.onclick = () => this.requestEdit({ type: 'duplicate', sourceId: selected.id, position: { x: this.layoutValue.boxes[selected.id].x + 32, y: this.layoutValue.boxes[selected.id].y + 32 } });
3879
+ duplicate.onclick = () => this.duplicateBoxes([selected.id], 48);
3110
3880
  const remove = document.createElement('button');
3111
3881
  remove.type = 'button';
3112
3882
  remove.textContent = 'Delete';
@@ -3117,14 +3887,118 @@ export class ProcessModeler extends BaseElement {
3117
3887
  }
3118
3888
  restoreControlFocus();
3119
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
+ }
3120
3994
  renderSelectionToolbar() {
3121
3995
  const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
3122
- const key = `${[...this.selectedIds].join(',')}|${this.selected?.kind}|${this.selected?.shape}|${this.selectedLineId}|${Boolean(this.selectedLineId && this.layoutValue.lines?.[this.selectedLineId])}|${this.locked}|${this.narrowValue}|${this.projection.lines.filter(line => line.from === this.selectedId).map(line => `${line.id}:${line.label}:${line.dashed}`).join(',')}`;
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(',')}`;
3123
3997
  if (key === this.selectionToolbarKey)
3124
3998
  return;
3125
3999
  this.selectionToolbarKey = key;
3126
4000
  toolbar.replaceChildren();
3127
- if (!this.selectedIds.size && !this.selectedLineId)
4001
+ if (!this.selection.length)
3128
4002
  return;
3129
4003
  const add = (label, command, action, withPlus = false) => {
3130
4004
  const button = document.createElement('button');
@@ -3145,10 +4019,15 @@ export class ProcessModeler extends BaseElement {
3145
4019
  button.append(text);
3146
4020
  toolbar.append(button);
3147
4021
  };
3148
- if (this.selectedLine) {
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) {
3149
4028
  const line = this.selectedLine;
3150
4029
  add('Insert a step', 'insert', () => this.openLineChooser(line, toolbar.querySelector('[data-selection-command=insert]') ?? undefined), true);
3151
- if (this.layoutValue.lines?.[line.id])
4030
+ if (this.layoutValue.lines?.[line.id] || line.fromSide || line.toSide || line.points?.length)
3152
4031
  add('Reset line', 'reset-line', () => this.resetLine(line.id));
3153
4032
  if (!this.disableConnections)
3154
4033
  add('Delete', 'delete-line', () => this.requestEdit({ type: 'disconnect', lineId: line.id }));
@@ -3161,9 +4040,9 @@ export class ProcessModeler extends BaseElement {
3161
4040
  if (!['end', 'finish', 'section', 'note'].includes(box.kind))
3162
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);
3163
4042
  if (failurePath)
3164
- 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);
3165
4044
  if (!['start', 'timer', 'section'].includes(box.kind))
3166
- add('Duplicate', 'duplicate', () => { const position = this.layoutValue.boxes[box.id]; this.requestEdit({ type: 'duplicate', sourceId: box.id, position: { x: position.x + 32, y: position.y + 32 } }); });
4045
+ add('Duplicate', 'duplicate', () => this.duplicateBoxes([box.id], 48));
3167
4046
  if (!['start', 'timer'].includes(box.kind))
3168
4047
  add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
3169
4048
  }
@@ -3197,6 +4076,9 @@ export class ProcessModeler extends BaseElement {
3197
4076
  paintViewport() {
3198
4077
  const world = this.shadowRoot.querySelector("[part=world]");
3199
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`);
3200
4082
  world.style.transform =
3201
4083
  `translate(${this.viewport.x}px,${this.viewport.y}px) scale(${this.viewport.zoom})`;
3202
4084
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
@@ -3244,17 +4126,27 @@ export class ProcessModeler extends BaseElement {
3244
4126
  minimap.append(viewport);
3245
4127
  }
3246
4128
  positionSelectionToolbar() {
3247
- if (!this.isRendered || (!this.selectedIds.size && !this.selectedLineId))
4129
+ if (!this.isRendered || !this.selection.length)
3248
4130
  return;
3249
4131
  const canvas = this.shadowRoot.querySelector('[part=canvas]');
3250
4132
  const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
3251
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 })));
3252
4135
  if (!positions.length && this.selectedLine) {
3253
4136
  const point = lineMidpoint(this.routedLines.get(this.selectedLine.id) ?? []);
3254
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`;
3255
4138
  toolbar.style.top = `${Math.max(8, point.y * this.viewport.zoom + this.viewport.y - 52)}px`;
3256
4139
  return;
3257
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
+ }
3258
4150
  if (!positions.length)
3259
4151
  return;
3260
4152
  const left = Math.min(...positions.map(position => position.x * this.viewport.zoom + this.viewport.x));
@@ -3266,9 +4158,11 @@ export class ProcessModeler extends BaseElement {
3266
4158
  focusBox(id) {
3267
4159
  Array.from(this.shadowRoot.querySelectorAll("[data-box-id]"))
3268
4160
  .find((box) => box.dataset.boxId === id)
3269
- ?.focus();
4161
+ ?.focus({ preventScroll: true });
3270
4162
  }
3271
4163
  setStatus(message, urgent = false) {
4164
+ if (!this.isRendered)
4165
+ return;
3272
4166
  this.shadowRoot.querySelector(urgent ? "[part=status]" : "[part=urgent-status]").textContent = "";
3273
4167
  this.shadowRoot.querySelector(urgent ? "[part=urgent-status]" : "[part=status]").textContent = message;
3274
4168
  }