@unofficialbox/box-open-elements 0.32.0 → 0.33.0

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