flexdesk 0.16.1 → 0.17.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.
Files changed (252) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2217 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3146 -164
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3146 -164
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +657 -234
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3252 -2124
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -347
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +7 -7
  144. package/src/tiles/scope_picker.js +0 -0
  145. package/src/tiles/tile_base.js +1 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +2 -0
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +0 -0
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  241. package/dist/chunk-FBGP5KN5.js.map +0 -7
  242. package/dist/chunk-FL5KFNQH.js.map +0 -7
  243. package/dist/chunk-NLTOY57M.js.map +0 -7
  244. package/dist/chunk-NNFTETK3.js +0 -633
  245. package/dist/chunk-NNFTETK3.js.map +0 -7
  246. package/dist/chunk-O5OHMWBB.js +0 -284
  247. package/dist/chunk-O5OHMWBB.js.map +0 -7
  248. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  249. package/dist/chunk-SN4LSOAR.js.map +0 -7
  250. package/dist/chunk-W6FBJLPW.js +0 -367
  251. package/dist/chunk-W6FBJLPW.js.map +0 -7
  252. package/src/ui/controllers/panel_state_machine.js +0 -634
package/dist/flow.js CHANGED
@@ -1,17 +1,30 @@
1
1
  import {
2
2
  modalHost
3
- } from "./chunk-NNFTETK3.js";
3
+ } from "./chunk-D3COLZZ2.js";
4
4
  import {
5
5
  DataTable
6
- } from "./chunk-FBGP5KN5.js";
7
- import "./chunk-6JSOVNID.js";
8
- import "./chunk-FL5KFNQH.js";
6
+ } from "./chunk-Q36EPT2R.js";
9
7
  import {
8
+ choiceList,
9
+ selectBox,
10
+ shownValue,
11
+ toggleChips
12
+ } from "./chunk-CVMCZZX2.js";
13
+ import "./chunk-HNU6MM2B.js";
14
+ import "./chunk-WVFGV5FT.js";
15
+ import {
16
+ CONNECTOR_STRINGS,
10
17
  CanvasAdapter,
18
+ ConnectorRouter,
19
+ NodePlatform,
20
+ OBSTACLE_ROUTING_NODE_LIMIT,
11
21
  arrange,
22
+ crowsFoot,
23
+ footAnchor,
24
+ layeredLayout,
12
25
  returnEdge,
13
26
  routeEdge
14
- } from "./chunk-SN4LSOAR.js";
27
+ } from "./chunk-GOQLR5WR.js";
15
28
  import {
16
29
  __export
17
30
  } from "./chunk-JYWURG5T.js";
@@ -56,7 +69,9 @@ var FLOW_STRINGS = Object.freeze({
56
69
  value: "Value",
57
70
  formula: "Formula",
58
71
  textOrValue: "Text, or insert a value",
59
- noColumns: "There are no columns to choose from yet: connect a step before this one.",
72
+ noColumns: "Columns not known yet: type the name.",
73
+ columnName: "Column name",
74
+ columnNames: "Column name, then Enter",
60
75
  noColumnsChosen: "None chosen",
61
76
  fieldFinding: "Fix this before publishing",
62
77
  // ── reference chips and Insert a value ─────────────────────────────
@@ -274,8 +289,11 @@ var UI_HINTS = Object.freeze([
274
289
  "x-ui-when",
275
290
  "x-ui-placeholder",
276
291
  "x-ui-enum-labels",
292
+ "x-ui-enum-descriptions",
277
293
  "x-ui-multiline",
278
- "x-ui-fold"
294
+ "x-ui-fold",
295
+ "x-ui-add-label",
296
+ "x-ui-formula-references"
279
297
  ]);
280
298
  var isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
281
299
  function pyRepr(value) {
@@ -381,6 +399,35 @@ function defaultIdBase(type) {
381
399
  function cleanStem(stem) {
382
400
  return String(stem ?? "").replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 40) || "step";
383
401
  }
402
+ var positive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
403
+ function readSize(raw) {
404
+ if (raw === void 0 || raw === null) return { size: null, problem: null };
405
+ const refused = { size: null, problem: "size must be {w, h} in positive pixels; it is not used." };
406
+ if (!isPlainObject2(raw)) return refused;
407
+ const w = raw.w === void 0 ? null : positive(raw.w);
408
+ const h = raw.h === void 0 ? null : positive(raw.h);
409
+ if (raw.w !== void 0 && w === null || raw.h !== void 0 && h === null || w === null && h === null) {
410
+ return refused;
411
+ }
412
+ return { size: Object.freeze({ w, h }), problem: null };
413
+ }
414
+ function readInline(raw, schema, id, problems) {
415
+ if (raw === void 0 || raw === null) return Object.freeze([]);
416
+ if (!Array.isArray(raw)) {
417
+ problems.push({ type_id: id, message: `"${id}"'s inline must be a list of setting names; it is not used.` });
418
+ return Object.freeze([]);
419
+ }
420
+ const known = new Set(fieldsFromSchema(schema).map((f) => f.key));
421
+ const keys = [];
422
+ for (const key of raw) {
423
+ if (typeof key === "string" && known.has(key)) {
424
+ if (!keys.includes(key)) keys.push(key);
425
+ } else {
426
+ problems.push({ type_id: id, message: `"${id}"'s inline names "${String(key)}", which its settings do not have; it is not drawn.` });
427
+ }
428
+ }
429
+ return Object.freeze(keys);
430
+ }
384
431
  function normalisePort(p) {
385
432
  return {
386
433
  ...p,
@@ -396,6 +443,10 @@ function createStepCatalogue(types, { categories = [], idBase = defaultIdBase }
396
443
  const problems = [];
397
444
  const byId = /* @__PURE__ */ new Map();
398
445
  const order = [];
446
+ const sizes = /* @__PURE__ */ new Map();
447
+ const accents = /* @__PURE__ */ new Map();
448
+ const inlines = /* @__PURE__ */ new Map();
449
+ const ranks = /* @__PURE__ */ new Map();
399
450
  for (const raw of Array.isArray(types) ? types : []) {
400
451
  if (!isPlainObject2(raw)) {
401
452
  problems.push({ type_id: null, message: "A step type must be an object." });
@@ -434,6 +485,24 @@ function createStepCatalogue(types, { categories = [], idBase = defaultIdBase }
434
485
  });
435
486
  byId.set(id, type);
436
487
  order.push(type);
488
+ const { size, problem } = readSize(raw.size);
489
+ if (problem) problems.push({ type_id: id, message: `"${id}"'s ${problem}` });
490
+ if (size) sizes.set(id, size);
491
+ if (raw.accent !== void 0 && raw.accent !== null) {
492
+ if (TONES.includes(raw.accent)) accents.set(id, raw.accent);
493
+ else problems.push({ type_id: id, message: `"${id}"'s accent "${raw.accent}" is not a tone; it is not used.` });
494
+ }
495
+ const inline = readInline(raw.inline, schema, id, problems);
496
+ if (inline.length) inlines.set(id, inline);
497
+ if (raw.rank !== void 0 && raw.rank !== null) {
498
+ if (raw.rank === "source" || raw.rank === "sink") ranks.set(id, raw.rank);
499
+ else problems.push({ type_id: id, message: `"${id}"'s rank "${String(raw.rank)}" is not 'source' or 'sink'; it is not used.` });
500
+ }
501
+ for (const hook of ["render", "update"]) {
502
+ if (raw[hook] !== void 0 && typeof raw[hook] !== "function") {
503
+ problems.push({ type_id: id, message: `"${id}"'s ${hook} is not a function; it is not used.` });
504
+ }
505
+ }
437
506
  }
438
507
  const cats = (Array.isArray(categories) ? categories : []).filter(isPlainObject2).map((c) => Object.freeze({
439
508
  id: String(c.id),
@@ -452,11 +521,11 @@ function createStepCatalogue(types, { categories = [], idBase = defaultIdBase }
452
521
  extra.push(Object.freeze({ id: "", label: "", tone: "grey", layout: "list" }));
453
522
  }
454
523
  const allCats = Object.freeze([...cats, ...extra]);
455
- const rank = (t) => {
524
+ const categoryIndex = (t) => {
456
525
  const id = t.category === null ? "" : t.category;
457
526
  return allCats.findIndex((c) => c.id === id);
458
527
  };
459
- const sorted2 = [...order].sort((a, b) => rank(a) - rank(b) || String(a.label).localeCompare(String(b.label)));
528
+ const sorted2 = [...order].sort((a, b) => categoryIndex(a) - categoryIndex(b) || String(a.label).localeCompare(String(b.label)));
460
529
  const portsOf2 = (typeId, direction, flow) => {
461
530
  const t = byId.get(typeId);
462
531
  if (!t) return [];
@@ -478,6 +547,20 @@ function createStepCatalogue(types, { categories = [], idBase = defaultIdBase }
478
547
  const t = byId.get(typeId);
479
548
  return t && allCats.find((c) => c.id === (t.category ?? ""))?.tone || "grey";
480
549
  },
550
+ /** The type's own box, `{w, h}` (either null), or null when it has none. */
551
+ size: (typeId) => sizes.get(typeId) ?? null,
552
+ /** The type's accent tone, or null when it has none. */
553
+ accent: (typeId) => accents.get(typeId) ?? null,
554
+ /** The setting names the type draws on its node (`inline`), in order; `[]` when none. */
555
+ inline: (typeId) => inlines.get(typeId) ?? Object.freeze([]),
556
+ /** The type's layered-layout hint, 'source' or 'sink', or null when it has none. */
557
+ rank: (typeId) => ranks.get(typeId) ?? null,
558
+ /** The type's `{render, update}` (`update` may be null), or null when it has no `render`. */
559
+ body: (typeId) => {
560
+ const t = byId.get(typeId);
561
+ const render = typeof t?.render === "function" ? t.render : null;
562
+ return render ? { render, update: typeof t.update === "function" ? t.update : null } : null;
563
+ },
481
564
  inputs: (typeId, { flow = false } = {}) => portsOf2(typeId, "input", flow),
482
565
  outputs: (typeId, { flow = false } = {}) => portsOf2(typeId, "output", flow),
483
566
  port: (typeId, name, direction) => byId.get(typeId)?.ports.find((p) => p.name === name && (!direction || p.direction === direction)) ?? null,
@@ -1044,7 +1127,6 @@ function createChipInput({
1044
1127
  }
1045
1128
 
1046
1129
  // src/flow/kit/widgets.js
1047
- var shownValue = (v) => v === void 0 ? "" : typeof v === "string" ? v : JSON.stringify(v);
1048
1130
  function typedValue(text, stored) {
1049
1131
  if (text === shownValue(stored) && stored !== void 0) return stored;
1050
1132
  if (stored === void 0 || typeof stored === "string") return text;
@@ -1063,30 +1145,6 @@ function textBox(value, { type = "text", placeholder = "", label = "" } = {}) {
1063
1145
  if (label) input.setAttribute("aria-label", label);
1064
1146
  return input;
1065
1147
  }
1066
- function selectBox(options, value, { empty = null, label = "", notFound = (v) => `${v} (not found)` } = {}) {
1067
- const box = el("select", "twm-flow-input twm-flow-input--select");
1068
- if (label) box.setAttribute("aria-label", label);
1069
- const values = [];
1070
- if (empty !== null) {
1071
- box.appendChild(new Option(empty, ""));
1072
- values.push(void 0);
1073
- }
1074
- options.forEach((opt) => {
1075
- const o = new Option(opt.label, String(values.length));
1076
- if (opt.disabled) o.disabled = true;
1077
- box.appendChild(o);
1078
- values.push(opt.value);
1079
- });
1080
- const at = values.findIndex((v, i) => i >= (empty !== null ? 1 : 0) && v === value);
1081
- if (value !== void 0 && value !== null && value !== "" && at < 0) {
1082
- box.appendChild(new Option(notFound(shownValue(value)), String(values.length)));
1083
- values.push(value);
1084
- box.value = String(values.length - 1);
1085
- } else {
1086
- box.value = at >= 0 ? String(at) : empty !== null ? "" : "0";
1087
- }
1088
- return { box, valueOf: () => box.value === "" ? void 0 : values[Number(box.value)] };
1089
- }
1090
1148
  function fitToText(area, maxLines = 12) {
1091
1149
  let width = -1;
1092
1150
  const fit = () => {
@@ -1376,50 +1434,6 @@ function stringListWidget(spec, value, ctx) {
1376
1434
  });
1377
1435
  return { el: rows.el, destroy: rows.destroy };
1378
1436
  }
1379
- function toggleChips({ options, value, label, readOnly, onChange, notFound }) {
1380
- const root = el("div", "twm-flow-togglechips");
1381
- root.setAttribute("role", "group");
1382
- if (label) root.setAttribute("aria-label", label);
1383
- const known = options.map((o) => o.value);
1384
- const chosen = new Set((Array.isArray(value) ? value : []).filter((v) => known.includes(v)));
1385
- const strays = (Array.isArray(value) ? value : []).filter((v) => !known.includes(v));
1386
- const emit = () => {
1387
- const out = [...known.filter((v) => chosen.has(v)), ...strays];
1388
- onChange(out.length ? out : void 0);
1389
- };
1390
- const chipFor = (text, pressed, toggle, stray = false) => {
1391
- const b = el("button", `twm-flow-togglechip${stray ? " twm-flow-togglechip--missing" : ""}`, text);
1392
- b.type = "button";
1393
- b.setAttribute("aria-pressed", pressed ? "true" : "false");
1394
- b.disabled = Boolean(readOnly);
1395
- b.addEventListener("click", () => {
1396
- const on = toggle();
1397
- b.setAttribute("aria-pressed", on ? "true" : "false");
1398
- emit();
1399
- });
1400
- return b;
1401
- };
1402
- for (const o of options) {
1403
- root.appendChild(chipFor(o.label, chosen.has(o.value), () => {
1404
- if (chosen.has(o.value)) chosen.delete(o.value);
1405
- else chosen.add(o.value);
1406
- return chosen.has(o.value);
1407
- }));
1408
- }
1409
- for (const v of [...strays]) {
1410
- const b = chipFor(notFound(shownValue(v)), true, () => {
1411
- const i = strays.indexOf(v);
1412
- if (i >= 0) {
1413
- strays.splice(i, 1);
1414
- return false;
1415
- }
1416
- strays.push(v);
1417
- return true;
1418
- }, true);
1419
- root.appendChild(b);
1420
- }
1421
- return root;
1422
- }
1423
1437
  function enumChipsWidget(spec, value, ctx) {
1424
1438
  const S = ctx.strings;
1425
1439
  const itemSpec = spec.items && typeof spec.items === "object" ? spec.items : {};
@@ -1433,48 +1447,41 @@ function enumChipsWidget(spec, value, ctx) {
1433
1447
  readOnly: ctx.readOnly,
1434
1448
  onChange: (v) => ctx.set(v),
1435
1449
  notFound: (v) => say(S, "notFound", v)
1436
- })
1450
+ }).el
1437
1451
  };
1438
1452
  }
1439
1453
  function choiceCardsWidget(spec, value, ctx) {
1440
1454
  const values = Array.isArray(spec.enum) ? spec.enum : [];
1441
1455
  const descriptions = spec["x-ui-enum-descriptions"] && typeof spec["x-ui-enum-descriptions"] === "object" ? spec["x-ui-enum-descriptions"] : {};
1442
- const root = el("div", "twm-flow-choices");
1443
- root.setAttribute("role", "radiogroup");
1444
- if (ctx.label) root.setAttribute("aria-label", ctx.label);
1445
- const name = uid("choice");
1446
- const current = value !== void 0 ? value : spec.default;
1447
- const radios = [];
1448
- for (const v of values) {
1449
- const row = el("label", "twm-flow-choice");
1450
- const radio = el("input", "twm-flow-choice__radio");
1451
- radio.type = "radio";
1452
- radio.name = name;
1453
- radio.checked = v === current;
1454
- radio.disabled = Boolean(ctx.readOnly);
1455
- radio.addEventListener("change", () => {
1456
- if (radio.checked) ctx.set(v);
1457
- });
1458
- radios.push(radio);
1459
- const words3 = el("span", "twm-flow-choice__words");
1460
- words3.appendChild(el("span", "twm-flow-choice__label", enumLabel(spec, v)));
1461
- if (descriptions[v] !== void 0) words3.appendChild(el("span", "twm-flow-choice__sub", descriptions[v]));
1462
- row.append(radio, words3);
1463
- root.appendChild(row);
1464
- }
1465
- if (current !== void 0 && !values.includes(current)) {
1466
- root.appendChild(el("p", "twm-flow-field__note", say(ctx.strings, "notFound", shownValue(current))));
1467
- }
1468
- return { el: root, focus: () => (radios.find((r) => r.checked) || radios[0])?.focus({ preventScroll: true }) };
1456
+ const list = choiceList({
1457
+ options: values.map((v) => ({ value: v, label: enumLabel(spec, v), description: descriptions[v] })),
1458
+ value: value !== void 0 ? value : spec.default,
1459
+ name: uid("choice"),
1460
+ label: ctx.label,
1461
+ readOnly: ctx.readOnly,
1462
+ onChange: (v) => ctx.set(v),
1463
+ notFound: (v) => say(ctx.strings, "notFound", v)
1464
+ });
1465
+ return { el: list.el, focus: list.focus };
1469
1466
  }
1470
1467
  var columnWords = (c) => c.type ? `${c.name} \xB7 ${c.type}` : String(c.name);
1468
+ function columnsNotKnownNote(S) {
1469
+ return el("p", "twm-flow-field__note", say(S, "noColumns"));
1470
+ }
1471
1471
  function upstreamColumnWidget(spec, value, ctx) {
1472
1472
  const S = ctx.strings;
1473
1473
  const columns = ctx.columns?.();
1474
1474
  if (!Array.isArray(columns)) {
1475
- const note = el("p", "twm-flow-field__note", say(S, "noColumns"));
1476
- if (value !== void 0) note.appendChild(el("span", "twm-flow-field__kept", ` ${shownValue(value)}`));
1477
- return { el: note };
1475
+ const root = el("div", "twm-flow-field__typed");
1476
+ const box2 = textBox(shownValue(value), {
1477
+ label: ctx.label,
1478
+ placeholder: spec["x-ui-placeholder"] ?? say(S, "columnName")
1479
+ });
1480
+ box2.spellcheck = false;
1481
+ box2.readOnly = Boolean(ctx.readOnly);
1482
+ box2.addEventListener("input", () => ctx.set(clean(typedValue(box2.value, value))));
1483
+ root.append(box2, columnsNotKnownNote(S));
1484
+ return { el: root, focus: () => box2.focus({ preventScroll: true }) };
1478
1485
  }
1479
1486
  const { box, valueOf } = selectBox(
1480
1487
  columns.map((c) => ({ value: String(c.name), label: columnWords(c) })),
@@ -1489,15 +1496,68 @@ function upstreamColumnWidget(spec, value, ctx) {
1489
1496
  box.addEventListener("change", () => ctx.set(clean(valueOf())));
1490
1497
  return { el: box };
1491
1498
  }
1499
+ function typedChips({ value, label, placeholder, readOnly, onChange, strings }) {
1500
+ const root = el("div", "twm-flow-togglechips twm-flow-typedchips");
1501
+ root.setAttribute("role", "group");
1502
+ if (label) root.setAttribute("aria-label", label);
1503
+ const list = Array.isArray(value) ? [...value] : [];
1504
+ const input = textBox("", { label, placeholder });
1505
+ input.classList.add("twm-flow-typedchips__input");
1506
+ input.spellcheck = false;
1507
+ input.readOnly = Boolean(readOnly);
1508
+ const emit = () => onChange(list.length ? [...list] : void 0);
1509
+ const chipFor = (v) => {
1510
+ const text = shownValue(v);
1511
+ const b = el("button", "twm-flow-togglechip", text);
1512
+ b.type = "button";
1513
+ b.setAttribute("aria-pressed", "true");
1514
+ b.setAttribute("aria-label", say(strings, "removeNamed", text));
1515
+ b.title = say(strings, "removeNamed", text);
1516
+ b.disabled = Boolean(readOnly);
1517
+ b.addEventListener("click", () => {
1518
+ const i = list.indexOf(v);
1519
+ if (i >= 0) list.splice(i, 1);
1520
+ b.remove();
1521
+ emit();
1522
+ input.focus({ preventScroll: true });
1523
+ });
1524
+ return b;
1525
+ };
1526
+ const take = () => {
1527
+ const name = input.value.replace(/,+$/, "").trim();
1528
+ input.value = "";
1529
+ if (!name || list.includes(name)) return;
1530
+ list.push(name);
1531
+ root.insertBefore(chipFor(name), input);
1532
+ emit();
1533
+ };
1534
+ for (const v of list) root.appendChild(chipFor(v));
1535
+ root.appendChild(input);
1536
+ input.addEventListener("keydown", (ev) => {
1537
+ if (ev.isComposing || ev.key !== "Enter" && ev.key !== ",") return;
1538
+ ev.preventDefault();
1539
+ take();
1540
+ });
1541
+ input.addEventListener("blur", () => {
1542
+ if (input.value.trim()) take();
1543
+ });
1544
+ return { el: root, input };
1545
+ }
1492
1546
  function upstreamColumnsWidget(spec, value, ctx) {
1493
1547
  const S = ctx.strings;
1494
1548
  const columns = ctx.columns?.();
1495
1549
  if (!Array.isArray(columns)) {
1496
- const note = el("p", "twm-flow-field__note", say(S, "noColumns"));
1497
- if (Array.isArray(value) && value.length) {
1498
- note.appendChild(el("span", "twm-flow-field__kept", ` ${value.map(shownValue).join(", ")}`));
1499
- }
1500
- return { el: note };
1550
+ const root = el("div", "twm-flow-field__typed");
1551
+ const chips = typedChips({
1552
+ value,
1553
+ label: ctx.label,
1554
+ readOnly: ctx.readOnly,
1555
+ strings: S,
1556
+ placeholder: spec["x-ui-placeholder"] ?? say(S, "columnNames"),
1557
+ onChange: (v) => ctx.set(v)
1558
+ });
1559
+ root.append(chips.el, columnsNotKnownNote(S));
1560
+ return { el: root, focus: () => chips.input.focus({ preventScroll: true }) };
1501
1561
  }
1502
1562
  return {
1503
1563
  el: toggleChips({
@@ -1507,7 +1567,7 @@ function upstreamColumnsWidget(spec, value, ctx) {
1507
1567
  readOnly: ctx.readOnly,
1508
1568
  onChange: (v) => ctx.set(v),
1509
1569
  notFound: (v) => say(S, "notFound", v)
1510
- })
1570
+ }).el
1511
1571
  };
1512
1572
  }
1513
1573
  var GENERIC_WIDGETS = Object.freeze({
@@ -2137,6 +2197,18 @@ function bindFlowKeys(root, handlers = {}) {
2137
2197
  run("paste", ev);
2138
2198
  return;
2139
2199
  }
2200
+ if (lower === "a") {
2201
+ run("selectAll", ev);
2202
+ return;
2203
+ }
2204
+ if (lower === "g") {
2205
+ run("group", ev);
2206
+ return;
2207
+ }
2208
+ return;
2209
+ }
2210
+ if (mod && lower === "g") {
2211
+ run("ungroup", ev);
2140
2212
  return;
2141
2213
  }
2142
2214
  if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
@@ -3041,21 +3113,70 @@ var LOGIC_GRAPH_STRINGS = Object.freeze({
3041
3113
  connectNoOutput: (port) => `This step has no output called ${port}.`,
3042
3114
  connectNoInput: (port) => `That step has no input called ${port}.`,
3043
3115
  connectNotFlow: (how) => how ? `Only flow ports connect; a data port is read as ${how}.` : "Only flow ports connect; a data port is read, not connected.",
3116
+ connectKinds: "A flow port connects to a flow port, and a data port to a data port.",
3044
3117
  connectTwice: "These two are already connected.",
3045
3118
  connectOneLine: (port) => `The ${port} port takes one connection; use a step that runs several branches.`
3046
3119
  });
3047
3120
  function emptyGraph() {
3048
3121
  return { nodes: [], connections: [] };
3049
3122
  }
3123
+ var round = (v) => Math.round(Number(v) || 0);
3124
+ function groupsOf(list, nodeIds) {
3125
+ const out = [];
3126
+ const ids = /* @__PURE__ */ new Set();
3127
+ const placed = /* @__PURE__ */ new Set();
3128
+ for (const gr of Array.isArray(list) ? list : []) {
3129
+ if (!gr || typeof gr !== "object" || gr.id === void 0 || gr.id === null || gr.id === "") continue;
3130
+ const id = String(gr.id);
3131
+ if (ids.has(id)) continue;
3132
+ const nodes = [];
3133
+ for (const raw of Array.isArray(gr.nodes) ? gr.nodes : []) {
3134
+ const n = String(raw);
3135
+ if (!nodeIds.has(n) || placed.has(n)) continue;
3136
+ placed.add(n);
3137
+ nodes.push(n);
3138
+ }
3139
+ if (!nodes.length) continue;
3140
+ ids.add(id);
3141
+ out.push({
3142
+ id,
3143
+ ...gr.label ? { label: String(gr.label) } : {},
3144
+ ...gr.tone ? { tone: String(gr.tone) } : {},
3145
+ nodes
3146
+ });
3147
+ }
3148
+ return out;
3149
+ }
3150
+ function notesOf(list) {
3151
+ const out = [];
3152
+ const ids = /* @__PURE__ */ new Set();
3153
+ for (const note of Array.isArray(list) ? list : []) {
3154
+ if (!note || typeof note !== "object" || note.id === void 0 || note.id === null || note.id === "") continue;
3155
+ const id = String(note.id);
3156
+ if (ids.has(id)) continue;
3157
+ ids.add(id);
3158
+ const w = round(note.size?.w);
3159
+ const h = round(note.size?.h);
3160
+ const size = { ...w > 0 ? { w } : {}, ...h > 0 ? { h } : {} };
3161
+ out.push({
3162
+ id,
3163
+ text: note.text === void 0 || note.text === null ? "" : String(note.text),
3164
+ position: { x: round(note.position?.x), y: round(note.position?.y) },
3165
+ ...Object.keys(size).length ? { size } : {}
3166
+ });
3167
+ }
3168
+ return out;
3169
+ }
3050
3170
  function normalise(graph) {
3051
3171
  const g = graph && typeof graph === "object" ? graph : emptyGraph();
3052
- return {
3172
+ const out = {
3053
3173
  nodes: (Array.isArray(g.nodes) ? g.nodes : []).map((n) => ({
3054
3174
  id: String(n.id),
3055
3175
  type: String(n.type),
3056
3176
  ...n.label ? { label: String(n.label) } : {},
3057
3177
  config: n.config && typeof n.config === "object" ? structuredClone(n.config) : {},
3058
- position: { x: Math.round(Number(n.position?.x) || 0), y: Math.round(Number(n.position?.y) || 0) }
3178
+ position: { x: Math.round(Number(n.position?.x) || 0), y: Math.round(Number(n.position?.y) || 0) },
3179
+ ...n.collapsed === true ? { collapsed: true } : {}
3059
3180
  })),
3060
3181
  connections: (Array.isArray(g.connections) ? g.connections : []).map((c) => ({
3061
3182
  source: String(c.source),
@@ -3064,6 +3185,11 @@ function normalise(graph) {
3064
3185
  targetPort: String(c.targetPort || "in")
3065
3186
  }))
3066
3187
  };
3188
+ const groups = groupsOf(g.groups, new Set(out.nodes.map((n) => n.id)));
3189
+ if (groups.length) out.groups = groups;
3190
+ const notes = notesOf(g.notes);
3191
+ if (notes.length) out.notes = notes;
3192
+ return out;
3067
3193
  }
3068
3194
  function sortedKeys(value) {
3069
3195
  if (Array.isArray(value)) return value.map(sortedKeys);
@@ -3080,14 +3206,17 @@ function serialise(graph) {
3080
3206
  type: n.type,
3081
3207
  ...n.label ? { label: n.label } : {},
3082
3208
  config: sortedKeys(n.config),
3083
- position: n.position
3209
+ position: n.position,
3210
+ ...n.collapsed ? { collapsed: true } : {}
3084
3211
  })),
3085
3212
  connections: g.connections.map((c) => ({
3086
3213
  source: c.source,
3087
3214
  target: c.target,
3088
3215
  sourcePort: c.sourcePort,
3089
3216
  targetPort: c.targetPort
3090
- }))
3217
+ })),
3218
+ ...g.groups ? { groups: g.groups } : {},
3219
+ ...g.notes ? { notes: g.notes } : {}
3091
3220
  });
3092
3221
  }
3093
3222
  function nextId(graph, typeId, { catalogue = null } = {}) {
@@ -3118,6 +3247,11 @@ function addNode(graph, typeId, position = null, {
3118
3247
  function removeNode(graph, nodeId) {
3119
3248
  graph.nodes = graph.nodes.filter((n) => n.id !== nodeId);
3120
3249
  graph.connections = graph.connections.filter((c) => c.source !== nodeId && c.target !== nodeId);
3250
+ if (Array.isArray(graph.groups)) {
3251
+ for (const gr of graph.groups) gr.nodes = gr.nodes.filter((id) => id !== nodeId);
3252
+ graph.groups = graph.groups.filter((gr) => gr.nodes.length);
3253
+ if (!graph.groups.length) delete graph.groups;
3254
+ }
3121
3255
  }
3122
3256
  function moveNode(graph, nodeId, position) {
3123
3257
  const node = graph.nodes.find((n) => n.id === nodeId);
@@ -3130,7 +3264,7 @@ function portOf(catalogue, node, name, direction) {
3130
3264
  const def = catalogue?.get?.(node.type);
3131
3265
  return def?.ports?.find((p) => p.name === name && p.direction === direction) || null;
3132
3266
  }
3133
- function canConnect(graph, catalogue, { source, sourcePort, target, targetPort }, { strings = null, dataRead = null } = {}) {
3267
+ function canConnect(graph, catalogue, { source, sourcePort, target, targetPort }, { strings = null, dataRead = null, dataPorts = false } = {}) {
3134
3268
  const S = createStrings(LOGIC_GRAPH_STRINGS, strings);
3135
3269
  if (source === target) return { ok: false, reason: S.connectSelf };
3136
3270
  const from = graph.nodes.find((n) => n.id === source);
@@ -3140,29 +3274,26 @@ function canConnect(graph, catalogue, { source, sourcePort, target, targetPort }
3140
3274
  const inp = portOf(catalogue, to, targetPort, "input");
3141
3275
  if (!out) return { ok: false, reason: S.connectNoOutput(sourcePort) };
3142
3276
  if (!inp) return { ok: false, reason: S.connectNoInput(targetPort) };
3143
- if ((out.port_type || "FLOW") !== "FLOW" || (inp.port_type || "FLOW") !== "FLOW") {
3144
- return { ok: false, reason: S.connectNotFlow(dataRead) };
3145
- }
3277
+ const flowOut = (out.port_type || "FLOW") === "FLOW";
3278
+ const flowIn = (inp.port_type || "FLOW") === "FLOW";
3279
+ if (!dataPorts && (!flowOut || !flowIn)) return { ok: false, reason: S.connectNotFlow(dataRead) };
3280
+ if (flowOut !== flowIn) return { ok: false, reason: S.connectKinds };
3146
3281
  if (graph.connections.some((c) => c.source === source && c.sourcePort === sourcePort && c.target === target && c.targetPort === targetPort)) {
3147
3282
  return { ok: false, reason: S.connectTwice };
3148
3283
  }
3149
- if (!out.multiple && graph.connections.some((c) => c.source === source && c.sourcePort === sourcePort)) {
3284
+ if (flowOut && !out.multiple && graph.connections.some((c) => c.source === source && c.sourcePort === sourcePort)) {
3150
3285
  return { ok: false, reason: S.connectOneLine(out.label || sourcePort) };
3151
3286
  }
3152
3287
  return { ok: true };
3153
3288
  }
3154
3289
  function connect(graph, catalogue, edge, options = {}) {
3155
3290
  const verdict = canConnect(graph, catalogue, edge, options);
3156
- if (verdict.ok) {
3157
- graph.connections.push({
3158
- source: edge.source,
3159
- target: edge.target,
3160
- sourcePort: edge.sourcePort,
3161
- targetPort: edge.targetPort
3162
- });
3163
- }
3291
+ if (verdict.ok) addConnection(graph, edge);
3164
3292
  return verdict;
3165
3293
  }
3294
+ function addConnection(graph, { source, target, sourcePort, targetPort }) {
3295
+ graph.connections.push({ source, target, sourcePort, targetPort });
3296
+ }
3166
3297
  function disconnect(graph, edge) {
3167
3298
  graph.connections = graph.connections.filter((c) => !sameEdge(c, edge));
3168
3299
  }
@@ -3188,14 +3319,25 @@ function inputNamesOf(graph, syntax, scope) {
3188
3319
  }
3189
3320
 
3190
3321
  // src/flow/canvas_editor/strings.js
3322
+ var counted = (steps, notes) => [[steps, "step"], [notes, "note"]].filter(([n]) => n > 0).map(([n, w]) => `${n} ${w}${n === 1 ? "" : "s"}`).join(" and ");
3191
3323
  var CANVAS_STRINGS = Object.freeze({
3192
3324
  palette: "Steps",
3193
3325
  canvasLabel: "Flow canvas",
3194
3326
  toolbarLabel: "Flow",
3195
3327
  arrange: "Arrange",
3196
3328
  arrangeTitle: "Lay the steps out left to right, in the order a run takes them",
3329
+ arrangeLayeredTitle: "Lay the steps out left to right, following their connections",
3197
3330
  fit: "Fit",
3198
3331
  fitTitle: "Show the whole flow",
3332
+ zoomOut: "Zoom out",
3333
+ zoomOutTitle: "Make the flow smaller, one step",
3334
+ zoomIn: "Zoom in",
3335
+ zoomInTitle: "Make the flow larger, one step",
3336
+ zoomReset: "100%",
3337
+ zoomResetTitle: "Show the flow at its natural size",
3338
+ zoomAtMin: "This is as small as the flow is drawn.",
3339
+ zoomAtMax: "This is as large as the flow is drawn.",
3340
+ minimapTitle: "Click to move the view there",
3199
3341
  nothingToUndo: "There is nothing to undo.",
3200
3342
  nothingToRedo: "There is nothing to redo.",
3201
3343
  arrangeNeedsTwo: "Arrange needs two steps or more.",
@@ -3209,30 +3351,158 @@ var CANVAS_STRINGS = Object.freeze({
3209
3351
  returnLine: "Back to the loop for its next pass",
3210
3352
  connectedReturn: "Goes round the loop again.",
3211
3353
  unknownType: (type) => `There is no step type "${type}".`,
3354
+ renderFailed: (name, why) => `${name} could not draw its body${why ? `: ${why}` : "."}`,
3355
+ updateFailed: (name, why) => `${name} could not bring its body up to date${why ? `: ${why}` : "."}`,
3212
3356
  stepId: (id) => `Step id: ${id}`,
3357
+ // `renameInPlace`
3358
+ nameEmpty: "A step needs a name: the one it had is kept.",
3359
+ // `collapsible`
3360
+ collapseNode: "Fold this step to its header",
3361
+ expandNode: "Show this step's ports again",
3213
3362
  connection: "Connection",
3214
3363
  connectionLine: (e) => `${e.source} \xB7 ${e.sourcePort} \u2192 ${e.target} \xB7 ${e.targetPort}`,
3215
3364
  removeConnection: "Remove connection",
3216
3365
  portCount: (n) => `\xD7${n}`,
3217
- portNotTaken: "not taken"
3366
+ portNotTaken: "not taken",
3367
+ // ── under the `connections` option ───────────────────────────────
3368
+ connectTypes: ({ from, fromStep, to, toStep, accepts, provides }) => `${to} on ${toStep} takes ${accepts.join(" or ")}, and ${from} on ${fromStep} gives ${provides.join(" or ")}.`,
3369
+ connectRefused: "These two cannot be connected.",
3370
+ connectDrag: "Drop it on an input to connect, or on the canvas to add a step there. Esc cancels.",
3371
+ dropOnInput: "Drop the line on an input to connect it.",
3372
+ dropPickerTitle: "Add a step and connect it",
3373
+ dropNothingFits: "No step has an input this output can connect to.",
3374
+ offerAccept: "Accept",
3375
+ offerDismiss: "Not now",
3376
+ // `multiSelect`
3377
+ alignLeft: "Align left edges",
3378
+ alignCenter: "Align centres",
3379
+ alignRight: "Align right edges",
3380
+ alignTop: "Align top edges",
3381
+ alignMiddle: "Align middles",
3382
+ alignBottom: "Align bottom edges",
3383
+ distributeHorizontal: "Distribute horizontally",
3384
+ distributeVertical: "Distribute vertically",
3385
+ alignNeedsTwo: "Pick two steps or more to align them.",
3386
+ distributeNeedsThree: "Pick three steps or more to distribute them.",
3387
+ stepsPicked: (n) => `${n} steps picked`,
3388
+ stepsPickedLine: "Drag one to move them all. The arrow keys nudge them.",
3389
+ removeSteps: (n) => n === 1 ? "Remove 1 step" : `Remove ${n} steps`,
3390
+ // `groups`
3391
+ group: "Group",
3392
+ groupTitle: "Group the picked steps (Ctrl+G)",
3393
+ ungroup: "Ungroup",
3394
+ ungroupTitle: "Ungroup the groups the picked steps are in (Ctrl+Shift+G)",
3395
+ groupNeedsOne: "Pick the steps to group first.",
3396
+ ungroupNeedsGroup: "None of the picked steps is in a group.",
3397
+ groupUntitled: "Group",
3398
+ groupName: "Group name",
3399
+ // `notes`
3400
+ note: "Note",
3401
+ noteTitle: "Add a note to the canvas",
3402
+ noteKind: "Note",
3403
+ noteText: "Note text",
3404
+ noteEmpty: "Empty note \u2014 double-click to write in it",
3405
+ removeNote: "Remove note",
3406
+ itemsPicked: (steps, notes) => `${counted(steps, notes)} picked`,
3407
+ removeItems: (n) => n === 1 ? "Remove 1 item" : `Remove ${n} items`,
3408
+ // `clipboard`
3409
+ copyLabel: (title, n) => n > 1 ? `${title} (copy ${n})` : `${title} (copy)`,
3410
+ copied: (n) => n === 1 ? "Copied 1 step." : `Copied ${n} steps.`,
3411
+ cutDone: (n) => n === 1 ? "Cut 1 step." : `Cut ${n} steps.`,
3412
+ // a copy or a cut that took notes too (`notes`)
3413
+ copiedItems: (steps, notes) => `Copied ${counted(steps, notes)}.`,
3414
+ cutDoneItems: (steps, notes) => `Cut ${counted(steps, notes)}.`,
3415
+ nothingPicked: "Pick a step first.",
3416
+ cannotCopy: "None of the picked steps can be copied.",
3417
+ cannotCut: "None of the picked steps can be removed, so none can be cut.",
3418
+ nothingToPaste: "There is nothing to paste.",
3419
+ pasteNotSteps: "What is on the clipboard is not steps of a flow.",
3420
+ notAddable: (name) => `\u2018${name}\u2019 cannot be added to this flow.`,
3421
+ clipboardFailed: "The clipboard could not be reached.",
3422
+ routingFallback: (n) => `More than ${n} steps: the lines are drawn as plain curves, not routed round the steps.`
3218
3423
  });
3219
3424
 
3425
+ // src/flow/canvas_editor/connections.js
3426
+ var portId = (direction, name) => `${direction}:${name}`;
3427
+ var typeList = (v) => Array.isArray(v) && v.length ? v.map(String) : null;
3428
+ function connectionRules({ catalogue, policy = null, dataPorts = false, strings }) {
3429
+ const S = strings;
3430
+ const nameOf2 = (n) => n?.label || catalogue.get(n?.type)?.label || n?.type || "";
3431
+ let asked = null;
3432
+ const typed = (ctx) => {
3433
+ const accepts = typeList(ctx.toPort.accepts);
3434
+ const provides = typeList(ctx.fromPort.provides);
3435
+ if (!accepts || !provides || provides.some((x) => accepts.includes(x))) return null;
3436
+ return {
3437
+ ok: false,
3438
+ reason: say(S, "connectTypes", {
3439
+ from: ctx.fromPort.label || ctx.fromPort.name,
3440
+ fromStep: nameOf2(ctx.fromNode),
3441
+ to: ctx.toPort.label || ctx.toPort.name,
3442
+ toStep: nameOf2(ctx.toNode),
3443
+ accepts,
3444
+ provides
3445
+ })
3446
+ };
3447
+ };
3448
+ const platform = new NodePlatform();
3449
+ const router = new ConnectorRouter({
3450
+ platform,
3451
+ // The generic refusals in the host's words, when its `strings` has them.
3452
+ strings: Object.fromEntries(Object.keys(CONNECTOR_STRINGS).filter((k) => S[k] !== void 0).map((k) => [k, S[k]])),
3453
+ policy: (ctx) => typed(ctx) || (typeof policy === "function" ? policy({ ...ctx, connection: { ...asked.connection }, graph: normalise(asked.graph) }) : null)
3454
+ });
3455
+ return function verdictOf(graph, edge) {
3456
+ const first = canConnect(graph, catalogue, edge, { strings: S, dataPorts });
3457
+ if (!first.ok) return first;
3458
+ platform.hydrate(graph.nodes.map((n) => ({
3459
+ ...n,
3460
+ ports: (catalogue.get(n.type)?.ports || []).map((p) => ({ ...p, id: portId(p.direction, p.name) }))
3461
+ })));
3462
+ router.hydrate(graph.connections.map((c, i) => ({
3463
+ id: i,
3464
+ from: c.source,
3465
+ to: c.target,
3466
+ fromPort: portId("output", c.sourcePort),
3467
+ toPort: portId("input", c.targetPort)
3468
+ })));
3469
+ asked = { connection: edge, graph };
3470
+ try {
3471
+ const v = router.canConnect(
3472
+ { nodeId: edge.source, portId: portId("output", edge.sourcePort) },
3473
+ { nodeId: edge.target, portId: portId("input", edge.targetPort) }
3474
+ );
3475
+ if (!v || v.ok === false) return { ok: false, reason: String(v?.reason || say(S, "connectRefused")) };
3476
+ return v.offer ? { ok: true, offer: v.offer } : { ok: true };
3477
+ } finally {
3478
+ asked = null;
3479
+ }
3480
+ };
3481
+ }
3482
+
3220
3483
  // src/flow/canvas_editor/layout.js
3221
3484
  var CANVAS_NODE = Object.freeze({ width: 208, header: 34, row: 22 });
3222
3485
  var FOOT = 12;
3223
3486
  var inputsOf = (catalogue, node) => catalogue?.inputs ? catalogue.inputs(node?.type) : [];
3224
3487
  var outputsOf = (catalogue, node) => catalogue?.outputs ? catalogue.outputs(node?.type) : [];
3225
3488
  var roleOf = (catalogue, node) => catalogue?.role ? catalogue.role(node?.type) : "step";
3489
+ var sizeOf = (catalogue, node) => catalogue?.size ? catalogue.size(node?.type) : null;
3490
+ var foldedOf = (node, box) => typeof box?.folded === "function" ? Boolean(box.folded(node)) : node?.collapsed === true;
3491
+ function nodeWidth(catalogue, node, box = CANVAS_NODE) {
3492
+ return sizeOf(catalogue, node)?.w || box.width;
3493
+ }
3226
3494
  function portAnchor(catalogue, node, portName, direction, box = CANVAS_NODE) {
3495
+ const x = node.position.x + (direction === "input" ? 0 : nodeWidth(catalogue, node, box));
3496
+ const side = direction === "input" ? "left" : "right";
3497
+ if (foldedOf(node, box)) return { x, y: node.position.y + box.header / 2, side };
3227
3498
  const list = direction === "input" ? inputsOf(catalogue, node) : outputsOf(catalogue, node);
3228
3499
  const i = Math.max(0, list.findIndex((p) => p.name === portName));
3229
- return {
3230
- x: node.position.x + (direction === "input" ? 0 : box.width),
3231
- y: node.position.y + box.header + box.row * i + box.row / 2,
3232
- side: direction === "input" ? "left" : "right"
3233
- };
3500
+ return { x, y: node.position.y + box.header + box.row * i + box.row / 2, side };
3234
3501
  }
3235
3502
  function nodeBottom(catalogue, node, box = CANVAS_NODE) {
3503
+ if (foldedOf(node, box)) return node.position.y + box.header;
3504
+ const h = sizeOf(catalogue, node)?.h;
3505
+ if (h) return node.position.y + h;
3236
3506
  const rows = Math.max(inputsOf(catalogue, node).length, outputsOf(catalogue, node).length, 1);
3237
3507
  return node.position.y + box.header + box.row * rows + FOOT;
3238
3508
  }
@@ -3245,7 +3515,9 @@ function layoutGraph(graph, catalogue, {
3245
3515
  row = CANVAS_NODE.row,
3246
3516
  gap = 96,
3247
3517
  rowGap = 40,
3248
- loopEntry = "in"
3518
+ loopEntry = "in",
3519
+ heightOf = null,
3520
+ folded = void 0
3249
3521
  } = {}) {
3250
3522
  const nodes = graph?.nodes || [];
3251
3523
  const connections = graph?.connections || [];
@@ -3271,16 +3543,86 @@ function layoutGraph(graph, catalogue, {
3271
3543
  }
3272
3544
  for (const n of nodes) if (!order.has(n.id)) order.set(n.id, order.size);
3273
3545
  const rows = (n) => Math.max(inputsOf(catalogue, n).length, outputsOf(catalogue, n).length);
3546
+ const box = { width, header, row, ...folded !== void 0 ? { folded } : {} };
3547
+ const height = (n) => {
3548
+ if (foldedOf(n, box)) return header + 24;
3549
+ const own = sizeOf(catalogue, n)?.h || (typeof heightOf === "function" ? heightOf(n) : null);
3550
+ return own ? own + 2 * FOOT : header + row * rows(n) + 24;
3551
+ };
3552
+ const widest = Math.max(width, ...nodes.map((n) => nodeWidth(catalogue, n, box)));
3274
3553
  return arrange(
3275
- nodes.map((n) => ({
3276
- id: n.id,
3277
- label: String(order.get(n.id)).padStart(6, "0"),
3278
- height: header + row * rows(n) + 24
3279
- })),
3554
+ nodes.map((n) => ({ id: n.id, label: String(order.get(n.id)).padStart(6, "0"), height: height(n) })),
3280
3555
  connections.filter((c) => !isReturn(c)).map((c) => ({ from: c.target, to: c.source })),
3281
- { columnGap: width + gap, rowGap }
3556
+ { columnGap: widest + gap, rowGap }
3282
3557
  );
3283
3558
  }
3559
+ var CURVE_STEPS = 24;
3560
+ function pathPoints(d, { steps = CURVE_STEPS } = {}) {
3561
+ const tokens = String(d || "").match(/-?\d*\.?\d+(?:[eE][-+]?\d+)?|[A-Za-z]/g) || [];
3562
+ const points = [];
3563
+ let at = null;
3564
+ let lastControl = null;
3565
+ let opened = null;
3566
+ let i = 0;
3567
+ const num2 = () => Number(tokens[i++]);
3568
+ const cubic = (c1, c2, end) => {
3569
+ for (let k = 1; k <= steps; k += 1) {
3570
+ const t = k / steps;
3571
+ const u = 1 - t;
3572
+ points.push({
3573
+ x: u * u * u * at.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * end.x,
3574
+ y: u * u * u * at.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * end.y
3575
+ });
3576
+ }
3577
+ lastControl = c2;
3578
+ at = end;
3579
+ };
3580
+ while (i < tokens.length) {
3581
+ const command = tokens[i++];
3582
+ if (command === "M") {
3583
+ at = { x: num2(), y: num2() };
3584
+ opened = at;
3585
+ points.push(at);
3586
+ lastControl = null;
3587
+ } else if (command === "Z") {
3588
+ at = opened;
3589
+ points.push(at);
3590
+ lastControl = null;
3591
+ } else if (command === "L") {
3592
+ at = { x: num2(), y: num2() };
3593
+ points.push(at);
3594
+ lastControl = null;
3595
+ } else if (command === "C") {
3596
+ const c1 = { x: num2(), y: num2() };
3597
+ const c2 = { x: num2(), y: num2() };
3598
+ cubic(c1, c2, { x: num2(), y: num2() });
3599
+ } else if (command === "S") {
3600
+ const c1 = lastControl ? { x: 2 * at.x - lastControl.x, y: 2 * at.y - lastControl.y } : at;
3601
+ const c2 = { x: num2(), y: num2() };
3602
+ cubic(c1, c2, { x: num2(), y: num2() });
3603
+ } else {
3604
+ throw new Error(`pathPoints reads M, L, C, S and Z only, not "${command}".`);
3605
+ }
3606
+ }
3607
+ return points;
3608
+ }
3609
+ function pathMidpoint(d) {
3610
+ const points = pathPoints(d);
3611
+ if (points.length < 2) return points[0] ? { ...points[0] } : { x: 0, y: 0 };
3612
+ const lengths = [0];
3613
+ for (let k2 = 1; k2 < points.length; k2 += 1) {
3614
+ lengths.push(lengths[k2 - 1] + Math.hypot(points[k2].x - points[k2 - 1].x, points[k2].y - points[k2 - 1].y));
3615
+ }
3616
+ const half = lengths[lengths.length - 1] / 2;
3617
+ let k = 1;
3618
+ while (k < lengths.length - 1 && lengths[k] < half) k += 1;
3619
+ const span = lengths[k] - lengths[k - 1];
3620
+ const t = span > 0 ? (half - lengths[k - 1]) / span : 0;
3621
+ return {
3622
+ x: points[k - 1].x + (points[k].x - points[k - 1].x) * t,
3623
+ y: points[k - 1].y + (points[k].y - points[k - 1].y) * t
3624
+ };
3625
+ }
3284
3626
 
3285
3627
  // src/flow/canvas_editor/editor.js
3286
3628
  var CANVAS_ACTIONS = Object.freeze([
@@ -3292,12 +3634,61 @@ var CANVAS_ACTIONS = Object.freeze([
3292
3634
  "flow:node:label",
3293
3635
  "flow:node:config",
3294
3636
  "flow:settings",
3295
- "flow:arrange"
3637
+ "flow:arrange",
3638
+ "flow:nodes:move",
3639
+ "flow:nodes:remove",
3640
+ "flow:nodes:align",
3641
+ "flow:paste",
3642
+ "flow:node:duplicate",
3643
+ "flow:node:collapse",
3644
+ "flow:group:add",
3645
+ "flow:group:remove",
3646
+ "flow:group:label",
3647
+ "flow:group:members",
3648
+ "flow:note:add",
3649
+ "flow:note:remove",
3650
+ "flow:note:edit",
3651
+ "flow:note:move"
3296
3652
  ]);
3653
+ var ALIGN = Object.freeze(["left", "center", "right", "top", "middle", "bottom"]);
3654
+ var ALIGN_ICON = Object.freeze({
3655
+ left: "align_horizontal_left",
3656
+ center: "align_horizontal_center",
3657
+ right: "align_horizontal_right",
3658
+ top: "align_vertical_top",
3659
+ middle: "align_vertical_center",
3660
+ bottom: "align_vertical_bottom"
3661
+ });
3662
+ var DISTRIBUTE = Object.freeze(["horizontal", "vertical"]);
3663
+ var DISTRIBUTE_ICON = Object.freeze({ horizontal: "horizontal_distribute", vertical: "vertical_distribute" });
3664
+ var COPY_OFFSET = 2;
3665
+ var NUDGE_FAR = 10;
3666
+ var FRAME = Object.freeze({ pad: 16, head: 26 });
3667
+ var NOTE_WIDTH = 192;
3297
3668
  var NATURAL = 1;
3298
3669
  var READABLE = 0.8;
3670
+ var ZOOM_STEPS = Object.freeze([
3671
+ CanvasAdapter.MIN_SCALE,
3672
+ 0.25,
3673
+ 0.33,
3674
+ 0.5,
3675
+ 0.67,
3676
+ 0.8,
3677
+ 1,
3678
+ 1.25,
3679
+ 1.5,
3680
+ 2,
3681
+ 3,
3682
+ CanvasAdapter.MAX_SCALE
3683
+ ]);
3684
+ var ZOOM_EPSILON = 1e-6;
3685
+ var WHEEL_SETTLE_MS = 300;
3686
+ var MINIMAP = Object.freeze({ width: 180, height: 120, margin: 40 });
3299
3687
  var DRAG_PX = 4;
3300
3688
  var RETURN_DROP = 40;
3689
+ var CLEARANCE = 16;
3690
+ var ARROW = Object.freeze({ length: 9, half: 4.5 });
3691
+ var ROUTINGS = Object.freeze(["simple", "obstacles"]);
3301
3692
  var SVG = "http://www.w3.org/2000/svg";
3302
3693
  var DEFAULT_REFERENCES = Object.freeze({
3303
3694
  template: TEMPLATE_REFERENCES,
@@ -3311,7 +3702,15 @@ function sortedKeys2(value) {
3311
3702
  }
3312
3703
  return value;
3313
3704
  }
3705
+ var cap = (word2) => word2.charAt(0).toUpperCase() + word2.slice(1);
3314
3706
  var plainObject = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
3707
+ function toCatalogue(value, who) {
3708
+ const catalogue = Array.isArray(value) ? createStepCatalogue(value) : value;
3709
+ if (!catalogue?.get || !catalogue?.byCategory) {
3710
+ throw new Error(`${who} needs a step catalogue (createStepCatalogue(types, \u2026)).`);
3711
+ }
3712
+ return catalogue;
3713
+ }
3315
3714
  function createCanvasEditor(host, options = {}) {
3316
3715
  const {
3317
3716
  widgets = createWidgetRegistry(),
@@ -3336,22 +3735,56 @@ function createCanvasEditor(host, options = {}) {
3336
3735
  paletteTypes = null,
3337
3736
  label = null,
3338
3737
  gridSize = 16,
3339
- waitsForAll = void 0
3738
+ waitsForAll = void 0,
3739
+ connections: linkOptions = null,
3740
+ multiSelect = false,
3741
+ edgeStyle = null,
3742
+ routing = "simple",
3743
+ hoverHighlight = false,
3744
+ onViewportChange = null,
3745
+ zoomControls = false,
3746
+ minimap = false,
3747
+ renameInPlace = false,
3748
+ clipboard = null,
3749
+ prepareCopy = null,
3750
+ collapsible = false,
3751
+ groups: groupsOption = false,
3752
+ notes: notesOption = false,
3753
+ layout = "flow"
3340
3754
  } = options;
3341
- const catalogue = Array.isArray(options.catalogue) ? createStepCatalogue(options.catalogue) : options.catalogue;
3342
- if (!catalogue?.get || !catalogue?.byCategory) {
3343
- throw new Error("createCanvasEditor needs a step catalogue (createStepCatalogue(types, \u2026)).");
3344
- }
3755
+ const folding = Boolean(collapsible);
3756
+ const multi = Boolean(multiSelect);
3757
+ const grouping = Boolean(groupsOption);
3758
+ const noting = Boolean(notesOption);
3759
+ if (layout !== "flow" && layout !== "layered" && typeof layout !== "function") {
3760
+ throw new Error(`createCanvasEditor's layout is 'flow', 'layered' or a function (graph, ctx) \u2192 Map; got ${String(layout)}.`);
3761
+ }
3762
+ if (!ROUTINGS.includes(routing)) {
3763
+ throw new Error(`createCanvasEditor: routing is ${ROUTINGS.map((r) => `'${r}'`).join(" or ")}, not '${routing}'.`);
3764
+ }
3765
+ if (clipboard && clipboard !== true && (typeof clipboard.read !== "function" || typeof clipboard.write !== "function")) {
3766
+ throw new Error("createCanvasEditor: clipboard is true or {read(), write(text)}.");
3767
+ }
3768
+ const clip = clipboard === true ? "memory" : clipboard || null;
3769
+ const styled = typeof edgeStyle === "function";
3770
+ let catalogue = toCatalogue(options.catalogue, "createCanvasEditor");
3345
3771
  const S = createStrings(CANVAS_STRINGS, strings);
3346
3772
  const BOX = Object.freeze({
3347
3773
  width: Number(nodeOptions.width) || CANVAS_NODE.width,
3348
3774
  header: Number(nodeOptions.header) || CANVAS_NODE.header,
3349
- row: Number(nodeOptions.row) || CANVAS_NODE.row
3775
+ row: Number(nodeOptions.row) || CANVAS_NODE.row,
3776
+ /** Is this node drawn folded to its header? Only with `collapsible`. */
3777
+ folded: (n) => folding && n?.collapsed === true
3350
3778
  });
3351
3779
  const loopPorts = Object.freeze({ ...DEFAULT_LOOP_PORTS, ...plainObject(loop) });
3352
3780
  const canRemove = typeof removable === "function" ? removable : (_node, type) => (type?.role || "step") !== "start";
3353
3781
  const offered = typeof paletteTypes === "function" ? paletteTypes : (type) => type.role !== "start";
3782
+ const linking = linkOptions && typeof linkOptions === "object" ? linkOptions : null;
3783
+ const dataConnect = linking?.dataPorts === "connect";
3784
+ const rulesFor = (types) => linking ? connectionRules({ catalogue: types, policy: linking.policy, dataPorts: dataConnect, strings: S }) : null;
3785
+ let verdictOf = rulesFor(catalogue);
3354
3786
  const root = el("div", "twm-flow-graph");
3787
+ if (linking) root.classList.add("twm-flow-graph--linking");
3355
3788
  if (BOX.width !== CANVAS_NODE.width) root.style.setProperty("--twm-canvas-node-width", `${BOX.width}px`);
3356
3789
  if (BOX.header !== CANVAS_NODE.header) root.style.setProperty("--twm-canvas-node-header", `${BOX.header}px`);
3357
3790
  if (BOX.row !== CANVAS_NODE.row) root.style.setProperty("--twm-canvas-node-row", `${BOX.row}px`);
@@ -3367,6 +3800,11 @@ function createCanvasEditor(host, options = {}) {
3367
3800
  message.setAttribute("aria-live", "polite");
3368
3801
  const banner = el("p", "twm-flow-graph__banner");
3369
3802
  banner.hidden = true;
3803
+ const offerBox = linking ? el("div", "twm-flow-graph__offer") : null;
3804
+ if (offerBox) {
3805
+ offerBox.hidden = true;
3806
+ offerBox.setAttribute("role", "status");
3807
+ }
3370
3808
  const body = el("div", "twm-flow-graph__body");
3371
3809
  const palette = el("nav", "twm-flow-graph__palette");
3372
3810
  palette.setAttribute("aria-label", say(S, "palette"));
@@ -3383,19 +3821,46 @@ function createCanvasEditor(host, options = {}) {
3383
3821
  let findings = [];
3384
3822
  let findingsBy = /* @__PURE__ */ new Map();
3385
3823
  let overlay = null;
3824
+ let liveValues = /* @__PURE__ */ new Map();
3386
3825
  let selected = null;
3826
+ let pressed = null;
3827
+ let released = null;
3387
3828
  let connecting = null;
3829
+ let pendingOffer = null;
3388
3830
  let suppressClick = false;
3389
3831
  let dragCleanup = null;
3390
3832
  let destroyed = false;
3391
3833
  let fitFrame = null;
3392
3834
  let travelling = null;
3393
3835
  let lastSelect = "";
3836
+ let hovered = null;
3837
+ let edgeEls = [];
3838
+ let routingFallback = false;
3394
3839
  const nodeEls = /* @__PURE__ */ new Map();
3840
+ const frameEls = /* @__PURE__ */ new Map();
3841
+ const noteEls = /* @__PURE__ */ new Map();
3842
+ let editing = null;
3843
+ const builtFrom = /* @__PURE__ */ new Map();
3844
+ const bodies = /* @__PURE__ */ new Map();
3845
+ const inlines = /* @__PURE__ */ new Map();
3846
+ let renaming = null;
3847
+ const bodyFindings = /* @__PURE__ */ new Map();
3848
+ let painting = false;
3395
3849
  const consumerState = /* @__PURE__ */ new Map();
3850
+ let startView = viewportOf(options.viewport);
3851
+ let reportedView = "";
3852
+ let wheelTimer = null;
3853
+ let pressEnd = null;
3854
+ let zoomLimit = "";
3855
+ let miniMap = null;
3856
+ let copiedText = null;
3857
+ let pasteRun = { text: null, times: 0 };
3396
3858
  const canvas = new CanvasAdapter({ container: canvasHost, gridSize });
3859
+ if (startView) canvas.setViewport(startView);
3397
3860
  canvas.root.setAttribute("role", "application");
3398
3861
  canvas.root.setAttribute("aria-label", label || say(S, "canvasLabel"));
3862
+ const labelLayer = styled ? el("div", "twm-flow-graph__edge-labels") : null;
3863
+ if (labelLayer) canvas.nodeLayer.appendChild(labelLayer);
3399
3864
  canvas.onSelectionChange = () => {
3400
3865
  if (connecting) cancelConnect();
3401
3866
  if (selected && selected.kind !== "flow") select(null);
@@ -3404,6 +3869,17 @@ function createCanvasEditor(host, options = {}) {
3404
3869
  const readOnlyReason = () => ro && typeof ro === "object" && ro.reason ? String(ro.reason) : "";
3405
3870
  const nodeOf = (id) => graph.nodes.find((x) => x.id === id) || null;
3406
3871
  const typeOf = (n) => catalogue.get(n?.type);
3872
+ const pickedIds = () => selected?.kind === "node" ? [selected.id] : selected?.kind === "many" ? selected.ids : [];
3873
+ const pickedSet = () => new Set(pickedIds());
3874
+ const isPicked = (id) => selected?.kind === "node" ? selected.id === id : selected?.kind === "many" ? selected.ids.includes(id) : false;
3875
+ const toggles = (ev) => multi && Boolean(ev.shiftKey || ev.ctrlKey || ev.metaKey);
3876
+ const groupList = () => Array.isArray(graph.groups) ? graph.groups : [];
3877
+ const noteList = () => Array.isArray(graph.notes) ? graph.notes : [];
3878
+ const groupOf = (id) => groupList().find((gr) => gr.id === id) || null;
3879
+ const noteOf = (id) => noteList().find((x) => x.id === id) || null;
3880
+ const pickedNotes = () => selected?.kind === "note" ? [selected.id] : selected?.kind === "many" ? selected.notes || [] : [];
3881
+ const isNotePicked = (id) => pickedNotes().includes(id);
3882
+ const allFindings = () => bodyFindings.size ? [...findings, ...bodyFindings.values()] : findings;
3407
3883
  const snapshot = () => JSON.stringify({ graph: JSON.parse(serialise(graph)), flowSettings: sortedKeys2(settingsValue) });
3408
3884
  const history = new FlowHistory({
3409
3885
  actions: CANVAS_ACTIONS,
@@ -3419,23 +3895,40 @@ function createCanvasEditor(host, options = {}) {
3419
3895
  onChange({ graph: normalise(graph), flowSettings: structuredClone(settingsValue), text, action, key, via });
3420
3896
  }
3421
3897
  function commit(action, key = null) {
3898
+ if (pendingOffer) clearOffer();
3422
3899
  if (history.commit(snapshot(), action, key)) emitChange(action, key);
3423
3900
  paintToolbar();
3424
3901
  }
3425
3902
  function restore(state) {
3903
+ editing?.done(false);
3426
3904
  const p = JSON.parse(state);
3427
3905
  graph = normalise(p.graph);
3428
3906
  settingsValue = plainObject(p.flowSettings);
3429
3907
  if (connecting) cancelConnect();
3430
- if (selected?.kind === "node" && !nodeOf(selected.id)) selected = null;
3431
- if (selected?.kind === "edge" && !graph.connections.some((c) => sameEdge(c, selected.edge))) selected = null;
3432
- findingsBy = groupFindings(findings, { graph });
3908
+ if (pendingOffer) clearOffer();
3909
+ prunePick();
3910
+ findingsBy = groupFindings(allFindings(), { graph });
3433
3911
  paintNodes();
3434
3912
  paintPanel();
3435
3913
  keepFocus();
3436
3914
  emitChange(null, null, travelling);
3437
3915
  announceSelection();
3438
3916
  }
3917
+ function prunePick() {
3918
+ if (selected?.kind === "node" && !nodeOf(selected.id)) selected = null;
3919
+ if (selected?.kind === "edge" && !graph.connections.some((c) => sameEdge(c, selected.edge))) selected = null;
3920
+ if (selected?.kind === "note" && !noteOf(selected.id)) selected = null;
3921
+ if (selected?.kind === "many") selected = pickOf(selected.ids, selected.notes || []);
3922
+ }
3923
+ function pickOf(ids, noteIds = []) {
3924
+ const live = new Set(graph.nodes.map((x) => x.id));
3925
+ const list = [...new Set(ids)].filter((id) => live.has(id));
3926
+ const liveNotes = new Set(noteList().map((x) => x.id));
3927
+ const notes = [...new Set(noteIds)].filter((id) => liveNotes.has(id));
3928
+ if (list.length + notes.length > 1) return { kind: "many", ids: list, ...notes.length ? { notes } : {} };
3929
+ if (list.length) return { kind: "node", id: list[0] };
3930
+ return notes.length ? { kind: "note", id: notes[0] } : null;
3931
+ }
3439
3932
  function keepFocus() {
3440
3933
  if (destroyed || !root.isConnected) return;
3441
3934
  const active = document.activeElement;
@@ -3458,16 +3951,97 @@ function createCanvasEditor(host, options = {}) {
3458
3951
  arrange: tool("arrange", {
3459
3952
  label: say(S, "arrange"),
3460
3953
  icon: "account_tree",
3461
- title: say(S, "arrangeTitle"),
3462
- onClick: () => arrangeGraph()
3954
+ title: say(S, layout === "flow" ? "arrangeTitle" : "arrangeLayeredTitle"),
3955
+ onClick: () => {
3956
+ if (arrangeGraph(arrangeTarget())) viewportSettled();
3957
+ }
3463
3958
  }),
3464
3959
  fit: tool("fit", {
3465
3960
  label: say(S, "fit"),
3466
3961
  icon: "fit_screen",
3467
3962
  title: say(S, "fitTitle"),
3468
- onClick: () => fit({ whole: true })
3963
+ onClick: () => {
3964
+ fit({ whole: true });
3965
+ viewportSettled();
3966
+ }
3469
3967
  })
3470
3968
  };
3969
+ const pickTools = [];
3970
+ if (multi) {
3971
+ for (const kind of ALIGN) {
3972
+ const id = `align-${kind}`;
3973
+ const words3 = say(S, `align${cap(kind)}`);
3974
+ tools[id] = tool(id, {
3975
+ label: words3,
3976
+ title: words3,
3977
+ icon: ALIGN_ICON[kind],
3978
+ iconOnly: true,
3979
+ onClick: () => alignNodes(kind)
3980
+ });
3981
+ pickTools.push(tools[id]);
3982
+ }
3983
+ for (const axis of DISTRIBUTE) {
3984
+ const id = `distribute-${axis}`;
3985
+ const words3 = say(S, `distribute${cap(axis)}`);
3986
+ tools[id] = tool(id, {
3987
+ label: words3,
3988
+ title: words3,
3989
+ icon: DISTRIBUTE_ICON[axis],
3990
+ iconOnly: true,
3991
+ onClick: () => distributeNodes(axis)
3992
+ });
3993
+ pickTools.push(tools[id]);
3994
+ }
3995
+ }
3996
+ const itemTools = [];
3997
+ if (grouping) {
3998
+ tools.group = tool("group", {
3999
+ label: say(S, "group"),
4000
+ title: say(S, "groupTitle"),
4001
+ icon: "select_all",
4002
+ iconOnly: true,
4003
+ onClick: () => groupPicked()
4004
+ });
4005
+ tools.ungroup = tool("ungroup", {
4006
+ label: say(S, "ungroup"),
4007
+ title: say(S, "ungroupTitle"),
4008
+ icon: "deselect",
4009
+ iconOnly: true,
4010
+ onClick: () => ungroupPicked()
4011
+ });
4012
+ itemTools.push(tools.group, tools.ungroup);
4013
+ }
4014
+ if (noting) {
4015
+ tools.note = tool("note", {
4016
+ label: say(S, "note"),
4017
+ title: say(S, "noteTitle"),
4018
+ icon: "sticky_note_2",
4019
+ onClick: () => {
4020
+ const n = addNote();
4021
+ if (n) editNote(n.id);
4022
+ }
4023
+ });
4024
+ itemTools.push(tools.note);
4025
+ }
4026
+ if (zoomControls) {
4027
+ tools["zoom-out"] = tool("zoom-out", {
4028
+ label: say(S, "zoomOut"),
4029
+ icon: "zoom_out",
4030
+ title: say(S, "zoomOutTitle"),
4031
+ onClick: () => zoomStep(-1)
4032
+ });
4033
+ tools["zoom-in"] = tool("zoom-in", {
4034
+ label: say(S, "zoomIn"),
4035
+ icon: "zoom_in",
4036
+ title: say(S, "zoomInTitle"),
4037
+ onClick: () => zoomStep(1)
4038
+ });
4039
+ tools["zoom-reset"] = tool("zoom-reset", {
4040
+ label: say(S, "zoomReset"),
4041
+ title: say(S, "zoomResetTitle"),
4042
+ onClick: () => zoomTo(NATURAL)
4043
+ });
4044
+ }
3471
4045
  const own = new Set(Object.keys(tools));
3472
4046
  const consumerTools = [];
3473
4047
  for (const a of Array.isArray(actions) ? actions : []) {
@@ -3489,6 +4063,9 @@ function createCanvasEditor(host, options = {}) {
3489
4063
  tools.redo,
3490
4064
  tools.arrange,
3491
4065
  tools.fit,
4066
+ ...["zoom-out", "zoom-in", "zoom-reset"].filter((id) => tools[id]).map((id) => tools[id]),
4067
+ ...pickTools,
4068
+ ...itemTools,
3492
4069
  ...consumerTools.map((t) => t.el),
3493
4070
  status,
3494
4071
  toolbarEnd
@@ -3496,12 +4073,26 @@ function createCanvasEditor(host, options = {}) {
3496
4073
  if (typeof slots.toolbarEnd === "function") slots.toolbarEnd(toolbarEnd);
3497
4074
  function refusals() {
3498
4075
  const stop = isReadOnly() ? readOnlyReason() || say(S, "readOnlyRefusal") : null;
3499
- return {
4076
+ const r = {
3500
4077
  undo: stop || (history.canUndo ? null : say(S, "nothingToUndo")),
3501
4078
  redo: stop || (history.canRedo ? null : say(S, "nothingToRedo")),
3502
4079
  arrange: stop || (graph.nodes.length < 2 ? say(S, "arrangeNeedsTwo") : null),
3503
- fit: null
4080
+ fit: null,
4081
+ "zoom-out": canvas.scale <= ZOOM_STEPS[0] + ZOOM_EPSILON ? say(S, "zoomAtMin") : null,
4082
+ "zoom-in": canvas.scale >= ZOOM_STEPS[ZOOM_STEPS.length - 1] - ZOOM_EPSILON ? say(S, "zoomAtMax") : null,
4083
+ "zoom-reset": null
3504
4084
  };
4085
+ if (multi) {
4086
+ const n = pickedIds().length;
4087
+ for (const kind of ALIGN) r[`align-${kind}`] = stop || (n < 2 ? say(S, "alignNeedsTwo") : null);
4088
+ for (const axis of DISTRIBUTE) r[`distribute-${axis}`] = stop || (n < 3 ? say(S, "distributeNeedsThree") : null);
4089
+ }
4090
+ if (grouping) {
4091
+ r.group = stop || (pickedIds().length ? null : say(S, "groupNeedsOne"));
4092
+ r.ungroup = stop || (groupsOfPick().length ? null : say(S, "ungroupNeedsGroup"));
4093
+ }
4094
+ if (noting) r.note = stop;
4095
+ return r;
3505
4096
  }
3506
4097
  function refuse(control, sentence) {
3507
4098
  control.disabled = Boolean(sentence);
@@ -3557,6 +4148,129 @@ function createCanvasEditor(host, options = {}) {
3557
4148
  travelling = null;
3558
4149
  }
3559
4150
  }
4151
+ const viewKey = (v) => `${v.scale}|${v.tx}|${v.ty}`;
4152
+ function viewportBaseline() {
4153
+ reportedView = viewKey(canvas.viewport());
4154
+ }
4155
+ function viewportSettled() {
4156
+ if (wheelTimer !== null) {
4157
+ clearTimeout(wheelTimer);
4158
+ wheelTimer = null;
4159
+ }
4160
+ if (destroyed) return;
4161
+ const now = canvas.viewport();
4162
+ const key = viewKey(now);
4163
+ if (key === reportedView) return;
4164
+ reportedView = key;
4165
+ onViewportChange?.({ ...now });
4166
+ }
4167
+ function zoomTo(scale) {
4168
+ const r = canvas.root.getBoundingClientRect();
4169
+ const centre = canvas.clientToCanvas(r.left + r.width / 2, r.top + r.height / 2);
4170
+ const s = Math.max(ZOOM_STEPS[0], Math.min(scale, ZOOM_STEPS[ZOOM_STEPS.length - 1]));
4171
+ canvas.setViewport({ scale: s, tx: r.width / 2 - centre.x * s, ty: r.height / 2 - centre.y * s });
4172
+ viewportSettled();
4173
+ }
4174
+ function zoomStep(direction) {
4175
+ const s = canvas.scale;
4176
+ const next = direction > 0 ? ZOOM_STEPS.find((z) => z > s + ZOOM_EPSILON) : [...ZOOM_STEPS].reverse().find((z) => z < s - ZOOM_EPSILON);
4177
+ if (next === void 0) return false;
4178
+ zoomTo(next);
4179
+ return true;
4180
+ }
4181
+ function viewportMoved() {
4182
+ if (destroyed) return;
4183
+ if (zoomControls) {
4184
+ const r = refusals();
4185
+ const limit = `${r["zoom-out"] || ""}|${r["zoom-in"] || ""}`;
4186
+ if (limit !== zoomLimit) {
4187
+ zoomLimit = limit;
4188
+ paintToolbar();
4189
+ }
4190
+ }
4191
+ paintMinimap();
4192
+ }
4193
+ if (zoomControls || minimap) canvas.onViewportChange = () => viewportMoved();
4194
+ if (typeof onViewportChange === "function") {
4195
+ canvas.root.addEventListener("pointerdown", () => {
4196
+ viewportSettled();
4197
+ pressEnd?.();
4198
+ const end = () => {
4199
+ pressEnd?.();
4200
+ viewportSettled();
4201
+ };
4202
+ window.addEventListener("pointerup", end, true);
4203
+ window.addEventListener("pointercancel", end, true);
4204
+ pressEnd = () => {
4205
+ window.removeEventListener("pointerup", end, true);
4206
+ window.removeEventListener("pointercancel", end, true);
4207
+ pressEnd = null;
4208
+ };
4209
+ }, true);
4210
+ canvas.root.addEventListener("wheel", () => {
4211
+ if (wheelTimer !== null) clearTimeout(wheelTimer);
4212
+ wheelTimer = setTimeout(() => {
4213
+ wheelTimer = null;
4214
+ viewportSettled();
4215
+ }, WHEEL_SETTLE_MS);
4216
+ }, { passive: true });
4217
+ }
4218
+ const mini = minimap ? el("div", "twm-flow-graph__minimap") : null;
4219
+ const miniSvg = minimap ? document.createElementNS(SVG, "svg") : null;
4220
+ if (mini) {
4221
+ miniSvg.setAttribute("class", "twm-flow-graph__minimap-map");
4222
+ miniSvg.setAttribute("width", String(MINIMAP.width));
4223
+ miniSvg.setAttribute("height", String(MINIMAP.height));
4224
+ mini.title = say(S, "minimapTitle");
4225
+ mini.setAttribute("aria-hidden", "true");
4226
+ mini.appendChild(miniSvg);
4227
+ mini.addEventListener("click", (ev) => {
4228
+ if (!miniMap) return;
4229
+ const r = miniSvg.getBoundingClientRect();
4230
+ const x = (ev.clientX - r.left - miniMap.ox) / miniMap.k;
4231
+ const y = (ev.clientY - r.top - miniMap.oy) / miniMap.k;
4232
+ const view = canvas.root.getBoundingClientRect();
4233
+ canvas.setViewport({ tx: view.width / 2 - x * canvas.scale, ty: view.height / 2 - y * canvas.scale });
4234
+ viewportSettled();
4235
+ });
4236
+ canvasHost.appendChild(mini);
4237
+ }
4238
+ const miniSized = mini && typeof ResizeObserver === "function" ? new ResizeObserver(() => paintMinimap()) : null;
4239
+ miniSized?.observe(canvas.root);
4240
+ function paintMinimap() {
4241
+ if (!mini || destroyed) return;
4242
+ const view = canvas.visibleBox();
4243
+ const boxes = graph.nodes.map((n) => ({
4244
+ x: n.position.x,
4245
+ y: n.position.y,
4246
+ width: BOX.width,
4247
+ height: bottomOf(n) - n.position.y
4248
+ }));
4249
+ const all = view.width > 0 && view.height > 0 ? [...boxes, view] : boxes;
4250
+ if (!all.length) {
4251
+ miniMap = null;
4252
+ miniSvg.replaceChildren();
4253
+ return;
4254
+ }
4255
+ const x0 = Math.min(...all.map((b) => b.x)) - MINIMAP.margin;
4256
+ const y0 = Math.min(...all.map((b) => b.y)) - MINIMAP.margin;
4257
+ const x1 = Math.max(...all.map((b) => b.x + b.width)) + MINIMAP.margin;
4258
+ const y1 = Math.max(...all.map((b) => b.y + b.height)) + MINIMAP.margin;
4259
+ const k = Math.min(MINIMAP.width / (x1 - x0), MINIMAP.height / (y1 - y0));
4260
+ miniMap = { k, ox: (MINIMAP.width - (x1 - x0) * k) / 2 - x0 * k, oy: (MINIMAP.height - (y1 - y0) * k) / 2 - y0 * k };
4261
+ const rect = (b, cls) => {
4262
+ const r = document.createElementNS(SVG, "rect");
4263
+ r.setAttribute("class", cls);
4264
+ r.setAttribute("x", String(b.x * k + miniMap.ox));
4265
+ r.setAttribute("y", String(b.y * k + miniMap.oy));
4266
+ r.setAttribute("width", String(b.width * k));
4267
+ r.setAttribute("height", String(b.height * k));
4268
+ return r;
4269
+ };
4270
+ const parts = boxes.map((b) => rect(b, "twm-flow-graph__minimap-node"));
4271
+ if (view.width > 0 && view.height > 0) parts.push(rect(view, "twm-flow-graph__minimap-view"));
4272
+ miniSvg.replaceChildren(...parts);
4273
+ }
3560
4274
  function paintPalette() {
3561
4275
  palette.replaceChildren(el("h3", "twm-flow-graph__palette-title", say(S, "palette")));
3562
4276
  for (const { category, types } of catalogue.byCategory()) {
@@ -3593,8 +4307,9 @@ function createCanvasEditor(host, options = {}) {
3593
4307
  let where = at;
3594
4308
  if (!where) {
3595
4309
  const box = canvas.visibleBox();
4310
+ const width = nodeWidth(catalogue, { type: typeId }, BOX);
3596
4311
  where = {
3597
- x: canvas.snap((box.width ? box.x + box.width / 2 - BOX.width / 2 : 80) + n % 4 * 24),
4312
+ x: canvas.snap((box.width ? box.x + box.width / 2 - width / 2 : 80) + n % 4 * 24),
3598
4313
  y: canvas.snap((box.height ? box.y + box.height / 3 : 80) + n % 4 * 24)
3599
4314
  };
3600
4315
  }
@@ -3653,7 +4368,8 @@ function createCanvasEditor(host, options = {}) {
3653
4368
  return;
3654
4369
  }
3655
4370
  const p = canvas.clientToCanvas(u.clientX, u.clientY);
3656
- addStep(t.type_id, { x: canvas.snap(p.x - BOX.width / 2), y: canvas.snap(p.y - BOX.header / 2) });
4371
+ const width = nodeWidth(catalogue, { type: t.type_id }, BOX);
4372
+ addStep(t.type_id, { x: canvas.snap(p.x - width / 2), y: canvas.snap(p.y - BOX.header / 2) });
3657
4373
  }
3658
4374
  function onCancel() {
3659
4375
  finish();
@@ -3673,10 +4389,39 @@ function createCanvasEditor(host, options = {}) {
3673
4389
  dragCleanup = finish;
3674
4390
  }
3675
4391
  const anchor = (n, port2, direction) => portAnchor(catalogue, n, port2, direction, BOX);
3676
- const bottomOf = (n) => nodeBottom(catalogue, n, BOX);
4392
+ const widthOf = (n) => nodeWidth(catalogue, n, BOX);
4393
+ const measured = (n) => (bodies.has(n.id) || inlines.has(n.id)) && !catalogue.size?.(n.type)?.h && !BOX.folded(n) ? nodeEls.get(n.id)?.offsetHeight || 0 : 0;
4394
+ const bottomOf = (n) => Math.max(nodeBottom(catalogue, n, BOX), n.position.y + measured(n));
3677
4395
  const isReturn = (c, target) => isReturnEdge(c, target, catalogue, loopPorts.entry);
3678
- function buildNode(nodeRow) {
4396
+ const portKey = (p) => {
4397
+ try {
4398
+ return JSON.stringify(p);
4399
+ } catch {
4400
+ return p;
4401
+ }
4402
+ };
4403
+ function buildKey(nodeRow) {
3679
4404
  const def = typeOf(nodeRow);
4405
+ const own2 = def ? catalogue.body?.(nodeRow.type) : null;
4406
+ return {
4407
+ known: Boolean(def),
4408
+ icon: def?.icon || "",
4409
+ label: def?.label ?? null,
4410
+ tone: catalogue.tone(nodeRow.type),
4411
+ ports: [...catalogue.inputs(nodeRow.type), ...catalogue.outputs(nodeRow.type)].map(portKey),
4412
+ render: own2?.render ?? null,
4413
+ update: own2?.update ?? null,
4414
+ inline: def ? portKey(inlineFields(nodeRow)) : ""
4415
+ };
4416
+ }
4417
+ function inlineFields(nodeRow) {
4418
+ const keys = catalogue.inline?.(nodeRow.type) || [];
4419
+ if (!keys.length) return [];
4420
+ const all = fieldsFromSchema(typeOf(nodeRow)?.config_schema);
4421
+ return keys.map((k) => all.find((f) => f.key === k)).filter(Boolean);
4422
+ }
4423
+ const sameBuild = (a, b) => Boolean(a && b) && a.known === b.known && a.icon === b.icon && a.label === b.label && a.tone === b.tone && a.render === b.render && a.update === b.update && a.inline === b.inline && a.ports.length === b.ports.length && a.ports.every((p, i) => p === b.ports[i]);
4424
+ function buildNode(nodeRow, def) {
3680
4425
  const tone = catalogue.tone(nodeRow.type);
3681
4426
  const n = el("div", "twm-flow-graph__node");
3682
4427
  n.dataset.nodeId = nodeRow.id;
@@ -3690,9 +4435,11 @@ function createCanvasEditor(host, options = {}) {
3690
4435
  n.setAttribute("aria-label", name);
3691
4436
  const title = el("span", "twm-flow-graph__node-title", name);
3692
4437
  title.title = name;
4438
+ if (renameInPlace) title.addEventListener("dblclick", () => startRename(nodeRow.id));
3693
4439
  const tag = el("span", "twm-flow-graph__node-tag", nodeRow.id);
3694
4440
  tag.title = say(S, "stepId", nodeRow.id);
3695
4441
  header.append(chip, title, tag);
4442
+ if (folding) header.appendChild(foldButton(nodeRow.id));
3696
4443
  header.addEventListener("pointerdown", (ev) => startMove(ev, nodeRow.id));
3697
4444
  n.appendChild(header);
3698
4445
  const inputs = catalogue.inputs(nodeRow.type);
@@ -3706,20 +4453,332 @@ function createCanvasEditor(host, options = {}) {
3706
4453
  );
3707
4454
  n.appendChild(row);
3708
4455
  }
4456
+ if (def) mountInline(n, nodeRow);
4457
+ const own2 = def ? catalogue.body?.(nodeRow.type) : null;
4458
+ if (own2) mountBody(n, nodeRow, own2);
3709
4459
  if (!def) n.appendChild(el("p", "twm-flow-graph__node-finding", say(S, "unknownType", nodeRow.type)));
3710
4460
  if (typeof summarise === "function") n.appendChild(el("p", "twm-flow-graph__node-summary"));
3711
4461
  const notes = el("div", "twm-flow-graph__node-findings");
3712
4462
  const runLine = el("p", "twm-flow-graph__node-run");
3713
4463
  n.append(notes, runLine);
4464
+ if (multi) {
4465
+ n.addEventListener("pointerdown", (ev) => {
4466
+ released = null;
4467
+ if (ev.button !== 0 || ev.target.closest(".twm-flow-graph__port")) return;
4468
+ pressed = { id: nodeRow.id, moved: false };
4469
+ }, true);
4470
+ }
3714
4471
  n.addEventListener("click", (ev) => {
3715
4472
  if (ev.target.closest(".twm-flow-graph__port")) return;
4473
+ if (multi) {
4474
+ const press = pressed || released;
4475
+ pressed = released = null;
4476
+ if (toggles(ev)) {
4477
+ togglePick(nodeRow.id);
4478
+ return;
4479
+ }
4480
+ if (press?.id === nodeRow.id && press.moved) return;
4481
+ }
3716
4482
  if (!(selected?.kind === "node" && selected.id === nodeRow.id)) select({ kind: "node", id: nodeRow.id });
3717
4483
  });
3718
4484
  n.addEventListener("focus", () => {
4485
+ if (multi && (pressed?.id === nodeRow.id || isPicked(nodeRow.id))) return;
3719
4486
  if (!(selected?.kind === "node" && selected.id === nodeRow.id)) select({ kind: "node", id: nodeRow.id });
3720
4487
  });
4488
+ if (hoverHighlight) {
4489
+ n.addEventListener("pointerenter", () => hover({ kind: "node", id: nodeRow.id }));
4490
+ n.addEventListener("pointerleave", () => {
4491
+ if (hovered?.kind === "node" && hovered.id === nodeRow.id) hover(null);
4492
+ });
4493
+ }
3721
4494
  return n;
3722
4495
  }
4496
+ function mountInline(n, nodeRow) {
4497
+ const list = inlineFields(nodeRow);
4498
+ if (!list.length) return;
4499
+ const box = el("div", "twm-flow-graph__node-inline");
4500
+ const drawn = { readOnly: isReadOnly(), fields: [] };
4501
+ for (const f of list) {
4502
+ const slot = { key: f.key, spec: f, shown: shownKey(nodeRow.config[f.key]), field: null };
4503
+ slot.field = inlineField(nodeRow.id, slot, nodeRow.config[f.key]);
4504
+ drawn.fields.push(slot);
4505
+ box.appendChild(slot.field.el);
4506
+ }
4507
+ n.appendChild(box);
4508
+ inlines.set(nodeRow.id, drawn);
4509
+ }
4510
+ const shownKey = (v) => v === void 0 ? "" : JSON.stringify(v);
4511
+ function inlineField(id, slot, value) {
4512
+ return renderField(slot.spec, value, {
4513
+ widgets,
4514
+ referenceMap: references,
4515
+ strings: S,
4516
+ services,
4517
+ readOnly: isReadOnly(),
4518
+ step: nodeOf(id),
4519
+ type: typeOf(nodeOf(id)),
4520
+ path: "",
4521
+ set: (key, v) => {
4522
+ const before = slot.shown;
4523
+ slot.shown = shownKey(v);
4524
+ if (!setNodeConfig(id, { [key]: v }, "inline")) slot.shown = before;
4525
+ }
4526
+ });
4527
+ }
4528
+ function paintInline(nodeRow) {
4529
+ const drawn = inlines.get(nodeRow.id);
4530
+ if (!drawn) return;
4531
+ const roNow = isReadOnly();
4532
+ const roChanged = drawn.readOnly !== roNow;
4533
+ drawn.readOnly = roNow;
4534
+ for (const slot of drawn.fields) {
4535
+ const value = nodeRow.config[slot.key];
4536
+ const shown = shownKey(value);
4537
+ if (!roChanged && shown === slot.shown) continue;
4538
+ const held = heldCaret(slot.field);
4539
+ slot.field.destroy();
4540
+ const fresh = inlineField(nodeRow.id, slot, value);
4541
+ slot.field.el.replaceWith(fresh.el);
4542
+ slot.field = fresh;
4543
+ slot.shown = shown;
4544
+ if (held) restoreCaret(fresh, held.caret);
4545
+ }
4546
+ }
4547
+ function heldCaret(field) {
4548
+ const active = document.activeElement;
4549
+ if (!active || !field.el.contains(active)) return null;
4550
+ let caret = field.caret?.() ?? null;
4551
+ if (!caret) {
4552
+ try {
4553
+ if (Number.isInteger(active.selectionStart)) {
4554
+ caret = { start: active.selectionStart, end: active.selectionEnd, direction: active.selectionDirection };
4555
+ }
4556
+ } catch {
4557
+ }
4558
+ }
4559
+ return { caret };
4560
+ }
4561
+ function restoreCaret(field, caret) {
4562
+ if (!field.focus() || !caret) return;
4563
+ if (field.caret?.()) {
4564
+ field.restoreCaret(caret);
4565
+ return;
4566
+ }
4567
+ const box = document.activeElement;
4568
+ try {
4569
+ const end = box.value.length;
4570
+ box.setSelectionRange(Math.min(caret.start, end), Math.min(caret.end, end), caret.direction || "none");
4571
+ } catch {
4572
+ }
4573
+ }
4574
+ function dropInline(id) {
4575
+ inlines.get(id)?.fields.forEach((slot) => slot.field.destroy());
4576
+ inlines.delete(id);
4577
+ }
4578
+ const shownName = (nodeRow) => nodeRow.label || typeOf(nodeRow)?.label || nodeRow.type;
4579
+ function renameNode(id, text) {
4580
+ const nodeRow = nodeOf(id);
4581
+ if (!nodeRow) return;
4582
+ if (text) nodeRow.label = text;
4583
+ else delete nodeRow.label;
4584
+ const n = nodeEls.get(nodeRow.id);
4585
+ const name = shownName(nodeRow);
4586
+ const title = n?.querySelector(".twm-flow-graph__node-title");
4587
+ if (title) title.textContent = title.title = name;
4588
+ if (n) {
4589
+ n.dataset.shape = `${nodeRow.type}|${nodeRow.label || ""}`;
4590
+ n.setAttribute("aria-label", name);
4591
+ }
4592
+ commit("flow:node:label", `label:${id}`);
4593
+ }
4594
+ function startRename(id) {
4595
+ if (!renameInPlace || isReadOnly() || destroyed) return false;
4596
+ renaming?.finish(false);
4597
+ const nodeRow = nodeOf(id);
4598
+ const n = nodeEls.get(id);
4599
+ const title = n?.querySelector(".twm-flow-graph__node-title");
4600
+ if (!nodeRow || !title) return false;
4601
+ const before = shownName(nodeRow);
4602
+ const input = el("input", "twm-flow-graph__node-title-input");
4603
+ input.type = "text";
4604
+ input.value = before;
4605
+ input.setAttribute("aria-label", say(S, "rename"));
4606
+ const edit = { id };
4607
+ let done = false;
4608
+ edit.finish = (keep, { enter = false } = {}) => {
4609
+ if (done) return;
4610
+ const text = input.value.trim();
4611
+ if (keep && !text) {
4612
+ setMessage(say(S, "nameEmpty"), "warning");
4613
+ input.setAttribute("aria-invalid", "true");
4614
+ if (enter && input.isConnected) return;
4615
+ } else if (input.getAttribute("aria-invalid")) setMessage("");
4616
+ done = true;
4617
+ if (renaming === edit) renaming = null;
4618
+ const hadFocus = input === document.activeElement;
4619
+ input.remove();
4620
+ title.hidden = false;
4621
+ if (keep && text && text !== before && nodeOf(id)) {
4622
+ renameNode(id, text);
4623
+ if (selected?.kind === "node" && selected.id === id) paintPanel();
4624
+ }
4625
+ if (hadFocus && n.isConnected) n.focus({ preventScroll: true });
4626
+ };
4627
+ input.addEventListener("keydown", (ev) => {
4628
+ if (ev.isComposing) return;
4629
+ if (ev.key === "Enter") {
4630
+ ev.preventDefault();
4631
+ edit.finish(true, { enter: true });
4632
+ } else if (ev.key === "Escape") {
4633
+ ev.preventDefault();
4634
+ ev.stopPropagation();
4635
+ edit.finish(false);
4636
+ }
4637
+ });
4638
+ input.addEventListener("blur", () => edit.finish(true));
4639
+ input.addEventListener("pointerdown", (ev) => ev.stopPropagation());
4640
+ title.hidden = true;
4641
+ title.after(input);
4642
+ renaming = edit;
4643
+ input.focus({ preventScroll: true });
4644
+ input.select();
4645
+ return true;
4646
+ }
4647
+ const nodeCopy = (nodeRow) => normalise({ nodes: [nodeRow], connections: [] }).nodes[0];
4648
+ function mountBody(n, nodeRow, own2) {
4649
+ const id = nodeRow.id;
4650
+ const box = el("div", "twm-flow-graph__node-body");
4651
+ const ctx = Object.freeze({
4652
+ get readOnly() {
4653
+ return isReadOnly();
4654
+ },
4655
+ /** What the host shows on this node now (`setNodeValues`), a copy; null when nothing. */
4656
+ get values() {
4657
+ return valuesCopy(id);
4658
+ },
4659
+ services,
4660
+ setConfig: (patch) => setNodeConfig(id, patch)
4661
+ });
4662
+ try {
4663
+ own2.render(box, nodeCopy(nodeRow), ctx);
4664
+ } catch (err) {
4665
+ bodyFailed(nodeRow, "render", err);
4666
+ return;
4667
+ }
4668
+ n.appendChild(box);
4669
+ bodies.set(id, { el: box, ctx, update: own2.update });
4670
+ }
4671
+ function bodyFailed(nodeRow, stage, err) {
4672
+ const name = nodeRow.label || typeOf(nodeRow)?.label || nodeRow.type;
4673
+ const why2 = err && typeof err === "object" && "message" in err ? err.message : err;
4674
+ bodyFindings.set(nodeRow.id, Object.freeze({
4675
+ code: `node-${stage}`,
4676
+ severity: "error",
4677
+ node_id: nodeRow.id,
4678
+ message: say(S, stage === "render" ? "renderFailed" : "updateFailed", name, String(why2 ?? ""))
4679
+ }));
4680
+ }
4681
+ function setNodeConfig(id, patch, from = "config") {
4682
+ if (destroyed || painting || isReadOnly()) return false;
4683
+ const nodeRow = nodeOf(id);
4684
+ if (!nodeRow || !patch || typeof patch !== "object" || Array.isArray(patch)) return false;
4685
+ const keys = Object.keys(patch).sort();
4686
+ if (!keys.length) return false;
4687
+ let next;
4688
+ try {
4689
+ next = structuredClone(patch);
4690
+ } catch {
4691
+ return false;
4692
+ }
4693
+ for (const k of keys) {
4694
+ if (next[k] === void 0) delete nodeRow.config[k];
4695
+ else nodeRow.config[k] = next[k];
4696
+ }
4697
+ paintNodes();
4698
+ if (selected?.kind === "node" && selected.id === id) paintPanel();
4699
+ commit("flow:node:config", `${from}:${id}:${keys.join(",")}`);
4700
+ return true;
4701
+ }
4702
+ function foldButton(id) {
4703
+ const b = el("button", "twm-flow-graph__node-fold");
4704
+ b.type = "button";
4705
+ b.appendChild(icon("expand_less"));
4706
+ b.addEventListener("pointerdown", (ev) => ev.stopPropagation());
4707
+ b.addEventListener("click", (ev) => {
4708
+ ev.stopPropagation();
4709
+ const nodeRow = nodeOf(id);
4710
+ if (nodeRow) setCollapsed(id, nodeRow.collapsed !== true);
4711
+ });
4712
+ return b;
4713
+ }
4714
+ function setCollapsed(id, collapsed) {
4715
+ if (!folding || destroyed || isReadOnly()) return false;
4716
+ const nodeRow = nodeOf(id);
4717
+ if (!nodeRow || nodeRow.collapsed === true === Boolean(collapsed)) return false;
4718
+ if (collapsed) nodeRow.collapsed = true;
4719
+ else delete nodeRow.collapsed;
4720
+ if (connecting?.source === id) cancelConnect();
4721
+ paintNodes();
4722
+ commit("flow:node:collapse");
4723
+ return true;
4724
+ }
4725
+ function valueEntry(v) {
4726
+ if (!v || typeof v !== "object") return null;
4727
+ const text = (x) => x === void 0 || x === null || x === "" ? null : String(x);
4728
+ const ports = {};
4729
+ for (const [name, x] of Object.entries(plainObject(v.ports))) {
4730
+ const shown = text(x);
4731
+ if (shown !== null) ports[name] = shown;
4732
+ }
4733
+ const badge = text(v.badge);
4734
+ if (badge === null && !Object.keys(ports).length) return null;
4735
+ return Object.freeze({ badge, tone: TONES.includes(v.tone) ? v.tone : null, ports: Object.freeze(ports) });
4736
+ }
4737
+ const valuesCopy = (id) => {
4738
+ const v = liveValues.get(id);
4739
+ return v ? { badge: v.badge, tone: v.tone, ports: { ...v.ports } } : null;
4740
+ };
4741
+ function paintValues(n, nodeRow) {
4742
+ const v = liveValues.get(nodeRow.id) || null;
4743
+ const header = n.querySelector(".twm-flow-graph__node-head");
4744
+ let badge = header.querySelector(".twm-flow-graph__node-badge");
4745
+ if (v?.badge !== null && v?.badge !== void 0) {
4746
+ if (!badge) {
4747
+ badge = el("span", "twm-flow-graph__node-badge");
4748
+ header.insertBefore(badge, header.querySelector(".twm-flow-graph__node-tag"));
4749
+ }
4750
+ if (badge.textContent !== v.badge) badge.textContent = badge.title = v.badge;
4751
+ if (v.tone) badge.dataset.tone = v.tone;
4752
+ else delete badge.dataset.tone;
4753
+ } else badge?.remove();
4754
+ for (const b of n.querySelectorAll(".twm-flow-graph__port")) {
4755
+ const shown = v && Object.hasOwn(v.ports, b.dataset.port) ? v.ports[b.dataset.port] : null;
4756
+ let tag = b.querySelector(".twm-flow-graph__port-value");
4757
+ if (shown === null) {
4758
+ tag?.remove();
4759
+ continue;
4760
+ }
4761
+ if (!tag) {
4762
+ tag = el("span", "twm-flow-graph__port-value");
4763
+ b.insertBefore(tag, b.querySelector(".twm-flow-graph__port-count"));
4764
+ }
4765
+ if (tag.textContent !== shown) tag.textContent = shown;
4766
+ }
4767
+ }
4768
+ function runUpdate(nodeRow) {
4769
+ const own2 = bodies.get(nodeRow.id);
4770
+ if (typeof own2?.update !== "function") return false;
4771
+ try {
4772
+ own2.update(own2.el, nodeCopy(nodeRow), own2.ctx);
4773
+ if (bodyFindings.get(nodeRow.id)?.code !== "node-update") return false;
4774
+ bodyFindings.delete(nodeRow.id);
4775
+ return true;
4776
+ } catch (err) {
4777
+ const before = bodyFindings.get(nodeRow.id)?.message;
4778
+ bodyFailed(nodeRow, "update", err);
4779
+ return before !== bodyFindings.get(nodeRow.id).message;
4780
+ }
4781
+ }
3723
4782
  function port(nodeRow, p) {
3724
4783
  const b = el("button", `twm-flow-graph__port twm-flow-graph__port--${p.direction === "input" ? "in" : "out"}`);
3725
4784
  b.type = "button";
@@ -3730,23 +4789,29 @@ function createCanvasEditor(host, options = {}) {
3730
4789
  el("span", "twm-flow-graph__port-label", p.label || p.name),
3731
4790
  el("span", "twm-flow-graph__port-count")
3732
4791
  );
3733
- if ((p.port_type || "FLOW") !== "FLOW") {
4792
+ const isData = (p.port_type || "FLOW") !== "FLOW";
4793
+ if (isData) {
3734
4794
  b.classList.add("twm-flow-graph__port--data");
3735
- b.disabled = true;
3736
- b.dataset.data = "";
3737
- b.title = say(S, "dataPort", dataPortRead ? dataPortRead(nodeRow, p) : null);
3738
- return b;
4795
+ if (!dataConnect) {
4796
+ b.disabled = true;
4797
+ b.dataset.data = "";
4798
+ b.title = say(S, "dataPort", dataPortRead ? dataPortRead(nodeRow, p) : null);
4799
+ return b;
4800
+ }
3739
4801
  }
3740
4802
  b.title = p.direction === "output" ? say(S, "connectFrom") : say(S, "connectTo");
3741
4803
  b.addEventListener("click", (ev) => {
3742
4804
  ev.stopPropagation();
4805
+ if (linking && suppressClick) return;
3743
4806
  portClicked(nodeRow.id, p);
3744
4807
  });
4808
+ if (linking && p.direction === "output") b.addEventListener("pointerdown", (ev) => startConnectDrag(ev, nodeRow.id, p));
3745
4809
  return b;
3746
4810
  }
3747
4811
  function portClicked(nodeId, p) {
3748
4812
  if (isReadOnly()) return;
3749
4813
  if (p.direction === "output") {
4814
+ if (pendingOffer) clearOffer();
3750
4815
  connecting = { source: nodeId, sourcePort: p.name };
3751
4816
  root.classList.add("twm-flow-graph--connecting");
3752
4817
  paintNodes();
@@ -3758,6 +4823,11 @@ function createCanvasEditor(host, options = {}) {
3758
4823
  return;
3759
4824
  }
3760
4825
  const edge = { ...connecting, target: nodeId, targetPort: p.name };
4826
+ if (verdictOf) {
4827
+ cancelConnect();
4828
+ linkTo(edge);
4829
+ return;
4830
+ }
3761
4831
  const verdict = connect(graph, catalogue, edge, { strings: S });
3762
4832
  cancelConnect();
3763
4833
  if (!verdict.ok) {
@@ -3773,15 +4843,244 @@ function createCanvasEditor(host, options = {}) {
3773
4843
  root.classList.remove("twm-flow-graph--connecting");
3774
4844
  paintNodes();
3775
4845
  }
4846
+ function linkTo(edge) {
4847
+ const verdict = verdictOf(graph, edge);
4848
+ if (!verdict.ok) {
4849
+ setMessage(verdict.reason, "warning");
4850
+ return;
4851
+ }
4852
+ const build = () => addConnection(graph, edge);
4853
+ if (verdict.offer) {
4854
+ showOffer(verdict.offer, build, "flow:edge:connect");
4855
+ return;
4856
+ }
4857
+ build();
4858
+ setMessage(isReturn(edge, nodeOf(edge.target)) ? say(S, "connectedReturn") : "");
4859
+ paintEdges();
4860
+ commit("flow:edge:connect");
4861
+ }
4862
+ function showOffer(offer, build, action, after = null) {
4863
+ const words3 = String(offer?.message ?? "");
4864
+ pendingOffer = { offer, build, action, after };
4865
+ const accept = button({
4866
+ label: offer?.accept ? String(offer.accept) : say(S, "offerAccept"),
4867
+ primary: true,
4868
+ className: "twm-flow-graph__offer-accept",
4869
+ onClick: () => acceptOffer()
4870
+ });
4871
+ accept.dataset.action = "accept-offer";
4872
+ const dismiss = button({
4873
+ label: say(S, "offerDismiss"),
4874
+ className: "twm-flow-graph__offer-dismiss",
4875
+ onClick: () => {
4876
+ clearOffer();
4877
+ keepFocus();
4878
+ }
4879
+ });
4880
+ dismiss.dataset.action = "dismiss-offer";
4881
+ offerBox.replaceChildren(el("p", "twm-flow-graph__offer-text", words3), accept, dismiss);
4882
+ offerBox.hidden = false;
4883
+ setMessage("");
4884
+ }
4885
+ function clearOffer() {
4886
+ pendingOffer = null;
4887
+ if (!offerBox) return;
4888
+ offerBox.hidden = true;
4889
+ offerBox.replaceChildren();
4890
+ }
4891
+ function acceptOffer() {
4892
+ const held = pendingOffer;
4893
+ if (!held || destroyed) return;
4894
+ clearOffer();
4895
+ if (isReadOnly()) return;
4896
+ const made = held.build();
4897
+ if (typeof held.offer?.apply === "function") {
4898
+ const draft = normalise(graph);
4899
+ const back = held.offer.apply(draft);
4900
+ graph = normalise(back && typeof back === "object" ? back : draft);
4901
+ }
4902
+ held.after?.(made);
4903
+ prunePick();
4904
+ findingsBy = groupFindings(allFindings(), { graph });
4905
+ paintNodes();
4906
+ paintPanel();
4907
+ announceSelection();
4908
+ commit(held.action);
4909
+ keepFocus();
4910
+ }
4911
+ function startConnectDrag(ev, nodeId, p) {
4912
+ if (ev.button !== 0 || ev.isPrimary === false || isReadOnly()) return;
4913
+ dragCleanup?.();
4914
+ const start = { x: ev.clientX, y: ev.clientY };
4915
+ let draft = null;
4916
+ const onMove = (m) => {
4917
+ if (!draft) {
4918
+ if (Math.abs(m.clientX - start.x) + Math.abs(m.clientY - start.y) < DRAG_PX) return;
4919
+ if (pendingOffer) clearOffer();
4920
+ connecting = { source: nodeId, sourcePort: p.name };
4921
+ root.classList.add("twm-flow-graph--connecting");
4922
+ paintNodes();
4923
+ setMessage(say(S, "connectDrag"));
4924
+ draft = document.createElementNS(SVG, "path");
4925
+ draft.setAttribute("class", "twm-flow-graph__edge twm-flow-graph__edge--draft");
4926
+ }
4927
+ const from = nodeOf(nodeId);
4928
+ if (!from) return;
4929
+ if (!draft.isConnected) canvas.edgeLayer.appendChild(draft);
4930
+ draft.setAttribute("d", routeEdge(
4931
+ anchor(from, p.name, "output"),
4932
+ canvas.clientToCanvas(m.clientX, m.clientY),
4933
+ { simple: true }
4934
+ ));
4935
+ };
4936
+ const finish = () => {
4937
+ window.removeEventListener("pointermove", onMove);
4938
+ window.removeEventListener("pointerup", onUp);
4939
+ window.removeEventListener("pointercancel", onCancel);
4940
+ window.removeEventListener("keydown", onKey, true);
4941
+ draft?.remove();
4942
+ dragCleanup = null;
4943
+ };
4944
+ function onUp(u) {
4945
+ const dragged = Boolean(draft);
4946
+ finish();
4947
+ if (!dragged) return;
4948
+ swallowNextClick({ afterRelease: true });
4949
+ dropConnection(u);
4950
+ }
4951
+ function onCancel() {
4952
+ const dragged = Boolean(draft);
4953
+ finish();
4954
+ if (dragged) {
4955
+ cancelConnect();
4956
+ setMessage("");
4957
+ }
4958
+ }
4959
+ function onKey(k) {
4960
+ if (k.key !== "Escape") return;
4961
+ k.preventDefault();
4962
+ k.stopPropagation();
4963
+ const dragged = Boolean(draft);
4964
+ finish();
4965
+ if (!dragged) return;
4966
+ cancelConnect();
4967
+ setMessage("");
4968
+ swallowNextClick();
4969
+ }
4970
+ window.addEventListener("pointermove", onMove);
4971
+ window.addEventListener("pointerup", onUp);
4972
+ window.addEventListener("pointercancel", onCancel);
4973
+ window.addEventListener("keydown", onKey, true);
4974
+ dragCleanup = finish;
4975
+ }
4976
+ function dropConnection(u) {
4977
+ const from = connecting;
4978
+ const under = document.elementFromPoint(u.clientX, u.clientY);
4979
+ const nodeHit = under?.closest("[data-node-id]");
4980
+ const onNode = Boolean(nodeHit && canvas.nodeLayer.contains(nodeHit));
4981
+ const portHit = onNode ? under.closest(".twm-flow-graph__port") : null;
4982
+ if (portHit && portHit.dataset.direction === "input") {
4983
+ portClicked(nodeHit.dataset.nodeId, { direction: "input", name: portHit.dataset.port });
4984
+ return;
4985
+ }
4986
+ cancelConnect();
4987
+ if (onNode) {
4988
+ setMessage(say(S, "dropOnInput"));
4989
+ return;
4990
+ }
4991
+ if (under && canvas.root.contains(under) || overCanvas(u.clientX, u.clientY)) {
4992
+ openDropPicker(from, u);
4993
+ return;
4994
+ }
4995
+ setMessage("");
4996
+ }
4997
+ function openDropPicker(from, u) {
4998
+ const p = canvas.clientToCanvas(u.clientX, u.clientY);
4999
+ const where = { x: canvas.snap(p.x), y: canvas.snap(p.y - BOX.header / 2) };
5000
+ const fits = /* @__PURE__ */ new Map();
5001
+ for (const t of catalogue.list()) {
5002
+ if (t.unavailable || !offered(t)) continue;
5003
+ const trial = normalise(graph);
5004
+ const added = addNode(trial, t.type_id, where, { catalogue });
5005
+ let pick = null;
5006
+ for (const inp of catalogue.inputs(t.type_id)) {
5007
+ const v = verdictOf(trial, { ...from, target: added.id, targetPort: inp.name });
5008
+ if (v.ok && (!pick || pick.offer && !v.offer)) pick = { port: inp.name, offer: v.offer || null };
5009
+ if (pick && !pick.offer) break;
5010
+ }
5011
+ if (pick) fits.set(t.type_id, pick);
5012
+ }
5013
+ if (!fits.size) {
5014
+ setMessage(say(S, "dropNothingFits"), "warning");
5015
+ return;
5016
+ }
5017
+ const mark = el("span", "twm-flow-graph__drop-anchor");
5018
+ const box = canvasHost.getBoundingClientRect();
5019
+ mark.style.left = `${Math.round(u.clientX - box.left)}px`;
5020
+ mark.style.top = `${Math.round(u.clientY - box.top)}px`;
5021
+ canvasHost.appendChild(mark);
5022
+ const asked = snapshot();
5023
+ openStepPicker({
5024
+ anchor: mark,
5025
+ strings: S,
5026
+ title: say(S, "dropPickerTitle"),
5027
+ categories: catalogue.categories.map((c) => ({ id: c.id, label: c.label, layout: c.layout })),
5028
+ entries: catalogue.list().filter((t) => fits.has(t.type_id)).map((t) => ({
5029
+ id: t.type_id,
5030
+ label: t.label,
5031
+ description: t.description,
5032
+ icon: t.icon || "widgets",
5033
+ tone: catalogue.tone(t.type_id),
5034
+ category: t.category ?? ""
5035
+ }))
5036
+ }).then((choice) => {
5037
+ mark.remove();
5038
+ if (destroyed) return;
5039
+ if (!choice?.entry || isReadOnly() || snapshot() !== asked) {
5040
+ keepFocus();
5041
+ return;
5042
+ }
5043
+ const fitting = fits.get(choice.entry.id);
5044
+ const build = () => {
5045
+ const added = addNode(graph, choice.entry.id, where, { catalogue });
5046
+ addConnection(graph, { ...from, target: added.id, targetPort: fitting.port });
5047
+ return added.id;
5048
+ };
5049
+ const after = (id) => {
5050
+ if (nodeOf(id)) selected = { kind: "node", id };
5051
+ };
5052
+ if (fitting.offer) {
5053
+ showOffer(fitting.offer, build, "flow:node:add", after);
5054
+ keepFocus();
5055
+ return;
5056
+ }
5057
+ after(build());
5058
+ paintNodes();
5059
+ paintPanel();
5060
+ announceSelection();
5061
+ commit("flow:node:add");
5062
+ keepFocus();
5063
+ });
5064
+ }
3776
5065
  function startMove(ev, nodeId) {
3777
5066
  if (ev.button !== 0) return;
3778
5067
  ev.stopPropagation();
3779
- select({ kind: "node", id: nodeId });
5068
+ if (toggles(ev)) return;
5069
+ if (!(multi && isPicked(nodeId))) select({ kind: "node", id: nodeId });
3780
5070
  if (isReadOnly()) return;
3781
- const nodeRow = nodeOf(nodeId);
3782
- const n = nodeEls.get(nodeId);
3783
- if (!nodeRow || !n) return;
3784
- const from = { ...nodeRow.position };
5071
+ const ids = selected?.kind === "many" ? selected.ids : [nodeId];
5072
+ if (!ids.includes(nodeId)) return;
5073
+ dragItems(ev, ids, pickedNotes(), () => {
5074
+ if (pressed?.id === nodeId) pressed.moved = true;
5075
+ });
5076
+ }
5077
+ function dragItems(ev, nodeIds, noteIds, onFirstMove = null) {
5078
+ const rows = [
5079
+ ...nodeIds.map((id) => ({ item: nodeOf(id), n: nodeEls.get(id) })),
5080
+ ...noteIds.map((id) => ({ item: noteOf(id), n: noteEls.get(id), note: true }))
5081
+ ].filter((r) => r.item && r.n);
5082
+ if (!rows.length) return;
5083
+ const from = rows.map((r) => ({ ...r.item.position }));
3785
5084
  const start = { x: ev.clientX, y: ev.clientY };
3786
5085
  let moved = false;
3787
5086
  const onMove = (m) => {
@@ -3789,16 +5088,21 @@ function createCanvasEditor(host, options = {}) {
3789
5088
  const dy = (m.clientY - start.y) / canvas.scale;
3790
5089
  if (!moved && Math.abs(dx) + Math.abs(dy) < 3) return;
3791
5090
  moved = true;
3792
- nodeRow.position = { x: canvas.snap(from.x + dx), y: canvas.snap(from.y + dy) };
3793
- n.style.left = `${nodeRow.position.x}px`;
3794
- n.style.top = `${nodeRow.position.y}px`;
5091
+ onFirstMove?.();
5092
+ rows.forEach(({ item, n }, i) => {
5093
+ item.position = { x: canvas.snap(from[i].x + dx), y: canvas.snap(from[i].y + dy) };
5094
+ n.style.left = `${item.position.x}px`;
5095
+ n.style.top = `${item.position.y}px`;
5096
+ });
3795
5097
  paintEdges();
3796
5098
  };
3797
5099
  const onUp = () => {
3798
5100
  window.removeEventListener("pointermove", onMove);
3799
5101
  window.removeEventListener("pointerup", onUp);
3800
5102
  window.removeEventListener("pointercancel", onUp);
3801
- if (moved) commit("flow:node:move");
5103
+ if (!moved) return;
5104
+ const nodes = rows.filter((r) => !r.note).length;
5105
+ commit(!nodes ? "flow:note:move" : rows.length > 1 ? "flow:nodes:move" : "flow:node:move");
3802
5106
  };
3803
5107
  window.addEventListener("pointermove", onMove);
3804
5108
  window.addEventListener("pointerup", onUp);
@@ -3806,69 +5110,187 @@ function createCanvasEditor(host, options = {}) {
3806
5110
  }
3807
5111
  function paintNodes() {
3808
5112
  if (destroyed) return;
3809
- const live = new Set(graph.nodes.map((x) => x.id));
3810
- for (const [id, n] of nodeEls) {
3811
- if (!live.has(id)) {
3812
- n.remove();
3813
- nodeEls.delete(id);
3814
- }
3815
- }
3816
- for (const nodeRow of graph.nodes) {
3817
- let n = nodeEls.get(nodeRow.id);
3818
- const shape = `${nodeRow.type}|${nodeRow.label || ""}`;
3819
- if (!n || n.dataset.shape !== shape) {
3820
- const fresh = buildNode(nodeRow);
3821
- fresh.dataset.shape = shape;
3822
- const focused = n && n.contains(document.activeElement);
3823
- if (n) n.replaceWith(fresh);
3824
- else canvas.nodeLayer.appendChild(fresh);
3825
- nodeEls.set(nodeRow.id, fresh);
3826
- n = fresh;
3827
- if (focused) fresh.focus({ preventScroll: true });
3828
- }
3829
- n.style.left = `${nodeRow.position.x}px`;
3830
- n.style.top = `${nodeRow.position.y}px`;
3831
- n.style.width = `${BOX.width}px`;
3832
- const picked = selected?.kind === "node" && selected.id === nodeRow.id;
3833
- n.classList.toggle("twm-flow-graph__node--picked", picked);
3834
- if (picked) n.setAttribute("aria-current", "true");
3835
- else n.removeAttribute("aria-current");
3836
- n.classList.toggle("twm-flow-graph__node--connect-source", connecting?.source === nodeRow.id);
3837
- const mine = findingsBy.get(nodeRow.id) || [];
3838
- const errors = mine.filter((f) => f.severity !== "warning");
3839
- n.classList.toggle("twm-flow-graph__node--error", errors.length > 0);
3840
- n.classList.toggle("twm-flow-graph__node--warning", errors.length === 0 && mine.length > 0);
3841
- n.querySelector(".twm-flow-graph__node-findings").replaceChildren(...mine.slice(0, 2).map((f) => el(
3842
- "p",
3843
- `twm-flow-graph__node-finding${f.severity === "warning" ? " twm-flow-graph__node-finding--warning" : ""}`,
3844
- String(f.message ?? "")
3845
- )));
3846
- const summary = n.querySelector(".twm-flow-graph__node-summary");
3847
- if (summary) summary.textContent = summarise(nodeRow, typeOf(nodeRow)) || "";
3848
- const step = overlay?.steps?.[nodeRow.id] || null;
3849
- const line = n.querySelector(".twm-flow-graph__node-run");
3850
- line.textContent = step ? String(step.line ?? step.state ?? "") : "";
3851
- if (step?.state) line.dataset.state = String(step.state);
3852
- else delete line.dataset.state;
3853
- if (step?.tone) line.dataset.tone = String(step.tone);
3854
- else delete line.dataset.tone;
3855
- n.classList.toggle("twm-flow-graph__node--skipped", step?.state === "skipped" || step?.state === "not-reached");
3856
- const ports = overlay?.ports?.[nodeRow.id] || null;
3857
- for (const b of n.querySelectorAll(".twm-flow-graph__port")) {
3858
- if (!("data" in b.dataset)) b.disabled = isReadOnly();
3859
- const mark = b.dataset.direction === "output" ? ports?.[b.dataset.port] : null;
3860
- b.classList.toggle("twm-flow-graph__port--taken", Boolean(mark?.taken));
3861
- b.classList.toggle("twm-flow-graph__port--untaken", Boolean(mark) && !mark.taken);
3862
- const count2 = b.querySelector(".twm-flow-graph__port-count");
3863
- count2.textContent = mark?.taken && Number.isFinite(Number(mark.count)) && mark.count !== null ? say(S, "portCount", mark.count) : mark && !mark.taken ? say(S, "portNotTaken") : "";
5113
+ painting = true;
5114
+ let bodyChanged = false;
5115
+ try {
5116
+ const live = new Set(graph.nodes.map((x) => x.id));
5117
+ for (const [id, n] of nodeEls) {
5118
+ if (!live.has(id)) {
5119
+ n.remove();
5120
+ nodeEls.delete(id);
5121
+ builtFrom.delete(id);
5122
+ bodies.delete(id);
5123
+ dropInline(id);
5124
+ if (renaming?.id === id) renaming.finish(false);
5125
+ bodyChanged = bodyFindings.delete(id) || bodyChanged;
5126
+ }
3864
5127
  }
5128
+ if (bodyChanged) findingsBy = groupFindings(allFindings(), { graph });
5129
+ const picks = pickedSet();
5130
+ for (const nodeRow of graph.nodes) bodyChanged = paintNode(nodeRow, picks) || bodyChanged;
5131
+ paintNotes();
5132
+ } finally {
5133
+ painting = false;
5134
+ }
5135
+ if (bodyChanged) {
5136
+ paintStrip();
5137
+ panel.setFindings(allFindings());
3865
5138
  }
3866
5139
  paintEdges();
3867
5140
  }
5141
+ function edgeLook(c, source, target, back) {
5142
+ const look = edgeStyle({ ...c }, { source, target, back });
5143
+ if (!look || typeof look !== "object") return null;
5144
+ const width = Number(look.width);
5145
+ return {
5146
+ tone: TONES.includes(look.tone) ? look.tone : null,
5147
+ dashed: Boolean(look.dashed),
5148
+ width: Number.isFinite(width) && width > 0 ? width : null,
5149
+ marker: typeof look.marker === "string" && look.marker ? look.marker : null,
5150
+ label: look.label === void 0 || look.label === null || look.label === "" ? null : String(look.label)
5151
+ };
5152
+ }
5153
+ function applyLook(element, look) {
5154
+ if (look.tone) element.style.setProperty("--twm-canvas-edge-stroke", `var(--twm-flow-${look.tone}-fg)`);
5155
+ if (look.width) element.style.setProperty("--twm-canvas-edge-width", String(look.width));
5156
+ }
5157
+ function arrowHead(tip, side) {
5158
+ const out = side === "right" ? 1 : -1;
5159
+ const baseX = tip.x + out * ARROW.length;
5160
+ const head = document.createElementNS(SVG, "path");
5161
+ head.setAttribute("class", "twm-flow-graph__edge-arrow");
5162
+ head.setAttribute("d", `M ${tip.x} ${tip.y} L ${baseX} ${tip.y - ARROW.half} L ${baseX} ${tip.y + ARROW.half} Z`);
5163
+ return head;
5164
+ }
5165
+ function lineEnd(to, marker) {
5166
+ if (marker === "arrow") return { ...to, x: to.x + (to.side === "right" ? 1 : -1) * ARROW.length };
5167
+ return marker ? footAnchor(to, to.side, marker) : to;
5168
+ }
5169
+ const touches = (c) => hovered?.kind === "node" ? c.source === hovered.id || c.target === hovered.id : hovered?.kind === "edge" ? sameEdge(c, hovered.edge) : false;
5170
+ function hover(next) {
5171
+ hovered = next;
5172
+ for (const { c, line } of edgeEls) line.classList.toggle("twm-flow-graph__edge--highlighted", touches(c));
5173
+ }
5174
+ function paintStrip() {
5175
+ strip.set(routingFallback ? [...allFindings(), {
5176
+ code: "canvas-routing-simple",
5177
+ severity: "warning",
5178
+ message: say(S, "routingFallback", OBSTACLE_ROUTING_NODE_LIMIT)
5179
+ }] : allFindings());
5180
+ }
5181
+ function paintNode(nodeRow, picks) {
5182
+ let bodyChanged = false;
5183
+ let n = nodeEls.get(nodeRow.id);
5184
+ const def = typeOf(nodeRow);
5185
+ const shape = `${nodeRow.type}|${nodeRow.label || ""}`;
5186
+ const key = buildKey(nodeRow);
5187
+ if (!n || n.dataset.shape !== shape || !sameBuild(builtFrom.get(nodeRow.id), key)) {
5188
+ if (renaming?.id === nodeRow.id) renaming.finish(false);
5189
+ bodies.delete(nodeRow.id);
5190
+ dropInline(nodeRow.id);
5191
+ bodyChanged = bodyFindings.delete(nodeRow.id);
5192
+ const fresh = buildNode(nodeRow, def);
5193
+ bodyChanged = bodyChanged || bodyFindings.has(nodeRow.id);
5194
+ fresh.dataset.shape = shape;
5195
+ const focused = n && n.contains(document.activeElement);
5196
+ if (n) n.replaceWith(fresh);
5197
+ else canvas.nodeLayer.appendChild(fresh);
5198
+ nodeEls.set(nodeRow.id, fresh);
5199
+ builtFrom.set(nodeRow.id, key);
5200
+ n = fresh;
5201
+ if (focused) fresh.focus({ preventScroll: true });
5202
+ } else paintInline(nodeRow);
5203
+ bodyChanged = runUpdate(nodeRow) || bodyChanged;
5204
+ if (bodyChanged) findingsBy = groupFindings(allFindings(), { graph });
5205
+ n.style.left = `${nodeRow.position.x}px`;
5206
+ n.style.top = `${nodeRow.position.y}px`;
5207
+ n.style.width = `${widthOf(nodeRow)}px`;
5208
+ const folded = BOX.folded(nodeRow);
5209
+ const h = folded ? null : catalogue.size?.(nodeRow.type)?.h;
5210
+ if (h) n.style.height = `${h}px`;
5211
+ else n.style.removeProperty("height");
5212
+ n.classList.toggle("twm-flow-graph__node--sized", Boolean(h));
5213
+ if (folding) paintFold(n, folded);
5214
+ if (liveValues.size || n.querySelector(".twm-flow-graph__node-badge, .twm-flow-graph__port-value")) {
5215
+ paintValues(n, nodeRow);
5216
+ }
5217
+ const accent = catalogue.accent?.(nodeRow.type) || null;
5218
+ if (accent) n.dataset.accent = accent;
5219
+ else delete n.dataset.accent;
5220
+ const picked = picks.has(nodeRow.id);
5221
+ n.classList.toggle("twm-flow-graph__node--picked", picked);
5222
+ if (picked) n.setAttribute("aria-current", "true");
5223
+ else n.removeAttribute("aria-current");
5224
+ n.classList.toggle("twm-flow-graph__node--connect-source", connecting?.source === nodeRow.id);
5225
+ const mine = findingsBy.get(nodeRow.id) || [];
5226
+ const errors = mine.filter((f) => f.severity !== "warning");
5227
+ n.classList.toggle("twm-flow-graph__node--error", errors.length > 0);
5228
+ n.classList.toggle("twm-flow-graph__node--warning", errors.length === 0 && mine.length > 0);
5229
+ n.querySelector(".twm-flow-graph__node-findings").replaceChildren(...mine.slice(0, 2).map((f) => el(
5230
+ "p",
5231
+ `twm-flow-graph__node-finding${f.severity === "warning" ? " twm-flow-graph__node-finding--warning" : ""}`,
5232
+ String(f.message ?? "")
5233
+ )));
5234
+ const summary = n.querySelector(".twm-flow-graph__node-summary");
5235
+ if (summary) summary.textContent = summarise(nodeRow, typeOf(nodeRow)) || "";
5236
+ const step = overlay?.steps?.[nodeRow.id] || null;
5237
+ const line = n.querySelector(".twm-flow-graph__node-run");
5238
+ line.textContent = step ? String(step.line ?? step.state ?? "") : "";
5239
+ if (step?.state) line.dataset.state = String(step.state);
5240
+ else delete line.dataset.state;
5241
+ if (step?.tone) line.dataset.tone = String(step.tone);
5242
+ else delete line.dataset.tone;
5243
+ n.classList.toggle("twm-flow-graph__node--skipped", step?.state === "skipped" || step?.state === "not-reached");
5244
+ const ports = overlay?.ports?.[nodeRow.id] || null;
5245
+ for (const b of n.querySelectorAll(".twm-flow-graph__port")) {
5246
+ if (!("data" in b.dataset)) b.disabled = isReadOnly();
5247
+ if (verdictOf && b.dataset.direction === "input") {
5248
+ b.classList.toggle("twm-flow-graph__port--can-drop", Boolean(connecting) && verdictOf(graph, {
5249
+ ...connecting,
5250
+ target: nodeRow.id,
5251
+ targetPort: b.dataset.port
5252
+ }).ok);
5253
+ }
5254
+ const mark = b.dataset.direction === "output" ? ports?.[b.dataset.port] : null;
5255
+ b.classList.toggle("twm-flow-graph__port--taken", Boolean(mark?.taken));
5256
+ b.classList.toggle("twm-flow-graph__port--untaken", Boolean(mark) && !mark.taken);
5257
+ const count2 = b.querySelector(".twm-flow-graph__port-count");
5258
+ count2.textContent = mark?.taken && Number.isFinite(Number(mark.count)) && mark.count !== null ? say(S, "portCount", mark.count) : mark && !mark.taken ? say(S, "portNotTaken") : "";
5259
+ }
5260
+ return bodyChanged;
5261
+ }
5262
+ function paintFold(n, folded) {
5263
+ n.classList.toggle("twm-flow-graph__node--collapsed", folded);
5264
+ const b = n.querySelector(".twm-flow-graph__node-fold");
5265
+ if (!b) return;
5266
+ b.disabled = isReadOnly();
5267
+ b.setAttribute("aria-expanded", String(!folded));
5268
+ b.title = say(S, folded ? "expandNode" : "collapseNode");
5269
+ b.setAttribute("aria-label", b.title);
5270
+ const glyph = b.querySelector(".material-symbols-outlined");
5271
+ const name = folded ? "expand_more" : "expand_less";
5272
+ if (glyph && glyph.textContent !== name) glyph.textContent = name;
5273
+ }
3868
5274
  function paintEdges() {
3869
5275
  if (destroyed) return;
5276
+ const fallback = routing === "obstacles" && graph.nodes.length > OBSTACLE_ROUTING_NODE_LIMIT;
5277
+ if (fallback !== routingFallback) {
5278
+ routingFallback = fallback;
5279
+ paintStrip();
5280
+ }
5281
+ const routed = routing === "obstacles" && !fallback;
3870
5282
  const byId = new Map(graph.nodes.map((x) => [x.id, x]));
5283
+ const copies = styled ? new Map(normalise(graph).nodes.map((x) => [x.id, x])) : null;
5284
+ const boxes = routed ? graph.nodes.map((x) => ({
5285
+ id: x.id,
5286
+ x: x.position.x - CLEARANCE,
5287
+ y: x.position.y - CLEARANCE,
5288
+ width: widthOf(x) + 2 * CLEARANCE,
5289
+ height: bottomOf(x) - x.position.y + 2 * CLEARANCE
5290
+ })) : null;
3871
5291
  const paths = [];
5292
+ const labels = [];
5293
+ edgeEls = [];
3872
5294
  graph.connections.forEach((c) => {
3873
5295
  const a = byId.get(c.source);
3874
5296
  const b = byId.get(c.target);
@@ -3876,18 +5298,23 @@ function createCanvasEditor(host, options = {}) {
3876
5298
  const from = anchor(a, c.sourcePort, "output");
3877
5299
  const to = anchor(b, c.targetPort, "input");
3878
5300
  const back = isReturn(c, b);
3879
- const d = back ? returnEdge(from, to, { floor: Math.max(bottomOf(a), bottomOf(b)) + RETURN_DROP }) : routeEdge(from, to, { simple: true });
5301
+ const look = styled ? edgeLook(c, copies.get(c.source), copies.get(c.target), back) : null;
5302
+ const end = look?.marker ? lineEnd(to, look.marker) : to;
5303
+ const d = back ? returnEdge(from, end, { floor: Math.max(bottomOf(a), bottomOf(b)) + RETURN_DROP }) : routed ? routeEdge(from, end, { obstacles: boxes.filter((x) => x.id !== a.id && x.id !== b.id) }) : routeEdge(from, end, { simple: true });
3880
5304
  const picked = selected?.kind === "edge" && sameEdge(selected.edge, c);
3881
5305
  const mark = overlay?.ports?.[c.source]?.[c.sourcePort];
3882
5306
  const line = document.createElementNS(SVG, "path");
3883
5307
  line.setAttribute("class", [
3884
5308
  "twm-flow-graph__edge",
3885
5309
  back ? "twm-flow-graph__edge--back" : "",
5310
+ look?.dashed ? "twm-flow-graph__edge--dashed" : "",
3886
5311
  picked ? "twm-flow-graph__edge--picked" : "",
3887
5312
  mark?.taken ? "twm-flow-graph__edge--taken" : "",
3888
- mark && !mark.taken ? "twm-flow-graph__edge--untaken" : ""
5313
+ mark && !mark.taken ? "twm-flow-graph__edge--untaken" : "",
5314
+ hovered && touches(c) ? "twm-flow-graph__edge--highlighted" : ""
3889
5315
  ].filter(Boolean).join(" "));
3890
5316
  line.setAttribute("d", d);
5317
+ if (look) applyLook(line, look);
3891
5318
  const hit = document.createElementNS(SVG, "path");
3892
5319
  hit.setAttribute("class", "twm-flow-graph__edge-hit");
3893
5320
  hit.setAttribute("d", d);
@@ -3896,42 +5323,434 @@ function createCanvasEditor(host, options = {}) {
3896
5323
  title.textContent = say(S, "returnLine");
3897
5324
  hit.appendChild(title);
3898
5325
  }
3899
- hit.addEventListener("pointerdown", (ev) => {
5326
+ const pick = (ev) => {
3900
5327
  ev.stopPropagation();
3901
5328
  select({ kind: "edge", edge: { ...c } });
3902
- });
3903
- paths.push(line, hit);
5329
+ };
5330
+ hit.addEventListener("pointerdown", pick);
5331
+ if (hoverHighlight) {
5332
+ hit.addEventListener("pointerenter", () => hover({ kind: "edge", edge: { ...c } }));
5333
+ hit.addEventListener("pointerleave", () => {
5334
+ if (hovered?.kind === "edge" && sameEdge(hovered.edge, c)) hover(null);
5335
+ });
5336
+ }
5337
+ paths.push(line);
5338
+ if (look?.marker) {
5339
+ const head = look.marker === "arrow" ? arrowHead(to, to.side) : crowsFoot(to, to.side, look.marker);
5340
+ if (look.marker !== "arrow") head.classList.add("twm-flow-graph__edge-foot");
5341
+ applyLook(head, look);
5342
+ paths.push(head);
5343
+ }
5344
+ paths.push(hit);
5345
+ if (look?.label !== null && look?.label !== void 0) {
5346
+ const at = pathMidpoint(d);
5347
+ const tag = el("div", "twm-flow-graph__edge-label", look.label);
5348
+ tag.style.left = `${at.x}px`;
5349
+ tag.style.top = `${at.y}px`;
5350
+ applyLook(tag, look);
5351
+ tag.addEventListener("pointerdown", pick);
5352
+ labels.push(tag);
5353
+ }
5354
+ edgeEls.push({ c, line });
3904
5355
  });
3905
5356
  canvas.edgeLayer.replaceChildren(...paths);
5357
+ labelLayer?.replaceChildren(...labels);
5358
+ paintFrames();
5359
+ paintMinimap();
3906
5360
  }
3907
5361
  function fit({ whole = false } = {}) {
3908
5362
  if (!graph.nodes.length || destroyed) return;
3909
- const xs = graph.nodes.map((x2) => x2.position.x);
3910
- const ys = graph.nodes.map((x2) => x2.position.y);
3911
- const x = Math.min(...xs);
3912
- const y = Math.min(...ys);
3913
- const height = Math.max(...graph.nodes.map(bottomOf)) - y;
3914
- canvas.fit({ x, y, width: Math.max(...xs) + BOX.width - x, height });
5363
+ const notes = noting ? noteList().map((n) => ({
5364
+ x: n.position.x,
5365
+ y: n.position.y,
5366
+ right: n.position.x + (n.size?.w || NOTE_WIDTH),
5367
+ bottom: n.position.y + (n.size?.h || noteEls.get(n.id)?.offsetHeight || 0)
5368
+ })) : [];
5369
+ const x = Math.min(...graph.nodes.map((n) => n.position.x), ...notes.map((n) => n.x));
5370
+ const y = Math.min(...graph.nodes.map((n) => n.position.y), ...notes.map((n) => n.y));
5371
+ const height = Math.max(...graph.nodes.map(bottomOf), ...notes.map((n) => n.bottom)) - y;
5372
+ const right = Math.max(...graph.nodes.map((n) => n.position.x + widthOf(n)), ...notes.map((n) => n.right));
5373
+ canvas.fit({ x, y, width: right - x, height });
3915
5374
  const scale = canvas.scale > NATURAL ? NATURAL : !whole && canvas.scale < READABLE ? READABLE : null;
3916
5375
  if (scale !== null) {
3917
5376
  const box = canvas.root.getBoundingClientRect();
3918
5377
  canvas.setViewport({ scale, tx: 40 - x * scale, ty: box.height / 2 - (y + height / 2) * scale });
3919
5378
  }
3920
5379
  }
3921
- function arrangeGraph() {
5380
+ function placeSteps(part, ids) {
5381
+ if (layout === "flow") {
5382
+ return layoutGraph(part, catalogue, { ...BOX, loopEntry: loopPorts.entry, heightOf: (n) => measured(n) || null });
5383
+ }
5384
+ const sizeOf2 = (n) => ({ width: widthOf(n), height: heightOf(n) });
5385
+ if (layout === "layered") {
5386
+ return layeredLayout(part.nodes, part.connections.map((c) => ({ from: c.source, to: c.target })), {
5387
+ rank: (n) => catalogue.rank(n.type),
5388
+ sizeOf: sizeOf2,
5389
+ columnGap: 96,
5390
+ rowGap: 40,
5391
+ componentGap: 80
5392
+ });
5393
+ }
5394
+ const at = layout(normalise(part), { catalogue, node: BOX, ids, sizeOf: sizeOf2, layeredLayout });
5395
+ if (!(at instanceof Map)) throw new Error("The canvas editor's layout function must return a Map of id \u2192 {x, y}.");
5396
+ for (const [id, p] of at) {
5397
+ if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) {
5398
+ throw new Error(`The canvas editor's layout function placed "${id}" at ${JSON.stringify(p)}; a place must be finite numbers {x, y}.`);
5399
+ }
5400
+ }
5401
+ return at;
5402
+ }
5403
+ const arrangeTarget = () => selected?.kind === "many" && selected.ids.length >= 2 ? { ids: [...selected.ids] } : {};
5404
+ function arrangeGraph({ ids = null } = {}) {
3922
5405
  if (refusals().arrange) return false;
3923
- const at = layoutGraph(graph, catalogue, { ...BOX, loopEntry: loopPorts.entry });
3924
- const ox = Math.min(...graph.nodes.map((x) => x.position.x));
3925
- const oy = Math.min(...graph.nodes.map((x) => x.position.y));
3926
- for (const nodeRow of graph.nodes) {
5406
+ const only = Array.isArray(ids) ? new Set(ids.map(String)) : null;
5407
+ const nodes = only ? graph.nodes.filter((n) => only.has(n.id)) : graph.nodes;
5408
+ if (nodes.length < 2) return false;
5409
+ const part = only ? { nodes, connections: graph.connections.filter((c) => only.has(c.source) && only.has(c.target)) } : graph;
5410
+ const at = placeSteps(part, only ? nodes.map((n) => n.id) : null);
5411
+ const ox = Math.min(...nodes.map((x) => x.position.x));
5412
+ const oy = Math.min(...nodes.map((x) => x.position.y));
5413
+ for (const nodeRow of nodes) {
3927
5414
  const p = at.get(nodeRow.id);
3928
- if (p) nodeRow.position = { x: canvas.snap(ox + p.x), y: canvas.snap(oy + p.y) };
5415
+ if (p) {
5416
+ nodeRow.position = { x: canvas.snap(ox + p.x), y: canvas.snap(oy + p.y) };
5417
+ }
3929
5418
  }
3930
5419
  paintNodes();
3931
5420
  commit("flow:arrange");
3932
- fit();
5421
+ if (!only) fit();
5422
+ return true;
5423
+ }
5424
+ function freshId(list, stem) {
5425
+ const taken = new Set(list.map((x) => x.id));
5426
+ if (!taken.has(stem)) return stem;
5427
+ for (let i = 2; ; i += 1) if (!taken.has(`${stem}-${i}`)) return `${stem}-${i}`;
5428
+ }
5429
+ const groupsOfPick = () => {
5430
+ const ids = pickedSet();
5431
+ return groupList().filter((gr) => gr.nodes.some((id) => ids.has(id)));
5432
+ };
5433
+ function ungroupNodes(ids) {
5434
+ const out = new Set(ids);
5435
+ for (const gr of groupList()) gr.nodes = gr.nodes.filter((id) => !out.has(id));
5436
+ setGroups(groupList().filter((gr) => gr.nodes.length));
5437
+ }
5438
+ function setGroups(list) {
5439
+ if (list.length) graph.groups = list;
5440
+ else delete graph.groups;
5441
+ }
5442
+ function groupNodes(ids, { label: label2 = "", tone = null } = {}) {
5443
+ if (!grouping || isReadOnly()) return null;
5444
+ const want = new Set(ids);
5445
+ const members = graph.nodes.filter((x) => want.has(x.id)).map((x) => x.id);
5446
+ if (!members.length) return null;
5447
+ ungroupNodes(members);
5448
+ const id = freshId(groupList(), "group");
5449
+ setGroups([...groupList(), {
5450
+ id,
5451
+ ...label2 ? { label: String(label2) } : {},
5452
+ ...tone ? { tone: String(tone) } : {},
5453
+ nodes: members
5454
+ }]);
5455
+ paintFrames();
5456
+ commit("flow:group:add");
5457
+ paintToolbar();
5458
+ return id;
5459
+ }
5460
+ function groupPicked() {
5461
+ if (refusals().group) return false;
5462
+ return groupNodes(pickedIds()) !== null;
5463
+ }
5464
+ function ungroupGroups(groupIds) {
5465
+ if (!grouping || isReadOnly()) return false;
5466
+ const gone = new Set(groupIds.filter((id) => groupOf(id)));
5467
+ if (!gone.size) return false;
5468
+ setGroups(groupList().filter((gr) => !gone.has(gr.id)));
5469
+ paintFrames();
5470
+ commit("flow:group:remove");
5471
+ paintToolbar();
3933
5472
  return true;
3934
5473
  }
5474
+ function ungroupPicked() {
5475
+ if (refusals().ungroup) return false;
5476
+ return ungroupGroups(groupsOfPick().map((gr) => gr.id));
5477
+ }
5478
+ function setGroupLook(id, { label: label2, tone } = {}) {
5479
+ const gr = grouping && !isReadOnly() ? groupOf(id) : null;
5480
+ if (!gr) return false;
5481
+ const name = label2 === void 0 ? gr.label || "" : String(label2 ?? "");
5482
+ const look = tone === void 0 ? gr.tone || "" : String(tone ?? "");
5483
+ if (name === (gr.label || "") && look === (gr.tone || "")) return false;
5484
+ setGroups(groupList().map((x) => x.id !== id ? x : {
5485
+ id,
5486
+ ...name ? { label: name } : {},
5487
+ ...look ? { tone: look } : {},
5488
+ nodes: x.nodes
5489
+ }));
5490
+ paintFrames();
5491
+ commit("flow:group:label", `group:${id}`);
5492
+ return true;
5493
+ }
5494
+ function setGroupMembers(id, ids) {
5495
+ const gr = grouping && !isReadOnly() ? groupOf(id) : null;
5496
+ if (!gr) return false;
5497
+ const want = new Set(ids);
5498
+ const members = graph.nodes.filter((x) => want.has(x.id)).map((x) => x.id);
5499
+ if (members.length === gr.nodes.length && members.every((m, i) => m === gr.nodes[i])) return false;
5500
+ const taken = new Set(members);
5501
+ setGroups(groupList().map((x) => x.id === id ? { ...x, nodes: members } : { ...x, nodes: x.nodes.filter((n) => !taken.has(n)) }).filter((x) => x.nodes.length));
5502
+ paintFrames();
5503
+ commit("flow:group:members");
5504
+ paintToolbar();
5505
+ return true;
5506
+ }
5507
+ function frameBox(gr) {
5508
+ const rows = gr.nodes.map(nodeOf).filter(Boolean);
5509
+ if (!rows.length) return null;
5510
+ const x = Math.min(...rows.map((n) => n.position.x)) - FRAME.pad;
5511
+ const y = Math.min(...rows.map((n) => n.position.y)) - FRAME.pad - FRAME.head;
5512
+ const right = Math.max(...rows.map((n) => n.position.x + widthOf(n))) + FRAME.pad;
5513
+ const bottom = Math.max(...rows.map(bottomOf)) + FRAME.pad;
5514
+ return { x, y, width: right - x, height: bottom - y };
5515
+ }
5516
+ function buildFrame(id) {
5517
+ const f = el("div", "twm-flow-graph__group");
5518
+ f.dataset.groupId = id;
5519
+ const head = el("div", "twm-flow-graph__group-head");
5520
+ head.appendChild(el("span", "twm-flow-graph__group-label"));
5521
+ head.addEventListener("pointerdown", (ev) => {
5522
+ if (ev.button !== 0 || editing?.kind === "group" && editing.id === id) return;
5523
+ ev.stopPropagation();
5524
+ const gr = groupOf(id);
5525
+ if (!gr) return;
5526
+ if (multi) pickNodes(gr.nodes);
5527
+ if (!isReadOnly()) dragItems(ev, gr.nodes, []);
5528
+ });
5529
+ head.addEventListener("dblclick", () => editGroupLabel(id));
5530
+ f.appendChild(head);
5531
+ return f;
5532
+ }
5533
+ function paintFrames() {
5534
+ if (!grouping || destroyed) return;
5535
+ const live = new Set(groupList().map((gr) => gr.id));
5536
+ if (editing?.kind === "group" && !live.has(editing.id)) editing.done(false);
5537
+ for (const [id, f] of frameEls) if (!live.has(id)) {
5538
+ f.remove();
5539
+ frameEls.delete(id);
5540
+ }
5541
+ for (const gr of groupList()) {
5542
+ let f = frameEls.get(gr.id);
5543
+ if (!f) {
5544
+ f = buildFrame(gr.id);
5545
+ frameEls.set(gr.id, f);
5546
+ }
5547
+ const box = frameBox(gr);
5548
+ Object.assign(f.style, {
5549
+ left: `${box.x}px`,
5550
+ top: `${box.y}px`,
5551
+ width: `${box.width}px`,
5552
+ height: `${box.height}px`
5553
+ });
5554
+ if (TONES.includes(gr.tone)) f.dataset.tone = gr.tone;
5555
+ else delete f.dataset.tone;
5556
+ const name = gr.label || say(S, "groupUntitled");
5557
+ f.setAttribute("aria-label", name);
5558
+ if (editing?.kind !== "group" || editing.id !== gr.id) {
5559
+ const tag = f.querySelector(".twm-flow-graph__group-label");
5560
+ tag.textContent = name;
5561
+ tag.title = name;
5562
+ tag.classList.toggle("twm-flow-graph__group-label--untitled", !gr.label);
5563
+ }
5564
+ }
5565
+ const order = groupList().map((gr) => frameEls.get(gr.id));
5566
+ const lead = [...canvas.nodeLayer.children].slice(0, order.length);
5567
+ if (order.some((f, i) => lead[i] !== f)) canvas.nodeLayer.prepend(...order);
5568
+ }
5569
+ function typeInPlace(kind, id, shown, input, keep) {
5570
+ editing?.done(true);
5571
+ input.value = kind === "note" ? noteOf(id)?.text ?? "" : groupOf(id)?.label ?? "";
5572
+ shown.hidden = true;
5573
+ shown.after(input);
5574
+ let over = false;
5575
+ const done = (save) => {
5576
+ if (over) return;
5577
+ over = true;
5578
+ editing = null;
5579
+ const text = input.value;
5580
+ input.remove();
5581
+ shown.hidden = false;
5582
+ if (save) keep(text);
5583
+ if (kind === "note") paintNotes();
5584
+ else paintFrames();
5585
+ keepFocus();
5586
+ };
5587
+ input.addEventListener("keydown", (k) => {
5588
+ if (k.key === "Escape") {
5589
+ k.preventDefault();
5590
+ k.stopPropagation();
5591
+ done(false);
5592
+ return;
5593
+ }
5594
+ if (k.key === "Enter" && (kind !== "note" || k.ctrlKey || k.metaKey)) {
5595
+ k.preventDefault();
5596
+ k.stopPropagation();
5597
+ done(true);
5598
+ }
5599
+ });
5600
+ input.addEventListener("blur", () => done(true));
5601
+ input.addEventListener("pointerdown", (p) => p.stopPropagation());
5602
+ editing = { kind, id, done };
5603
+ input.focus({ preventScroll: true });
5604
+ input.select?.();
5605
+ }
5606
+ function editGroupLabel(id) {
5607
+ const f = frameEls.get(id);
5608
+ if (!f || !groupOf(id) || isReadOnly()) return false;
5609
+ if (editing?.kind === "group" && editing.id === id) return true;
5610
+ const input = el("input", "twm-flow-graph__group-input");
5611
+ input.type = "text";
5612
+ input.setAttribute("aria-label", say(S, "groupName"));
5613
+ typeInPlace(
5614
+ "group",
5615
+ id,
5616
+ f.querySelector(".twm-flow-graph__group-label"),
5617
+ input,
5618
+ (text) => setGroupLook(id, { label: text.trim() })
5619
+ );
5620
+ return true;
5621
+ }
5622
+ function addNote({ text = "", position = null, size = null } = {}) {
5623
+ if (!noting || isReadOnly()) return null;
5624
+ let where = position;
5625
+ if (!where) {
5626
+ const box = canvas.visibleBox();
5627
+ where = {
5628
+ x: canvas.snap(box.width ? box.x + box.width / 2 - NOTE_WIDTH / 2 : 80),
5629
+ y: canvas.snap(box.height ? box.y + box.height / 3 : 80)
5630
+ };
5631
+ }
5632
+ const id = freshId(noteList(), "note");
5633
+ const next = normalise({ nodes: [], connections: [], notes: [{ id, text, position: where, size }] }).notes[0];
5634
+ graph.notes = [...noteList(), next];
5635
+ selected = { kind: "note", id };
5636
+ paintNodes();
5637
+ paintPanel();
5638
+ announceSelection();
5639
+ commit("flow:note:add");
5640
+ return structuredClone(next);
5641
+ }
5642
+ function setNoteText(id, text) {
5643
+ const note = noting && !isReadOnly() ? noteOf(id) : null;
5644
+ if (!note || note.text === String(text ?? "")) return false;
5645
+ note.text = String(text ?? "");
5646
+ paintNotes();
5647
+ if (selected?.kind === "note" && selected.id === id) paintPanel();
5648
+ commit("flow:note:edit", `note:${id}`);
5649
+ return true;
5650
+ }
5651
+ function moveNote(id, position) {
5652
+ const note = noting && !isReadOnly() ? noteOf(id) : null;
5653
+ const x = Math.round(Number(position?.x));
5654
+ const y = Math.round(Number(position?.y));
5655
+ if (!note || !Number.isFinite(x) || !Number.isFinite(y)) return false;
5656
+ if (note.position.x === x && note.position.y === y) return false;
5657
+ note.position = { x, y };
5658
+ paintNotes();
5659
+ commit("flow:note:move");
5660
+ return true;
5661
+ }
5662
+ function dropNotes(ids, commitAs = "flow:note:remove") {
5663
+ const gone = new Set(ids);
5664
+ const kept = noteList().filter((x) => !gone.has(x.id));
5665
+ if (kept.length === noteList().length) return false;
5666
+ if (kept.length) graph.notes = kept;
5667
+ else delete graph.notes;
5668
+ if (commitAs) {
5669
+ prunePick();
5670
+ paintNodes();
5671
+ paintPanel();
5672
+ announceSelection();
5673
+ commit(commitAs);
5674
+ }
5675
+ return true;
5676
+ }
5677
+ function editNote(id) {
5678
+ const box = noteEls.get(id);
5679
+ if (!box || !noteOf(id) || isReadOnly()) return false;
5680
+ if (editing?.kind === "note" && editing.id === id) return true;
5681
+ const input = el("textarea", "twm-flow-graph__note-input");
5682
+ input.setAttribute("aria-label", say(S, "noteText"));
5683
+ typeInPlace(
5684
+ "note",
5685
+ id,
5686
+ box.querySelector(".twm-flow-graph__note-text"),
5687
+ input,
5688
+ (text) => setNoteText(id, text)
5689
+ );
5690
+ return true;
5691
+ }
5692
+ function buildNote(id) {
5693
+ const box = el("div", "twm-flow-graph__note");
5694
+ box.dataset.noteId = id;
5695
+ box.setAttribute("data-twm-flow-item", "");
5696
+ box.setAttribute("role", "note");
5697
+ box.tabIndex = 0;
5698
+ box.appendChild(el("p", "twm-flow-graph__note-text"));
5699
+ box.addEventListener("pointerdown", (ev) => {
5700
+ if (ev.button !== 0 || editing?.kind === "note" && editing.id === id) return;
5701
+ ev.stopPropagation();
5702
+ if (toggles(ev)) return;
5703
+ if (!isNotePicked(id)) select({ kind: "note", id });
5704
+ if (isReadOnly()) return;
5705
+ dragItems(ev, selected?.kind === "many" ? selected.ids : [], pickedNotes());
5706
+ });
5707
+ box.addEventListener("click", (ev) => {
5708
+ if (!toggles(ev)) return;
5709
+ const notes = pickedNotes();
5710
+ pickItems(pickedIds(), notes.includes(id) ? notes.filter((x) => x !== id) : [...notes, id]);
5711
+ });
5712
+ box.addEventListener("dblclick", () => editNote(id));
5713
+ return box;
5714
+ }
5715
+ function paintNotes() {
5716
+ if (!noting || destroyed) return;
5717
+ const live = new Set(noteList().map((x) => x.id));
5718
+ if (editing?.kind === "note" && !live.has(editing.id)) editing.done(false);
5719
+ for (const [id, box] of noteEls) if (!live.has(id)) {
5720
+ box.remove();
5721
+ noteEls.delete(id);
5722
+ }
5723
+ const picks = new Set(pickedNotes());
5724
+ for (const note of noteList()) {
5725
+ let box = noteEls.get(note.id);
5726
+ if (!box) {
5727
+ box = buildNote(note.id);
5728
+ noteEls.set(note.id, box);
5729
+ canvas.nodeLayer.appendChild(box);
5730
+ }
5731
+ box.style.left = `${note.position.x}px`;
5732
+ box.style.top = `${note.position.y}px`;
5733
+ box.style.width = `${note.size?.w || NOTE_WIDTH}px`;
5734
+ if (note.size?.h) box.style.height = `${note.size.h}px`;
5735
+ else box.style.removeProperty("height");
5736
+ const picked = picks.has(note.id);
5737
+ box.classList.toggle("twm-flow-graph__note--picked", picked);
5738
+ if (picked) box.setAttribute("aria-current", "true");
5739
+ else box.removeAttribute("aria-current");
5740
+ const shown = box.querySelector(".twm-flow-graph__note-text");
5741
+ shown.textContent = note.text || say(S, "noteEmpty");
5742
+ shown.classList.toggle("twm-flow-graph__note-text--empty", !note.text);
5743
+ box.setAttribute("aria-label", note.text || say(S, "noteEmpty"));
5744
+ }
5745
+ }
5746
+ function notesIn(box) {
5747
+ return noteList().filter((x) => {
5748
+ const el2 = noteEls.get(x.id);
5749
+ const w = x.size?.w || NOTE_WIDTH;
5750
+ const h = x.size?.h || el2?.offsetHeight || FRAME.head;
5751
+ return x.position.x < box.x + box.width && x.position.x + w > box.x && x.position.y < box.y + box.height && x.position.y + h > box.y;
5752
+ }).map((x) => x.id);
5753
+ }
3935
5754
  const panel = createSettingsPanel({
3936
5755
  widgets,
3937
5756
  references,
@@ -3955,19 +5774,7 @@ function createCanvasEditor(host, options = {}) {
3955
5774
  },
3956
5775
  onRename: (stepId, text) => {
3957
5776
  if (isReadOnly()) return;
3958
- const nodeRow = nodeOf(stepId);
3959
- if (!nodeRow) return;
3960
- if (text) nodeRow.label = text;
3961
- else delete nodeRow.label;
3962
- const n = nodeEls.get(nodeRow.id);
3963
- const name = text || typeOf(nodeRow)?.label || nodeRow.type;
3964
- const title = n?.querySelector(".twm-flow-graph__node-title");
3965
- if (title) title.textContent = title.title = name;
3966
- if (n) {
3967
- n.dataset.shape = `${nodeRow.type}|${nodeRow.label || ""}`;
3968
- n.setAttribute("aria-label", name);
3969
- }
3970
- commit("flow:node:label", `label:${stepId}`);
5777
+ renameNode(stepId, text);
3971
5778
  },
3972
5779
  onFocusLost: () => keepFocus(),
3973
5780
  values: typeof values === "function" ? (q) => values({
@@ -4002,9 +5809,10 @@ function createCanvasEditor(host, options = {}) {
4002
5809
  canvas.bringIntoView({
4003
5810
  x: n.position.x,
4004
5811
  y: n.position.y,
4005
- width: BOX.width,
5812
+ width: widthOf(n),
4006
5813
  height: bottomOf(n) - n.position.y
4007
5814
  });
5815
+ viewportSettled();
4008
5816
  }
4009
5817
  if (!(f.field && panel.focusField(f.field))) focusFirstField();
4010
5818
  }
@@ -4019,33 +5827,63 @@ function createCanvasEditor(host, options = {}) {
4019
5827
  }
4020
5828
  function select(next) {
4021
5829
  selected = next;
5830
+ const picks = pickedSet();
4022
5831
  for (const [id, n] of nodeEls) {
4023
- const picked = next?.kind === "node" && next.id === id;
5832
+ const picked = picks.has(id);
4024
5833
  n.classList.toggle("twm-flow-graph__node--picked", picked);
4025
5834
  if (picked) n.setAttribute("aria-current", "true");
4026
5835
  else n.removeAttribute("aria-current");
4027
5836
  }
5837
+ paintNotes();
4028
5838
  paintEdges();
4029
5839
  paintPanel();
4030
5840
  announceSelection();
5841
+ if (multi || grouping) paintToolbar();
5842
+ }
5843
+ function pickItems(ids, noteIds = []) {
5844
+ const next = pickOf(ids, noteIds);
5845
+ if (JSON.stringify(next) !== JSON.stringify(selected)) select(next);
5846
+ }
5847
+ const pickNodes = (ids) => pickItems(ids);
5848
+ function togglePick(id) {
5849
+ const ids = pickedIds();
5850
+ pickItems(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id], pickedNotes());
5851
+ }
5852
+ function nodesIn(box) {
5853
+ return graph.nodes.filter((x) => x.position.x < box.x + box.width && x.position.x + widthOf(x) > box.x && x.position.y < box.y + box.height && bottomOf(x) > box.y).map((x) => x.id);
4031
5854
  }
4032
5855
  function announceSelection() {
4033
- const now = selected?.kind === "node" ? `step:${selected.id}` : selected?.kind === "edge" ? `connection:${JSON.stringify(selected.edge)}` : selected?.kind === "flow" ? "flow" : "";
5856
+ const now = selected?.kind === "node" ? `step:${selected.id}` : selected?.kind === "many" ? `many:${JSON.stringify([selected.ids, selected.notes || []])}` : selected?.kind === "note" ? `note:${selected.id}` : selected?.kind === "edge" ? `connection:${JSON.stringify(selected.edge)}` : selected?.kind === "flow" ? "flow" : "";
4034
5857
  if (now === lastSelect) return;
4035
5858
  lastSelect = now;
4036
5859
  if (!onSelect || destroyed) return;
4037
5860
  if (selected?.kind === "node") onSelect({ kind: "step", id: selected.id });
5861
+ else if (selected?.kind === "many") {
5862
+ onSelect({ kind: "many", ids: [...selected.ids], ...selected.notes ? { notes: [...selected.notes] } : {} });
5863
+ } else if (selected?.kind === "note") onSelect({ kind: "note", id: selected.id });
4038
5864
  else if (selected?.kind === "edge") onSelect({ kind: "connection", id: null, connection: { ...selected.edge } });
4039
5865
  else if (selected?.kind === "flow") onSelect({ kind: "flow", id: null });
4040
5866
  else onSelect({ kind: null, id: null });
4041
5867
  }
4042
5868
  function removeSelected() {
4043
5869
  if (!selected || isReadOnly()) return false;
4044
- if (selected.kind === "node") {
5870
+ if (selected.kind === "many") {
5871
+ const doomed = removableOf(selected.ids);
5872
+ const notes = selected.notes || [];
5873
+ if (!doomed.length && !notes.length) return false;
5874
+ for (const id of doomed) removeNode(graph, id);
5875
+ dropNotes(notes, null);
5876
+ findingsBy = groupFindings(allFindings(), { graph });
5877
+ select(null);
5878
+ paintNodes();
5879
+ commit(doomed.length ? "flow:nodes:remove" : "flow:note:remove");
5880
+ } else if (selected.kind === "note") {
5881
+ if (!dropNotes([selected.id])) return false;
5882
+ } else if (selected.kind === "node") {
4045
5883
  const nodeRow = nodeOf(selected.id);
4046
5884
  if (!nodeRow || !canRemove(nodeRow, typeOf(nodeRow))) return false;
4047
5885
  removeNode(graph, selected.id);
4048
- findingsBy = groupFindings(findings, { graph });
5886
+ findingsBy = groupFindings(allFindings(), { graph });
4049
5887
  select(null);
4050
5888
  paintNodes();
4051
5889
  commit("flow:node:remove");
@@ -4058,30 +5896,76 @@ function createCanvasEditor(host, options = {}) {
4058
5896
  keepFocus();
4059
5897
  return true;
4060
5898
  }
4061
- function paintConnection() {
4062
- const e = selected.edge;
5899
+ const removableOf = (ids) => ids.filter((id) => {
5900
+ const nodeRow = nodeOf(id);
5901
+ return nodeRow && canRemove(nodeRow, typeOf(nodeRow));
5902
+ });
5903
+ function paintPickBox({ title, line, removeLabel, removeIcon, action, refused: refused2 = false }) {
4063
5904
  const remove = button({
4064
- label: say(S, "removeConnection"),
4065
- icon: "link_off",
5905
+ label: removeLabel,
5906
+ icon: removeIcon,
4066
5907
  danger: true,
4067
5908
  className: "twm-flow-graph__connection-remove",
4068
5909
  onClick: () => removeSelected()
4069
5910
  });
4070
- remove.dataset.action = "remove-connection";
4071
- remove.disabled = isReadOnly();
5911
+ remove.dataset.action = action;
5912
+ remove.disabled = isReadOnly() || refused2;
4072
5913
  connectionBox.replaceChildren(
4073
- el("h3", "twm-flow-graph__connection-title", say(S, "connection")),
4074
- el("p", "twm-flow-graph__connection-line", say(S, "connectionLine", e)),
5914
+ el("h3", "twm-flow-graph__connection-title", title),
5915
+ el("p", "twm-flow-graph__connection-line", line),
4075
5916
  remove
4076
5917
  );
4077
5918
  connectionBox.hidden = false;
4078
5919
  }
5920
+ function paintConnection() {
5921
+ paintPickBox({
5922
+ title: say(S, "connection"),
5923
+ line: say(S, "connectionLine", selected.edge),
5924
+ removeLabel: say(S, "removeConnection"),
5925
+ removeIcon: "link_off",
5926
+ action: "remove-connection"
5927
+ });
5928
+ }
5929
+ function paintMany() {
5930
+ const n = removableOf(selected.ids).length;
5931
+ const notes = selected.notes?.length || 0;
5932
+ if (!notes) {
5933
+ paintPickBox({
5934
+ title: say(S, "stepsPicked", selected.ids.length),
5935
+ line: say(S, "stepsPickedLine"),
5936
+ removeLabel: say(S, "removeSteps", n),
5937
+ removeIcon: "delete",
5938
+ action: "remove-steps",
5939
+ refused: n === 0
5940
+ });
5941
+ return;
5942
+ }
5943
+ paintPickBox({
5944
+ title: say(S, "itemsPicked", selected.ids.length, notes),
5945
+ line: say(S, "stepsPickedLine"),
5946
+ removeLabel: say(S, "removeItems", n + notes),
5947
+ removeIcon: "delete",
5948
+ action: "remove-steps"
5949
+ });
5950
+ }
5951
+ function paintNote() {
5952
+ const text = noteOf(selected.id)?.text || "";
5953
+ paintPickBox({
5954
+ title: say(S, "noteKind"),
5955
+ line: text || say(S, "noteEmpty"),
5956
+ removeLabel: say(S, "removeNote"),
5957
+ removeIcon: "delete",
5958
+ action: "remove-note"
5959
+ });
5960
+ }
4079
5961
  function paintPanel() {
4080
5962
  if (destroyed) return;
4081
- if (selected?.kind === "edge") {
5963
+ if (selected?.kind === "edge" || selected?.kind === "many" || selected?.kind === "note") {
4082
5964
  panel.clear();
4083
5965
  panel.el.hidden = true;
4084
- paintConnection();
5966
+ if (selected.kind === "edge") paintConnection();
5967
+ else if (selected.kind === "note") paintNote();
5968
+ else paintMany();
4085
5969
  return;
4086
5970
  }
4087
5971
  connectionBox.hidden = true;
@@ -4128,8 +6012,304 @@ function createCanvasEditor(host, options = {}) {
4128
6012
  slots: { after }
4129
6013
  });
4130
6014
  }
4131
- panel.setFindings(findings);
6015
+ panel.setFindings(allFindings());
6016
+ }
6017
+ const heightOf = (n) => bottomOf(n) - n.position.y;
6018
+ const pickedRows = () => pickedIds().map(nodeOf).filter(Boolean);
6019
+ function alignNodes(kind) {
6020
+ if (!ALIGN.includes(kind)) throw new Error(`align("${kind}"): the kinds are ${ALIGN.join(", ")}.`);
6021
+ if (refusals()[`align-${kind}`]) return false;
6022
+ const rows = pickedRows();
6023
+ const left = Math.min(...rows.map((n) => n.position.x));
6024
+ const right = Math.max(...rows.map((n) => n.position.x + widthOf(n)));
6025
+ const top = Math.min(...rows.map((n) => n.position.y));
6026
+ const bottom = Math.max(...rows.map(bottomOf));
6027
+ for (const n of rows) {
6028
+ const { x, y } = n.position;
6029
+ const w = widthOf(n);
6030
+ const h = heightOf(n);
6031
+ n.position = {
6032
+ x: kind === "left" ? left : kind === "right" ? right - w : kind === "center" ? Math.round((left + right - w) / 2) : x,
6033
+ y: kind === "top" ? top : kind === "bottom" ? bottom - h : kind === "middle" ? Math.round((top + bottom - h) / 2) : y
6034
+ };
6035
+ }
6036
+ paintNodes();
6037
+ commit("flow:nodes:align");
6038
+ return true;
6039
+ }
6040
+ function distributeNodes(axis) {
6041
+ if (!DISTRIBUTE.includes(axis)) throw new Error(`distribute("${axis}"): the axes are ${DISTRIBUTE.join(", ")}.`);
6042
+ if (refusals()[`distribute-${axis}`]) return false;
6043
+ const across = axis === "horizontal";
6044
+ const at = (n) => across ? n.position.x : n.position.y;
6045
+ const size = (n) => across ? widthOf(n) : heightOf(n);
6046
+ const rows = pickedRows().sort((a, b) => at(a) - at(b));
6047
+ const first = at(rows[0]);
6048
+ const end = Math.max(...rows.map((n) => at(n) + size(n)));
6049
+ const gap = (end - first - rows.reduce((sum, n) => sum + size(n), 0)) / (rows.length - 1);
6050
+ let cursor = first;
6051
+ for (const n of rows) {
6052
+ const p = Math.round(cursor);
6053
+ n.position = across ? { x: p, y: n.position.y } : { x: n.position.x, y: p };
6054
+ cursor += size(n) + gap;
6055
+ }
6056
+ paintNodes();
6057
+ commit("flow:nodes:align");
6058
+ return true;
6059
+ }
6060
+ function nudge(dx, dy, ev) {
6061
+ if (!multi || !canvas.root.contains(ev.target)) return false;
6062
+ const rows = pickedRows();
6063
+ const notes = pickedNotes().map(noteOf).filter(Boolean);
6064
+ if (!(rows.length + notes.length) || isReadOnly()) return false;
6065
+ const step = (canvas.gridSize || 1) * (ev.shiftKey ? NUDGE_FAR : 1);
6066
+ for (const n of [...rows, ...notes]) {
6067
+ n.position = { x: n.position.x + dx * step, y: n.position.y + dy * step };
6068
+ const box = (rows.includes(n) ? nodeEls : noteEls).get(n.id);
6069
+ if (box) {
6070
+ box.style.left = `${n.position.x}px`;
6071
+ box.style.top = `${n.position.y}px`;
6072
+ }
6073
+ }
6074
+ paintEdges();
6075
+ const action = !rows.length ? "flow:note:move" : rows.length + notes.length > 1 ? "flow:nodes:move" : "flow:node:move";
6076
+ commit(action, `nudge:${[...rows, ...notes].map((n) => n.id).join(",")}`);
6077
+ return void 0;
6078
+ }
6079
+ function onTab(ev) {
6080
+ if (ev.key !== "Tab" || ev.ctrlKey || ev.metaKey || ev.altKey || ev.defaultPrevented) return;
6081
+ const from = ev.target.closest?.("[data-node-id]");
6082
+ if (!from && (ev.target !== canvas.root || ev.shiftKey)) return;
6083
+ const ids = graph.nodes.map((x) => x.id);
6084
+ const i = from ? ids.indexOf(from.dataset.nodeId) : -1;
6085
+ const next = nodeEls.get(ids[ev.shiftKey ? i - 1 : i + 1]);
6086
+ if (!next) return;
6087
+ ev.preventDefault();
6088
+ next.focus({ preventScroll: true });
6089
+ const n = nodeOf(next.dataset.nodeId);
6090
+ canvas.bringIntoView({ x: n.position.x, y: n.position.y, width: widthOf(n), height: heightOf(n) });
6091
+ }
6092
+ function pressLetGo(ev) {
6093
+ released = ev.type === "pointerup" ? pressed : null;
6094
+ pressed = null;
6095
+ }
6096
+ if (multi) {
6097
+ canvas.onMarquee = (box) => pickItems(nodesIn(box), noting ? notesIn(box) : []);
6098
+ canvas.root.addEventListener("keydown", onTab);
6099
+ window.addEventListener("pointerup", pressLetGo, true);
6100
+ window.addEventListener("pointercancel", pressLetGo, true);
6101
+ }
6102
+ const CLIPBOARD_OFF = Object.freeze({ ok: false, reason: null });
6103
+ function refused(reason) {
6104
+ setMessage(reason, "warning");
6105
+ return { ok: false, reason };
6106
+ }
6107
+ const roRefusal = () => refused(readOnlyReason() || say(S, "readOnlyRefusal"));
6108
+ const titleOf2 = (n) => n.label || typeOf(n)?.label || n.type;
6109
+ const addable = (type) => Boolean(type) && !type.unavailable && offered(type);
6110
+ const copyRows = () => {
6111
+ const picks = pickedSet();
6112
+ return graph.nodes.filter((n) => picks.has(n.id) && addable(typeOf(n)));
6113
+ };
6114
+ const copyNoteRows = () => {
6115
+ if (!noting) return [];
6116
+ const picks = new Set(pickedNotes());
6117
+ return noteList().filter((x) => picks.has(x.id));
6118
+ };
6119
+ const pickedAny = () => pickedIds().length > 0 || copyNoteRows().length > 0;
6120
+ function copyText(rows, notes = []) {
6121
+ const ids = new Set(rows.map((n) => n.id));
6122
+ const groups = grouping ? groupList().filter((gr) => gr.nodes.every((id) => ids.has(id))) : [];
6123
+ return serialise({
6124
+ nodes: rows,
6125
+ connections: graph.connections.filter((c) => ids.has(c.source) && ids.has(c.target)),
6126
+ ...groups.length ? { groups } : {},
6127
+ ...notes.length ? { notes } : {}
6128
+ });
6129
+ }
6130
+ const tookSay = (key, rows, notes) => notes.length ? say(S, `${key}Items`, rows.length, notes.length) : say(S, key, rows.length);
6131
+ const tookIds = (rows, notes) => ({
6132
+ ok: true,
6133
+ ids: rows.map((n) => n.id),
6134
+ ...notes.length ? { notes: notes.map((x) => x.id) } : {}
6135
+ });
6136
+ function writeClipboard(text, times) {
6137
+ pasteRun = { text, times };
6138
+ if (clip === "memory") {
6139
+ copiedText = text;
6140
+ return true;
6141
+ }
6142
+ const failed = () => setMessage(say(S, "clipboardFailed"), "warning");
6143
+ try {
6144
+ const done = clip.write(text);
6145
+ if (done && typeof done.then === "function") done.then(null, failed);
6146
+ } catch {
6147
+ failed();
6148
+ return false;
6149
+ }
6150
+ return true;
6151
+ }
6152
+ function copyNodes() {
6153
+ if (!clip) return CLIPBOARD_OFF;
6154
+ if (!pickedAny()) return refused(say(S, "nothingPicked"));
6155
+ const rows = copyRows();
6156
+ const notes = copyNoteRows();
6157
+ if (!rows.length && !notes.length) return refused(say(S, "cannotCopy"));
6158
+ if (!writeClipboard(copyText(rows, notes), 0)) return { ok: false, reason: say(S, "clipboardFailed") };
6159
+ setMessage(tookSay("copied", rows, notes), "ok");
6160
+ return tookIds(rows, notes);
6161
+ }
6162
+ function cutNodes() {
6163
+ if (!clip) return CLIPBOARD_OFF;
6164
+ if (isReadOnly()) return roRefusal();
6165
+ if (!pickedAny()) return refused(say(S, "nothingPicked"));
6166
+ const rows = copyRows().filter((n) => canRemove(n, typeOf(n)));
6167
+ const notes = copyNoteRows();
6168
+ if (!rows.length && !notes.length) return refused(say(S, "cannotCut"));
6169
+ if (!writeClipboard(copyText(rows, notes), -1)) return { ok: false, reason: say(S, "clipboardFailed") };
6170
+ for (const n of rows) removeNode(graph, n.id);
6171
+ dropNotes(notes.map((x) => x.id), null);
6172
+ findingsBy = groupFindings(allFindings(), { graph });
6173
+ select(null);
6174
+ paintNodes();
6175
+ commit(!rows.length ? "flow:note:remove" : rows.length + notes.length > 1 ? "flow:nodes:remove" : "flow:node:remove");
6176
+ setMessage(tookSay("cutDone", rows, notes), "ok");
6177
+ keepFocus();
6178
+ return tookIds(rows, notes);
6179
+ }
6180
+ function stepsOf(text) {
6181
+ if (typeof text !== "string" || !text.trim()) return { reason: say(S, "nothingToPaste") };
6182
+ let raw;
6183
+ try {
6184
+ raw = JSON.parse(text);
6185
+ } catch {
6186
+ return { reason: say(S, "pasteNotSteps") };
6187
+ }
6188
+ const notesToo = noting && Array.isArray(raw?.notes) && raw.notes.length > 0;
6189
+ if (!raw || !Array.isArray(raw.nodes) || !raw.nodes.length && !notesToo || raw.nodes.some((n) => !n || typeof n !== "object" || !n.id || !n.type)) {
6190
+ return { reason: say(S, "pasteNotSteps") };
6191
+ }
6192
+ const g = normalise(raw);
6193
+ if (!g.nodes.length && !g.notes?.length) return { reason: say(S, "pasteNotSteps") };
6194
+ const odd = g.nodes.find((n) => !addable(typeOf(n)));
6195
+ if (odd) return { reason: typeOf(odd) ? say(S, "notAddable", titleOf2(odd)) : say(S, "unknownType", odd.type) };
6196
+ return { g };
6197
+ }
6198
+ function addCopies(g, offset, via, action) {
6199
+ const step = (canvas.gridSize || CANVAS_NODE.row) * offset;
6200
+ const titles = new Set(graph.nodes.map(titleOf2));
6201
+ const made = [];
6202
+ const idOf = /* @__PURE__ */ new Map();
6203
+ for (const from of g.nodes) {
6204
+ const id = nextId({ nodes: [...graph.nodes, ...made] }, from.type, { catalogue });
6205
+ const title = titleOf2(from);
6206
+ let label2 = from.label || null;
6207
+ if (titles.has(title)) {
6208
+ let i = 1;
6209
+ label2 = say(S, "copyLabel", title, i);
6210
+ while (titles.has(label2)) {
6211
+ i += 1;
6212
+ label2 = say(S, "copyLabel", title, i);
6213
+ }
6214
+ titles.add(label2);
6215
+ }
6216
+ const copy = {
6217
+ id,
6218
+ type: from.type,
6219
+ ...label2 ? { label: label2 } : {},
6220
+ config: structuredClone(from.config),
6221
+ position: { x: from.position.x + step, y: from.position.y + step }
6222
+ };
6223
+ if (typeof prepareCopy === "function") prepareCopy(copy, { from: from.id, via });
6224
+ const [clean2] = normalise({
6225
+ nodes: [{ ...copy, id, type: from.type, position: { ...copy.position } }],
6226
+ connections: []
6227
+ }).nodes;
6228
+ made.push(clean2);
6229
+ idOf.set(from.id, id);
6230
+ }
6231
+ graph.nodes.push(...made);
6232
+ for (const c of g.connections) {
6233
+ const source = idOf.get(c.source);
6234
+ const target = idOf.get(c.target);
6235
+ if (source && target) connect(graph, catalogue, { ...c, source, target }, { strings: S });
6236
+ }
6237
+ const noteIds = [];
6238
+ for (const from of noting ? g.notes || [] : []) {
6239
+ const id = freshId(noteList(), "note");
6240
+ graph.notes = [...noteList(), {
6241
+ ...structuredClone(from),
6242
+ id,
6243
+ position: { x: from.position.x + step, y: from.position.y + step }
6244
+ }];
6245
+ noteIds.push(id);
6246
+ }
6247
+ const groupIds = [];
6248
+ for (const gr of grouping ? g.groups || [] : []) {
6249
+ const members = gr.nodes.map((id2) => idOf.get(id2)).filter(Boolean);
6250
+ if (!members.length || members.length !== gr.nodes.length) continue;
6251
+ const id = freshId(groupList(), "group");
6252
+ setGroups([...groupList(), {
6253
+ id,
6254
+ ...gr.label ? { label: gr.label } : {},
6255
+ ...gr.tone ? { tone: gr.tone } : {},
6256
+ nodes: members
6257
+ }]);
6258
+ groupIds.push(id);
6259
+ }
6260
+ const ids = made.map((n) => n.id);
6261
+ if (connecting) cancelConnect();
6262
+ paintNodes();
6263
+ if (multi) pickItems(ids, noteIds);
6264
+ else select(ids.length ? { kind: "node", id: ids[0] } : { kind: "note", id: noteIds[0] });
6265
+ commit(action);
6266
+ setMessage("");
6267
+ return {
6268
+ ok: true,
6269
+ ids,
6270
+ ...noteIds.length ? { notes: noteIds } : {},
6271
+ ...groupIds.length ? { groups: groupIds } : {}
6272
+ };
6273
+ }
6274
+ function pasteNodes(text) {
6275
+ if (!clip) return CLIPBOARD_OFF;
6276
+ if (isReadOnly()) return roRefusal();
6277
+ const settle = (t) => {
6278
+ if (destroyed) return CLIPBOARD_OFF;
6279
+ if (isReadOnly()) return roRefusal();
6280
+ const { g, reason } = stepsOf(t);
6281
+ if (!g) return refused(reason);
6282
+ const times = pasteRun.text === t ? pasteRun.times + 1 : 1;
6283
+ pasteRun = { text: t, times };
6284
+ return addCopies(g, COPY_OFFSET * times, "paste", "flow:paste");
6285
+ };
6286
+ if (text !== void 0) return settle(text);
6287
+ if (clip === "memory") return settle(copiedText);
6288
+ let got;
6289
+ try {
6290
+ got = clip.read();
6291
+ } catch {
6292
+ return refused(say(S, "clipboardFailed"));
6293
+ }
6294
+ if (got && typeof got.then === "function") {
6295
+ return got.then(settle, () => destroyed ? CLIPBOARD_OFF : refused(say(S, "clipboardFailed")));
6296
+ }
6297
+ return settle(got);
4132
6298
  }
6299
+ function duplicateNodes() {
6300
+ if (!clip) return CLIPBOARD_OFF;
6301
+ if (isReadOnly()) return roRefusal();
6302
+ if (!pickedAny()) return refused(say(S, "nothingPicked"));
6303
+ const rows = copyRows();
6304
+ const notes = copyNoteRows();
6305
+ if (!rows.length && !notes.length) return refused(say(S, "cannotCopy"));
6306
+ return addCopies(JSON.parse(copyText(rows, notes)), COPY_OFFSET, "duplicate", "flow:node:duplicate");
6307
+ }
6308
+ const clipKey = (run, needsPick = true) => () => {
6309
+ if (!clip || needsPick && !pickedAny()) return false;
6310
+ run();
6311
+ return void 0;
6312
+ };
4133
6313
  const unbindKeys = bindFlowKeys(root, {
4134
6314
  undo: () => {
4135
6315
  undo();
@@ -4148,6 +6328,8 @@ function createCanvasEditor(host, options = {}) {
4148
6328
  },
4149
6329
  open: (ev) => {
4150
6330
  if (ev.target.closest?.(".twm-flow-graph__port")) return false;
6331
+ const noteEl = noting ? ev.target.closest?.("[data-note-id]") : null;
6332
+ if (noteEl && canvas.nodeLayer.contains(noteEl)) return editNote(noteEl.dataset.noteId) ? void 0 : false;
4151
6333
  const nodeEl = ev.target.closest?.("[data-node-id]");
4152
6334
  if (nodeEl && canvas.nodeLayer.contains(nodeEl)) {
4153
6335
  if (!(selected?.kind === "node" && selected.id === nodeEl.dataset.nodeId)) {
@@ -4158,14 +6340,37 @@ function createCanvasEditor(host, options = {}) {
4158
6340
  return false;
4159
6341
  },
4160
6342
  escape: () => {
4161
- if (!connecting) return false;
6343
+ if (!connecting) {
6344
+ if (!pickedNotes().length && !(multi && pickedIds().length)) return false;
6345
+ select(null);
6346
+ return void 0;
6347
+ }
4162
6348
  cancelConnect();
4163
6349
  setMessage("");
4164
6350
  return void 0;
4165
- }
6351
+ },
6352
+ copy: clipKey(copyNodes),
6353
+ cut: clipKey(cutNodes),
6354
+ paste: clipKey(() => pasteNodes(), false),
6355
+ duplicate: clipKey(duplicateNodes),
6356
+ selectAll: () => {
6357
+ if (!multi) return false;
6358
+ pickItems(graph.nodes.map((x) => x.id), noting ? noteList().map((x) => x.id) : []);
6359
+ return void 0;
6360
+ },
6361
+ group: () => grouping && groupPicked() ? void 0 : false,
6362
+ ungroup: () => grouping && ungroupPicked() ? void 0 : false,
6363
+ up: (ev) => nudge(0, -1, ev),
6364
+ down: (ev) => nudge(0, 1, ev),
6365
+ left: (ev) => nudge(-1, 0, ev),
6366
+ right: (ev) => nudge(1, 0, ev)
4166
6367
  });
4167
- root.append(toolbar, why, readonlyBanner, message, strip.el, banner, body);
6368
+ root.append(toolbar, why, readonlyBanner, message, ...offerBox ? [offerBox] : [], strip.el, banner, body);
4168
6369
  if (host) host.appendChild(root);
6370
+ function cancelFit() {
6371
+ if (fitFrame !== null) globalThis.cancelAnimationFrame?.(fitFrame);
6372
+ fitFrame = null;
6373
+ }
4169
6374
  function paintReadOnly() {
4170
6375
  root.classList.toggle("twm-flow-graph--readonly", isReadOnly());
4171
6376
  readonlyBanner.textContent = readOnlyReason() || (isReadOnly() ? say(S, "readOnlyRefusal") : "");
@@ -4182,18 +6387,19 @@ function createCanvasEditor(host, options = {}) {
4182
6387
  * was loaded is where undo starts. With `baseline: false` the stacks are
4183
6388
  * kept and the loaded content is the present they undo from.
4184
6389
  */
4185
- load({ graph: g = null, flowSettings: fs } = {}, { baseline = true, fit: andFit = true } = {}) {
6390
+ load({ graph: g = null, flowSettings: fs } = {}, { baseline = true, fit: andFit = true, viewport } = {}) {
4186
6391
  if (destroyed) return;
6392
+ editing?.done(false);
4187
6393
  if (connecting) cancelConnect();
6394
+ if (pendingOffer) clearOffer();
4188
6395
  graph = normalise(g);
4189
6396
  if (fs !== void 0) {
4190
6397
  settingsSchema = fs?.schema ?? settingsSchema;
4191
6398
  settingsTitle = fs?.title ?? settingsTitle;
4192
6399
  settingsValue = structuredClone(plainObject(fs?.value));
4193
6400
  }
4194
- if (selected?.kind === "node" && !nodeOf(selected.id)) selected = null;
4195
- if (selected?.kind === "edge" && !graph.connections.some((c) => sameEdge(c, selected.edge))) selected = null;
4196
- findingsBy = groupFindings(findings, { graph });
6401
+ prunePick();
6402
+ findingsBy = groupFindings(allFindings(), { graph });
4197
6403
  paintNodes();
4198
6404
  paintPanel();
4199
6405
  const state = snapshot();
@@ -4201,14 +6407,53 @@ function createCanvasEditor(host, options = {}) {
4201
6407
  else if (state !== history.settled) history.adopt({ ...history.keep(), settled: state }, state);
4202
6408
  paintToolbar();
4203
6409
  announceSelection();
4204
- if (andFit) {
4205
- if (fitFrame !== null) globalThis.cancelAnimationFrame?.(fitFrame);
6410
+ const at = viewportOf(viewport) || startView;
6411
+ startView = null;
6412
+ if (at) {
6413
+ cancelFit();
6414
+ canvas.setViewport(at);
6415
+ viewportBaseline();
6416
+ } else if (andFit) {
6417
+ cancelFit();
6418
+ const fitNow = () => {
6419
+ fit();
6420
+ viewportBaseline();
6421
+ };
4206
6422
  fitFrame = globalThis.requestAnimationFrame ? globalThis.requestAnimationFrame(() => {
4207
6423
  fitFrame = null;
4208
- fit();
4209
- }) : (fit(), null);
6424
+ fitNow();
6425
+ }) : (fitNow(), null);
4210
6426
  }
4211
6427
  },
6428
+ /**
6429
+ * Swap the step types while the editor is open — a type registered
6430
+ * after `load`. The palette is redrawn; a node whose type's definition
6431
+ * changed — what it is built from, not the object — is rebuilt (so a node
6432
+ * of a type that was unknown now draws); the rest keep their elements;
6433
+ * the graph, and so the history, are untouched.
6434
+ */
6435
+ setCatalogue(next) {
6436
+ if (destroyed) return;
6437
+ catalogue = toCatalogue(next, "setCatalogue");
6438
+ verdictOf = rulesFor(catalogue);
6439
+ if (connecting) cancelConnect();
6440
+ if (pendingOffer) clearOffer();
6441
+ paintPalette();
6442
+ paintNodes();
6443
+ paintPanel();
6444
+ },
6445
+ /** `{scale, tx, ty}`, as the kernel holds it. */
6446
+ getViewport: () => canvas.viewport(),
6447
+ /** Put the view there (clamped to the kernel's limits) — a fit still waiting
6448
+ * for its frame is dropped, and nothing is reported back. */
6449
+ setViewport(next) {
6450
+ const at = viewportOf(next);
6451
+ if (!at || destroyed) return false;
6452
+ cancelFit();
6453
+ canvas.setViewport(at);
6454
+ viewportBaseline();
6455
+ return true;
6456
+ },
4212
6457
  getGraph: () => normalise(graph),
4213
6458
  serialise: () => serialise(graph),
4214
6459
  /** The text `onChange` hands on and the history holds: the graph and the flow's settings. */
@@ -4216,11 +6461,51 @@ function createCanvasEditor(host, options = {}) {
4216
6461
  getFlowSettings: () => structuredClone(settingsValue),
4217
6462
  setFindings(list) {
4218
6463
  findings = findingsList(list);
4219
- findingsBy = groupFindings(findings, { graph });
4220
- strip.set(findings);
6464
+ findingsBy = groupFindings(allFindings(), { graph });
6465
+ paintStrip();
4221
6466
  paintNodes();
4222
- panel.setFindings(findings);
6467
+ panel.setFindings(allFindings());
6468
+ },
6469
+ /**
6470
+ * What the host shows on the nodes now — `{[id]: {badge, tone, ports:
6471
+ * {[name]: text}}}`, or null for none — replacing what it showed
6472
+ * before. Painted in place: nothing is rebuilt or recorded, `onChange`
6473
+ * is not called and the panel is not touched. A type's own `update`
6474
+ * runs for each node whose values changed, and reads them as `ctx.values`.
6475
+ */
6476
+ setNodeValues(next) {
6477
+ if (destroyed) return;
6478
+ const fresh = /* @__PURE__ */ new Map();
6479
+ for (const [id, v] of Object.entries(plainObject(next))) {
6480
+ const entry = valueEntry(v);
6481
+ if (entry) fresh.set(String(id), entry);
6482
+ }
6483
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
6484
+ const changed = [.../* @__PURE__ */ new Set([...liveValues.keys(), ...fresh.keys()])].filter((id) => !same(liveValues.get(id), fresh.get(id)));
6485
+ liveValues = fresh;
6486
+ let bodyChanged = false;
6487
+ painting = true;
6488
+ try {
6489
+ for (const id of changed) {
6490
+ const nodeRow = nodeOf(id);
6491
+ const n = nodeEls.get(id);
6492
+ if (!nodeRow || !n) continue;
6493
+ paintValues(n, nodeRow);
6494
+ bodyChanged = runUpdate(nodeRow) || bodyChanged;
6495
+ }
6496
+ } finally {
6497
+ painting = false;
6498
+ }
6499
+ if (bodyChanged) {
6500
+ findingsBy = groupFindings(allFindings(), { graph });
6501
+ paintNodes();
6502
+ paintStrip();
6503
+ panel.setFindings(allFindings());
6504
+ }
4223
6505
  },
6506
+ /** `collapsible`: fold a node to its header (true) or unfold it, one
6507
+ * `flow:node:collapse` entry; false when refused or nothing changes. */
6508
+ setCollapsed: (id, collapsed) => setCollapsed(String(id), collapsed),
4224
6509
  /** Draw a run on the canvas — never changes the graph and rebuilds no field. */
4225
6510
  setRunOverlay(next) {
4226
6511
  overlay = next && typeof next === "object" ? next : null;
@@ -4232,6 +6517,9 @@ function createCanvasEditor(host, options = {}) {
4232
6517
  setReadOnly(next) {
4233
6518
  ro = next;
4234
6519
  if (ro && connecting) cancelConnect();
6520
+ if (ro) editing?.done(false);
6521
+ if (isReadOnly()) renaming?.finish(false);
6522
+ if (ro && pendingOffer) clearOffer();
4235
6523
  dragCleanup?.();
4236
6524
  paintReadOnly();
4237
6525
  paintPalette();
@@ -4251,6 +6539,7 @@ function createCanvasEditor(host, options = {}) {
4251
6539
  select(target) {
4252
6540
  if (target === null || target === void 0) select(null);
4253
6541
  else if (target === "flow") select({ kind: "flow" });
6542
+ else if (multi && Array.isArray(target)) pickNodes(target.map(String));
4254
6543
  else if (nodeOf(String(target))) select({ kind: "node", id: String(target) });
4255
6544
  },
4256
6545
  /** Rebuild the panel's fields, keeping the focus and the caret — for a
@@ -4263,14 +6552,62 @@ function createCanvasEditor(host, options = {}) {
4263
6552
  },
4264
6553
  undo,
4265
6554
  redo,
4266
- arrange: () => arrangeGraph(),
4267
- fit,
6555
+ arrange: (opts) => {
6556
+ const done = arrangeGraph(opts);
6557
+ viewportBaseline();
6558
+ return done;
6559
+ },
6560
+ /** `multiSelect`: line the picked nodes up — left, center, right, top,
6561
+ * middle or bottom. False when refused (fewer than two, read only). */
6562
+ align: (kind) => multi ? alignNodes(kind) : false,
6563
+ /** `multiSelect`: equal gaps, horizontal or vertical (three or more). */
6564
+ distribute: (axis) => multi ? distributeNodes(axis) : false,
6565
+ fit: (opts) => {
6566
+ fit(opts);
6567
+ viewportBaseline();
6568
+ },
6569
+ /** `groups`: group these steps (default: the picked ones), each leaving
6570
+ * the group it was in — one `flow:group:add`. The new group's id, or null. */
6571
+ group: (ids = null, look = {}) => ids === null ? refusals().group ? null : groupNodes(pickedIds(), look) : groupNodes(Array.isArray(ids) ? ids.map(String) : [], look),
6572
+ /** `groups`: remove these groups (default: the ones the pick is in), their
6573
+ * steps staying where they are — one `flow:group:remove`. */
6574
+ ungroup: (ids = null) => ids === null ? ungroupPicked() : ungroupGroups((Array.isArray(ids) ? ids : [ids]).map(String)),
6575
+ /** `groups`: a group's `{label, tone}` — one `flow:group:label`. */
6576
+ setGroup: (id, look) => setGroupLook(String(id), look || {}),
6577
+ /** `groups`: exactly these steps in the group — one `flow:group:members`. */
6578
+ setGroupMembers: (id, ids) => setGroupMembers(String(id), Array.isArray(ids) ? ids.map(String) : []),
6579
+ /** `notes`: add a note — one `flow:note:add`. A copy of it, or null. */
6580
+ addNote: (note) => addNote(note || {}),
6581
+ /** `notes`: a note's text — one `flow:note:edit`. */
6582
+ editNote: (id, text) => setNoteText(String(id), text),
6583
+ /** `notes`: put a note at `{x, y}` — one `flow:note:move`. */
6584
+ moveNote: (id, position) => moveNote(String(id), position),
6585
+ /** `notes`: take a note away — one `flow:note:remove`. */
6586
+ removeNote: (id) => noting && !isReadOnly() ? dropNotes([String(id)]) : false,
6587
+ /** `clipboard`: copy the picked steps and the lines among them (and the picked notes, the groups wholly
6588
+ * inside); `{ok, ids, notes?}` or `{ok: false, reason}`. */
6589
+ copy: () => copyNodes(),
6590
+ /** `clipboard`: copy, then remove what was copied (one remove entry). Refused read only. */
6591
+ cut: () => cutNodes(),
6592
+ /** `clipboard`: paste `text`, or what the clipboard holds — a promise when the consumer's `read` is async. */
6593
+ paste: (text) => pasteNodes(text),
6594
+ /** `clipboard`: a copy of the picked steps beside them, one `flow:node:duplicate` entry. */
6595
+ duplicate: () => duplicateNodes(),
4268
6596
  destroy() {
4269
6597
  if (destroyed) return;
6598
+ editing?.done(false);
6599
+ if (wheelTimer !== null) viewportSettled();
6600
+ pressEnd?.();
4270
6601
  dragCleanup?.();
4271
- if (fitFrame !== null) globalThis.cancelAnimationFrame?.(fitFrame);
6602
+ cancelFit();
6603
+ renaming?.finish(false);
6604
+ for (const id of [...inlines.keys()]) dropInline(id);
6605
+ miniSized?.disconnect();
4272
6606
  closeFlowPopovers();
4273
6607
  unbindKeys();
6608
+ canvas.root.removeEventListener("keydown", onTab);
6609
+ window.removeEventListener("pointerup", pressLetGo, true);
6610
+ window.removeEventListener("pointercancel", pressLetGo, true);
4274
6611
  panel.destroy();
4275
6612
  strip.destroy();
4276
6613
  destroyed = true;
@@ -4283,9 +6620,18 @@ function createCanvasEditor(host, options = {}) {
4283
6620
  paintNodes();
4284
6621
  paintPanel();
4285
6622
  paintToolbar();
6623
+ viewportBaseline();
4286
6624
  history.baseline(snapshot());
4287
6625
  return api;
4288
6626
  }
6627
+ function viewportOf(v) {
6628
+ if (!v || typeof v !== "object") return null;
6629
+ const scale = Number(v.scale);
6630
+ const tx = Number(v.tx);
6631
+ const ty = Number(v.ty);
6632
+ if (!(scale > 0) || !Number.isFinite(scale) || !Number.isFinite(tx) || !Number.isFinite(ty)) return null;
6633
+ return { scale, tx, ty };
6634
+ }
4289
6635
 
4290
6636
  // src/flow/outline/strings.js
4291
6637
  var plural2 = (n, one, many) => n === 1 ? one : many.replace("{n}", String(n));
@@ -8045,9 +10391,11 @@ var pipeline_exports = {};
8045
10391
  __export(pipeline_exports, {
8046
10392
  LANE_ACTIONS: () => LANE_ACTIONS,
8047
10393
  PARAMETER_NAME: () => PARAMETER_NAME,
10394
+ PIPELINE_STRINGS: () => PIPELINE_STRINGS,
8048
10395
  addSource: () => addSource,
8049
10396
  addStepAfter: () => addStepAfter,
8050
10397
  downstreamOf: () => downstreamOf,
10398
+ duplicateStep: () => duplicateStep,
8051
10399
  emptyPipeline: () => emptyPipeline,
8052
10400
  inputCandidates: () => inputCandidates,
8053
10401
  inputOf: () => inputOf,
@@ -8069,6 +10417,8 @@ var LANE_ACTIONS = Object.freeze([
8069
10417
  "flow:step:remove",
8070
10418
  "flow:step:label",
8071
10419
  "flow:step:config",
10420
+ "flow:step:duplicate",
10421
+ "flow:step:menu",
8072
10422
  "flow:join:set",
8073
10423
  "flow:source:add",
8074
10424
  "flow:parameter:add",
@@ -8076,6 +10426,11 @@ var LANE_ACTIONS = Object.freeze([
8076
10426
  "flow:parameter:remove",
8077
10427
  "flow:settings"
8078
10428
  ]);
10429
+ var PIPELINE_STRINGS = Object.freeze({
10430
+ copyLabel: (title, n) => n > 1 ? `${title} (copy ${n})` : `${title} (copy)`,
10431
+ duplicateNoStep: (id) => `There is no step \u2018${id}\u2019 to copy.`,
10432
+ duplicateUnavailable: (title, why) => `\u2018${title}\u2019 cannot be copied: ${why}`
10433
+ });
8079
10434
  var PARAMETER_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
8080
10435
  var ID_OK2 = /^[A-Za-z0-9_-]{1,64}$/;
8081
10436
  var isObject3 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
@@ -8248,6 +10603,29 @@ function removeStep(pipeline, catalogue, id) {
8248
10603
  pipeline.nodes = pipeline.nodes.filter((n) => n.id !== id);
8249
10604
  return true;
8250
10605
  }
10606
+ function duplicateStep(pipeline, catalogue, id, { strings = null } = {}) {
10607
+ const S = { ...PIPELINE_STRINGS, ...isObject3(strings) ? strings : {} };
10608
+ const original = findNode(pipeline, id);
10609
+ if (!original) return { ok: false, reason: S.duplicateNoStep(String(id)) };
10610
+ const type = catalogue?.get?.(original.type) ?? null;
10611
+ const title = original.label || type?.label || String(original.type);
10612
+ if (type?.unavailable) return { ok: false, reason: S.duplicateUnavailable(title, String(type.unavailable)) };
10613
+ const titles = new Set(pipeline.nodes.map((n) => n.label || catalogue?.get?.(n.type)?.label || String(n.type)));
10614
+ let label = S.copyLabel(title, 1);
10615
+ for (let i = 2; titles.has(label); i += 1) label = S.copyLabel(title, i);
10616
+ const copy = {
10617
+ id: nextStepId(pipeline, original.type, catalogue),
10618
+ type: original.type,
10619
+ label,
10620
+ config: isObject3(original.config) ? clone2(original.config) : {}
10621
+ };
10622
+ for (const [k, v] of Object.entries(original)) if (!NODE_KEYS.includes(k)) copy[k] = clone2(v);
10623
+ pipeline.nodes.splice(pipeline.nodes.indexOf(original) + 1, 0, copy);
10624
+ for (const c of pipeline.connections.filter((x) => x.targetId === id)) {
10625
+ pipeline.connections.push({ ...clone2(c), id: nextConnectionId(pipeline), targetId: copy.id });
10626
+ }
10627
+ return { ok: true, node: copy };
10628
+ }
8251
10629
  function mayFeed(pipeline, stepId, sourceId) {
8252
10630
  if (!sourceId || sourceId === stepId) return false;
8253
10631
  return !downstreamOf(pipeline, stepId).has(sourceId);
@@ -8435,6 +10813,7 @@ var LANE_STRINGS = Object.freeze({
8435
10813
  stepMenu: (title) => `${title}: what to do`,
8436
10814
  openSettings: "Open its settings",
8437
10815
  addStepAfter: "Add a step after it",
10816
+ unavailable: "Not available here.",
8438
10817
  // ── the toolbar ────────────────────────────────────────────────────
8439
10818
  toolbar: "Flow",
8440
10819
  readOnlyTitle: "This flow is open to read only.",
@@ -8559,6 +10938,7 @@ function createLaneEditor(host, options = {}) {
8559
10938
  let travelling = null;
8560
10939
  let shownColumns = "";
8561
10940
  let panelReadOnly = false;
10941
+ let panelPainting = false;
8562
10942
  const node = (id) => pipeline.nodes.find((n) => n.id === id) ?? null;
8563
10943
  const typeOf = (n) => catalogue.get(n?.type) ?? null;
8564
10944
  const typeLabel = (n) => typeOf(n)?.label ?? String(n?.type ?? "");
@@ -8590,6 +10970,7 @@ function createLaneEditor(host, options = {}) {
8590
10970
  let undoBtn = null;
8591
10971
  let redoBtn = null;
8592
10972
  let statusEl = null;
10973
+ let noticeEl = null;
8593
10974
  let showPreviewBtn = null;
8594
10975
  let showRunBtn = null;
8595
10976
  const actionButtons = /* @__PURE__ */ new Map();
@@ -8635,6 +11016,10 @@ function createLaneEditor(host, options = {}) {
8635
11016
  statusEl = el("span", "twm-flow-lanes__status");
8636
11017
  statusEl.setAttribute("aria-live", "polite");
8637
11018
  toolbar.appendChild(statusEl);
11019
+ noticeEl = el("span", "twm-flow-lanes__notice");
11020
+ noticeEl.setAttribute("role", "status");
11021
+ noticeEl.hidden = true;
11022
+ toolbar.appendChild(noticeEl);
8638
11023
  toolbar.appendChild(el("span", "twm-flow-lanes__spacer"));
8639
11024
  const showId = uid("lanes-show");
8640
11025
  const showLabel = el("span", "twm-flow-lanes__show-label", say(S, "stepsShow"));
@@ -8797,6 +11182,7 @@ function createLaneEditor(host, options = {}) {
8797
11182
  function commit(action, key = null, { preview = true } = {}) {
8798
11183
  const moved = history.commit(snapshot(), action, key);
8799
11184
  if (!moved) return false;
11185
+ notice("");
8800
11186
  report(action, key);
8801
11187
  if (preview) runner.schedule();
8802
11188
  return true;
@@ -9207,6 +11593,15 @@ function createLaneEditor(host, options = {}) {
9207
11593
  }
9208
11594
  const n = selected && selected !== "flow" ? node(selected) : null;
9209
11595
  settingsHint.hidden = true;
11596
+ panelPainting = true;
11597
+ try {
11598
+ paintPanelBody(n);
11599
+ } finally {
11600
+ panelPainting = false;
11601
+ }
11602
+ panel.setFindings(findings);
11603
+ }
11604
+ function paintPanelBody(n) {
9210
11605
  if (n) {
9211
11606
  panel.show(stepPanelSpec(n));
9212
11607
  shownColumns = JSON.stringify(inputColumns(n.id));
@@ -9227,13 +11622,12 @@ function createLaneEditor(host, options = {}) {
9227
11622
  settingsHint.hidden = false;
9228
11623
  shownColumns = "";
9229
11624
  }
9230
- panel.setFindings(findings);
9231
11625
  }
9232
- function reshowForColumns() {
11626
+ function reshowForColumns({ leaving = false } = {}) {
9233
11627
  if (!panel || !selected || selected === "flow" || !node(selected)) return;
9234
11628
  const now = JSON.stringify(inputColumns(selected));
9235
11629
  if (now === shownColumns) return;
9236
- if (panel.el.contains(root.ownerDocument.activeElement)) return;
11630
+ if (!leaving && panel.el.contains(root.ownerDocument.activeElement)) return;
9237
11631
  paintPanel();
9238
11632
  }
9239
11633
  function availableDataTabs() {
@@ -9513,6 +11907,46 @@ function createLaneEditor(host, options = {}) {
9513
11907
  options.onSelect?.({ kind: selected ? "step" : null, id: selected });
9514
11908
  return true;
9515
11909
  }
11910
+ function duplicateRefusal(id) {
11911
+ if (isReadOnly()) return readOnlyReason()[0] ?? say(S, "readOnlyTitle");
11912
+ const r = duplicateStep(normalisePipeline(pipeline), catalogue, id, { strings: options.strings });
11913
+ return r.ok ? null : r.reason;
11914
+ }
11915
+ function duplicate(id) {
11916
+ if (isReadOnly() || !node(id)) return false;
11917
+ const r = duplicateStep(pipeline, catalogue, id, { strings: options.strings });
11918
+ if (!r.ok) {
11919
+ notice(r.reason);
11920
+ return false;
11921
+ }
11922
+ selected = r.node.id;
11923
+ commit("flow:step:duplicate");
11924
+ afterShapeChange(r.node.id);
11925
+ options.onSelect?.({ kind: "step", id: r.node.id });
11926
+ return true;
11927
+ }
11928
+ function notice(text) {
11929
+ if (!noticeEl) return;
11930
+ noticeEl.textContent = String(text ?? "");
11931
+ noticeEl.hidden = !noticeEl.textContent;
11932
+ }
11933
+ async function runHostItem(item, id) {
11934
+ if (isReadOnly() || !node(id)) return false;
11935
+ const before = serialisePipeline(pipeline);
11936
+ const draft = normalisePipeline(pipeline);
11937
+ const step = clone3(node(id));
11938
+ const answer = await item.run?.({ step, pipeline: draft, editor: api });
11939
+ if (answer === false || destroyed || isReadOnly()) return false;
11940
+ if (serialisePipeline(pipeline) !== before) return false;
11941
+ if (serialisePipeline(draft) === before) return false;
11942
+ pipeline = normalisePipeline(draft);
11943
+ relayout();
11944
+ if (selected && selected !== "flow" && !node(selected)) selected = firstStep();
11945
+ commit("flow:step:menu");
11946
+ paintAll();
11947
+ focusCard(selected);
11948
+ return true;
11949
+ }
9516
11950
  function changeSetting(stepId, key, value) {
9517
11951
  if (isReadOnly()) return;
9518
11952
  if (stepId === null) {
@@ -9667,27 +12101,56 @@ function createLaneEditor(host, options = {}) {
9667
12101
  const menu = el("div", "twm-flow-lanes__menu");
9668
12102
  menu.setAttribute("role", "menu");
9669
12103
  let handle = null;
9670
- const item = (label, glyph, run, { danger = false, disabled = false } = {}) => {
12104
+ const item = (label, glyph, run, { danger = false, refusal = null, id: itemId = null } = {}) => {
9671
12105
  const b = el("button", `twm-flow-lanes__menu-item${danger ? " twm-flow-lanes__menu-item--danger" : ""}`);
12106
+ b.classList.toggle("twm-flow-lanes__menu-item--refused", Boolean(refusal));
9672
12107
  b.type = "button";
9673
12108
  b.setAttribute("role", "menuitem");
9674
- b.disabled = disabled;
9675
- b.append(icon(glyph), el("span", "", label));
12109
+ if (itemId) b.dataset.item = itemId;
12110
+ const words3 = el("span", "twm-flow-lanes__menu-words");
12111
+ words3.appendChild(el("span", "twm-flow-lanes__menu-label", label));
12112
+ if (refusal) {
12113
+ b.disabled = true;
12114
+ b.setAttribute("aria-disabled", "true");
12115
+ words3.appendChild(el("span", "twm-flow-lanes__menu-reason", refusal));
12116
+ }
12117
+ b.append(icon(glyph || ""), words3);
9676
12118
  b.addEventListener("click", () => {
12119
+ if (b.disabled) return;
9677
12120
  handle?.close("chosen");
9678
12121
  run();
9679
12122
  });
9680
12123
  menu.appendChild(b);
9681
12124
  return b;
9682
12125
  };
9683
- const first = item(say(S, "openSettings"), "tune", () => openSettingsOf(id));
12126
+ const first = item(say(S, "openSettings"), "tune", () => openSettingsOf(id), { id: "settings" });
9684
12127
  if (canAddAfter(id)) {
9685
12128
  item(say(S, "addStepAfter"), "add", () => {
9686
12129
  const add = [...surface.querySelectorAll(".twm-flow-lanes__node")].find((w) => w.dataset.step === id)?.querySelector(".twm-flow-lanes__add");
9687
12130
  if (add) pickAfter(id, add);
9688
- });
12131
+ }, { id: "add" });
12132
+ }
12133
+ item(say(S, "duplicate"), "content_copy", () => duplicate(id), { refusal: duplicateRefusal(id), id: "duplicate" });
12134
+ if (!isReadOnly()) item(say(S, "removeStep"), "delete", () => remove(id), { danger: true, id: "remove" });
12135
+ const extra = typeof options.menuItems === "function" ? options.menuItems(clone3(node(id))) : null;
12136
+ const hosts = (Array.isArray(extra) ? extra : []).filter((x) => isObject4(x) && x.label !== void 0);
12137
+ if (hosts.length) {
12138
+ const sep = el("div", "twm-flow-lanes__menu-sep");
12139
+ sep.setAttribute("role", "separator");
12140
+ menu.appendChild(sep);
12141
+ }
12142
+ const roWhy = isReadOnly() ? readOnlyReason()[0] ?? say(S, "readOnlyTitle") : null;
12143
+ for (const h of hosts) {
12144
+ const refusal = roWhy ?? (h.disabled ? String(h.reason || say(S, "unavailable")) : null);
12145
+ item(
12146
+ String(h.label),
12147
+ h.icon || "",
12148
+ () => {
12149
+ runHostItem(h, id);
12150
+ },
12151
+ { danger: Boolean(h.danger), refusal, id: h.id !== void 0 ? String(h.id) : null }
12152
+ );
9689
12153
  }
9690
- if (!isReadOnly()) item(say(S, "removeStep"), "delete", () => remove(id), { danger: true });
9691
12154
  menu.addEventListener("keydown", (ev) => {
9692
12155
  if (ev.key !== "ArrowDown" && ev.key !== "ArrowUp") return;
9693
12156
  ev.preventDefault();
@@ -9733,6 +12196,14 @@ function createLaneEditor(host, options = {}) {
9733
12196
  remove(id);
9734
12197
  return true;
9735
12198
  },
12199
+ duplicate: (ev) => {
12200
+ if (!(inSurface(ev) || inTabs(ev))) return false;
12201
+ const id = stepAt(ev) ?? selected;
12202
+ if (!id || id === "flow" || !node(id)) return false;
12203
+ if (isReadOnly()) notice(readOnlyReason()[0] ?? say(S, "readOnlyTitle"));
12204
+ else duplicate(id);
12205
+ return true;
12206
+ },
9736
12207
  rename: () => {
9737
12208
  if (!selected || selected === "flow" || !panel) return false;
9738
12209
  const t = panel.el.querySelector("[data-twm-flow-title]");
@@ -9762,6 +12233,12 @@ function createLaneEditor(host, options = {}) {
9762
12233
  if (focusCard(selected)) ev.preventDefault();
9763
12234
  };
9764
12235
  settingsCol?.addEventListener("keydown", onPanelKey);
12236
+ const onPanelLeave = (ev) => {
12237
+ const to = ev.relatedTarget;
12238
+ if (panelPainting || to && (panel.el.contains(to) || to.closest?.(".twm-flow-popover"))) return;
12239
+ reshowForColumns({ leaving: true });
12240
+ };
12241
+ settingsCol?.addEventListener("focusout", onPanelLeave);
9765
12242
  function arrow(ev, dir) {
9766
12243
  if (inTabs(ev)) {
9767
12244
  if (dir !== "left" && dir !== "right") return false;
@@ -9907,6 +12384,7 @@ function createLaneEditor(host, options = {}) {
9907
12384
  lastRunAsk?.abort();
9908
12385
  unbindKeys();
9909
12386
  settingsCol?.removeEventListener("keydown", onPanelKey);
12387
+ settingsCol?.removeEventListener("focusout", onPanelLeave);
9910
12388
  closeFlowPopovers();
9911
12389
  table?.dispose?.();
9912
12390
  table = null;
@@ -9943,6 +12421,7 @@ export {
9943
12421
  LANE_GEOMETRY,
9944
12422
  LAYOUT_STRINGS as LANE_LAYOUT_STRINGS,
9945
12423
  LANE_PAD,
12424
+ PIPELINE_STRINGS as LANE_PIPELINE_STRINGS,
9946
12425
  LANE_STRINGS,
9947
12426
  LOGIC_GRAPH_STRINGS,
9948
12427
  OUTLINE_ACTIONS,
@@ -9955,6 +12434,7 @@ export {
9955
12434
  TEMPLATE_REFERENCES,
9956
12435
  TONES,
9957
12436
  UI_HINTS,
12437
+ addConnection,
9958
12438
  addNode,
9959
12439
  admittedTypes,
9960
12440
  alwaysBefore,
@@ -9963,6 +12443,9 @@ export {
9963
12443
  button,
9964
12444
  canConnect,
9965
12445
  nodeBottom as canvasNodeBottom,
12446
+ nodeWidth as canvasNodeWidth,
12447
+ pathMidpoint as canvasPathMidpoint,
12448
+ pathPoints as canvasPathPoints,
9966
12449
  portAnchor as canvasPortAnchor,
9967
12450
  checkSettingsSchema,
9968
12451
  closeFlowPopovers,