flexdesk 0.16.1 → 0.17.1

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 (262) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2435 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/canvas.js +18 -0
  6. package/charts.js +21 -0
  7. package/core.js +14 -0
  8. package/css/base.css +1834 -75
  9. package/css/flexdesk.css +3165 -159
  10. package/css/overrides.css +16 -0
  11. package/css/reset.css +0 -0
  12. package/css/tokens.css +0 -0
  13. package/dist/canvas.js +6 -239
  14. package/dist/canvas.js.map +3 -3
  15. package/dist/charts.js +707 -18
  16. package/dist/charts.js.map +4 -4
  17. package/dist/chunk-3PHCPZHT.js +0 -0
  18. package/dist/chunk-3PHCPZHT.js.map +0 -0
  19. package/dist/chunk-4A7SJC3T.js +503 -0
  20. package/dist/chunk-4A7SJC3T.js.map +7 -0
  21. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  22. package/dist/chunk-CVMCZZX2.js.map +7 -0
  23. package/dist/chunk-D3COLZZ2.js +1311 -0
  24. package/dist/chunk-D3COLZZ2.js.map +7 -0
  25. package/dist/chunk-FOOS3T5L.js +0 -0
  26. package/dist/chunk-FOOS3T5L.js.map +0 -0
  27. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  28. package/dist/chunk-GOQLR5WR.js.map +7 -0
  29. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  30. package/dist/chunk-HNU6MM2B.js.map +7 -0
  31. package/dist/chunk-HYJYYEPK.js +375 -0
  32. package/dist/chunk-HYJYYEPK.js.map +7 -0
  33. package/dist/chunk-JKZLNKG6.js +2041 -0
  34. package/dist/chunk-JKZLNKG6.js.map +7 -0
  35. package/dist/chunk-JYWURG5T.js +0 -0
  36. package/dist/chunk-JYWURG5T.js.map +0 -0
  37. package/dist/{chunk-FBGP5KN5.js → chunk-LWV23YVE.js} +5073 -532
  38. package/dist/chunk-LWV23YVE.js.map +7 -0
  39. package/dist/chunk-N7HC3ADV.js +30 -0
  40. package/dist/chunk-N7HC3ADV.js.map +7 -0
  41. package/dist/{chunk-RQM5Z2MU.js → chunk-RMTRWWEO.js} +633 -311
  42. package/dist/chunk-RMTRWWEO.js.map +7 -0
  43. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  44. package/dist/chunk-UURZ37SD.js.map +7 -0
  45. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  46. package/dist/chunk-UWO43BZU.js.map +7 -0
  47. package/dist/chunk-WVFGV5FT.js +0 -0
  48. package/dist/chunk-WVFGV5FT.js.map +0 -0
  49. package/dist/core.js +170 -118
  50. package/dist/core.js.map +2 -2
  51. package/dist/editor.js +156 -70
  52. package/dist/editor.js.map +2 -2
  53. package/dist/flexdesk.css +3165 -159
  54. package/dist/flow.js +2789 -306
  55. package/dist/flow.js.map +4 -4
  56. package/dist/host.js +109 -3
  57. package/dist/host.js.map +3 -3
  58. package/dist/reset.css +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js +0 -0
  60. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  61. package/dist/tiles.js +657 -234
  62. package/dist/tiles.js.map +4 -4
  63. package/dist/tokens.css +0 -0
  64. package/dist/widgets.js +3290 -2124
  65. package/dist/widgets.js.map +4 -4
  66. package/dist/wm.js +1227 -347
  67. package/dist/wm.js.map +4 -4
  68. package/editor.js +20 -0
  69. package/flow.js +21 -0
  70. package/host.js +14 -0
  71. package/package.json +12 -1
  72. package/src/canvas/canvas_adapter.js +5 -0
  73. package/src/canvas/edge_router.js +0 -0
  74. package/src/canvas/graph/connector_router.js +0 -0
  75. package/src/canvas/graph/history_service.js +0 -0
  76. package/src/canvas/graph/node_platform.js +0 -0
  77. package/src/canvas/index.js +1 -0
  78. package/src/canvas/layered_layout.js +244 -0
  79. package/src/charts/chart_types.js +0 -0
  80. package/src/charts/data_series_plot_window.js +0 -0
  81. package/src/charts/downsample.js +2 -2
  82. package/src/charts/plot_config.js +53 -29
  83. package/src/charts/plot_config_editor.js +627 -0
  84. package/src/charts/plot_grid.js +449 -0
  85. package/src/charts/plot_popout_window.js +103 -317
  86. package/src/charts/plotly_wrapper.js +22 -2
  87. package/src/charts/series_transforms.js +206 -0
  88. package/src/core/event_bus.js +0 -0
  89. package/src/core/logging.js +1 -1
  90. package/src/core/settings.js +105 -19
  91. package/src/core/state_guard.js +93 -51
  92. package/src/core/state_guard_config.js +85 -74
  93. package/src/core/state_machine.js +0 -0
  94. package/src/editor/editor_pane.js +3 -3
  95. package/src/editor/monaco_editor_factory.js +124 -60
  96. package/src/editor/monaco_loader.js +103 -28
  97. package/src/editor/notebook_search.js +0 -0
  98. package/src/editor/notebook_tab_bar.js +38 -52
  99. package/src/editor/notebook_undo_manager.js +0 -0
  100. package/src/editor/split_pane_container.js +6 -3
  101. package/src/flow/canvas_editor/connections.js +90 -0
  102. package/src/flow/canvas_editor/editor.js +2351 -153
  103. package/src/flow/canvas_editor/index.js +2 -1
  104. package/src/flow/canvas_editor/layout.js +119 -12
  105. package/src/flow/canvas_editor/strings.js +80 -0
  106. package/src/flow/kit/always_before.js +0 -0
  107. package/src/flow/kit/catalogue.js +97 -3
  108. package/src/flow/kit/chip_input.js +0 -0
  109. package/src/flow/kit/dom.js +0 -0
  110. package/src/flow/kit/findings.js +0 -0
  111. package/src/flow/kit/history.js +0 -0
  112. package/src/flow/kit/index.js +3 -3
  113. package/src/flow/kit/keys.js +7 -1
  114. package/src/flow/kit/logic_graph.js +97 -15
  115. package/src/flow/kit/references.js +0 -0
  116. package/src/flow/kit/settings_panel.js +0 -0
  117. package/src/flow/kit/settings_schema.js +2 -1
  118. package/src/flow/kit/step_picker.js +0 -0
  119. package/src/flow/kit/strings.js +3 -1
  120. package/src/flow/kit/value_picker.js +0 -0
  121. package/src/flow/kit/widgets.js +86 -109
  122. package/src/flow/lanes/editor.js +136 -14
  123. package/src/flow/lanes/index.js +3 -1
  124. package/src/flow/lanes/layout.js +0 -0
  125. package/src/flow/lanes/pipeline.js +44 -2
  126. package/src/flow/lanes/preview.js +0 -0
  127. package/src/flow/lanes/strings.js +1 -0
  128. package/src/flow/outline/editor.js +0 -0
  129. package/src/flow/outline/index.js +0 -0
  130. package/src/flow/outline/mapping.js +0 -0
  131. package/src/flow/outline/menu.js +0 -0
  132. package/src/flow/outline/operations.js +0 -0
  133. package/src/flow/outline/recognise.js +0 -0
  134. package/src/flow/outline/strings.js +0 -0
  135. package/src/help/help_modal.js +168 -42
  136. package/src/help/help_registry.js +12 -1
  137. package/src/host/external_links.js +0 -0
  138. package/src/host/file_picker.js +117 -0
  139. package/src/host/host.js +10 -2
  140. package/src/host/photino_host.js +9 -0
  141. package/src/host/photino_host.md +21 -3
  142. package/src/host/pywebview_host.js +10 -0
  143. package/src/tiles/chart_tiles.js +53 -245
  144. package/src/tiles/config_schema.js +8 -0
  145. package/src/tiles/figure_tile.js +322 -0
  146. package/src/tiles/layout_persistence.js +3 -3
  147. package/src/tiles/list_tiles.js +7 -7
  148. package/src/tiles/scope_picker.js +0 -0
  149. package/src/tiles/tile_base.js +1 -1
  150. package/src/tiles/tile_config_form.js +442 -0
  151. package/src/tiles/tile_grid.js +2 -0
  152. package/src/tiles/tile_registry.js +0 -0
  153. package/src/tiling/bottom_panel_tabs.js +67 -99
  154. package/src/tiling/command_palette.js +64 -19
  155. package/src/tiling/content_registry.js +4 -1
  156. package/src/tiling/desktops.js +11 -1
  157. package/src/tiling/entity_sources.js +0 -0
  158. package/src/tiling/keymap.js +403 -128
  159. package/src/tiling/kind_taxonomy.js +78 -0
  160. package/src/tiling/landing_table.js +0 -0
  161. package/src/tiling/loading_overlay.js +0 -0
  162. package/src/tiling/nav_panel.js +59 -10
  163. package/src/tiling/page_factory.js +5 -5
  164. package/src/tiling/panel_availability.js +49 -0
  165. package/src/tiling/panel_keys.js +0 -0
  166. package/src/tiling/shell.js +114 -12
  167. package/src/tiling/tab_strip.js +0 -0
  168. package/src/tiling/tile_breadcrumb.js +0 -0
  169. package/src/tiling/tile_renderer.js +105 -9
  170. package/src/tiling/tile_tab_menu.js +0 -0
  171. package/src/tiling/tile_tree.js +0 -0
  172. package/src/tiling/wm.js +562 -30
  173. package/src/tiling/zoom.js +0 -0
  174. package/src/ui/base/component_base.js +0 -0
  175. package/src/ui/base/controller_base.js +0 -0
  176. package/src/ui/base/page_base.js +0 -0
  177. package/src/ui/components/about_dialog.js +0 -0
  178. package/src/ui/components/action_dropdown.js +30 -2
  179. package/src/ui/components/alert_log.js +0 -0
  180. package/src/ui/components/alerts_pane.js +0 -0
  181. package/src/ui/components/attribute_list_editor.js +0 -0
  182. package/src/ui/components/autocomplete_field.js +0 -0
  183. package/src/ui/components/cell_editor_host.js +293 -0
  184. package/src/ui/components/cell_editors.js +480 -0
  185. package/src/ui/components/cell_grid.js +761 -0
  186. package/src/ui/components/cell_paste.js +243 -0
  187. package/src/ui/components/cell_selection.js +220 -0
  188. package/src/ui/components/choice_controls.js +351 -0
  189. package/src/ui/components/color_field.js +211 -0
  190. package/src/ui/components/color_list_field.js +210 -0
  191. package/src/ui/components/column_fit.js +0 -0
  192. package/src/ui/components/column_groups.js +314 -0
  193. package/src/ui/components/column_order.js +142 -0
  194. package/src/ui/components/computing_status_window.js +98 -25
  195. package/src/ui/components/confirm_dialog.js +81 -38
  196. package/src/ui/components/context_menu.js +2 -2
  197. package/src/ui/components/conversation_view.js +440 -0
  198. package/src/ui/components/crosstab.js +289 -0
  199. package/src/ui/components/data_table.js +696 -71
  200. package/src/ui/components/detail_header.js +22 -11
  201. package/src/ui/components/document_outline.js +397 -0
  202. package/src/ui/components/drag_reorder.js +0 -0
  203. package/src/ui/components/fact_table.js +0 -0
  204. package/src/ui/components/fill_handle.js +322 -0
  205. package/src/ui/components/gallery_picker.js +19 -8
  206. package/src/ui/components/header_actions.js +55 -0
  207. package/src/ui/components/inline_renamer.js +0 -0
  208. package/src/ui/components/job_progress.js +51 -0
  209. package/src/ui/components/managed_window.js +27 -4
  210. package/src/ui/components/menu_bar.js +745 -0
  211. package/src/ui/components/modal.js +449 -75
  212. package/src/ui/components/notification_history.js +3 -3
  213. package/src/ui/components/progress.js +483 -0
  214. package/src/ui/components/record_list.js +0 -0
  215. package/src/ui/components/row_activation.js +0 -0
  216. package/src/ui/components/section_stack.js +298 -0
  217. package/src/ui/components/series.js +173 -0
  218. package/src/ui/components/settings_view.js +634 -0
  219. package/src/ui/components/slide_out_panel.js +43 -6
  220. package/src/ui/components/slider_field.js +275 -18
  221. package/src/ui/components/sortable_list.js +0 -0
  222. package/src/ui/components/staged_edits.js +361 -0
  223. package/src/ui/components/staged_history.js +172 -0
  224. package/src/ui/components/table_filter_chips.js +0 -0
  225. package/src/ui/components/table_state_store.js +0 -0
  226. package/src/ui/components/table_structure.js +182 -0
  227. package/src/ui/components/table_values.js +0 -0
  228. package/src/ui/components/tabs.js +497 -0
  229. package/src/ui/components/tag_input.js +215 -0
  230. package/src/ui/components/toast.js +25 -8
  231. package/src/ui/components/toggle_field.js +175 -0
  232. package/src/ui/components/tree_view.js +55 -34
  233. package/src/ui/components/tsv.js +74 -0
  234. package/src/ui/components/window_taskbar.js +0 -0
  235. package/src/ui/components/wizard.js +563 -0
  236. package/src/ui/controllers/window_chrome_controller.js +2 -2
  237. package/src/ui/notification_center.js +375 -119
  238. package/src/ui/utils/auto_scrollbars.js +0 -0
  239. package/src/ui/utils/markdown.js +709 -0
  240. package/src/ui/utils/native_context_menu.js +0 -0
  241. package/src/ui/utils/overlay_scrollbar.js +16 -12
  242. package/src/ui/utils/raf_resize_observer.js +0 -0
  243. package/src/ui/utils/scroll_lock.js +0 -0
  244. package/src/ui/utils/strip_scroll.js +55 -0
  245. package/src/ui/utils/tooltip_service.js +0 -8
  246. package/tiles.js +27 -0
  247. package/widgets.js +69 -0
  248. package/wm.js +28 -0
  249. package/dist/chunk-6JSOVNID.js.map +0 -7
  250. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  251. package/dist/chunk-FBGP5KN5.js.map +0 -7
  252. package/dist/chunk-FL5KFNQH.js.map +0 -7
  253. package/dist/chunk-NLTOY57M.js.map +0 -7
  254. package/dist/chunk-NNFTETK3.js +0 -633
  255. package/dist/chunk-NNFTETK3.js.map +0 -7
  256. package/dist/chunk-O5OHMWBB.js +0 -284
  257. package/dist/chunk-O5OHMWBB.js.map +0 -7
  258. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  259. package/dist/chunk-SN4LSOAR.js.map +0 -7
  260. package/dist/chunk-W6FBJLPW.js +0 -367
  261. package/dist/chunk-W6FBJLPW.js.map +0 -7
  262. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -27,7 +27,8 @@
27
27
  * `'redo'`), because an undo is a change to what the consumer holds. `load` is
28
28
  * a baseline, so opening a flow and looking at it makes nothing to undo; a
29
29
  * consumer's publish is a baseline too (`history.baseline(editor.snapshot())`).
30
- * `destroy` flushes nothing, because nothing is held.
30
+ * `destroy` flushes nothing, because nothing is held (a viewport report
31
+ * waiting for the wheel to settle is told, not saved: see below).
31
32
  *
32
33
  * ══ THE KEYS STOP AT THIS ROOT ══════════════════════════════════════════
33
34
  *
@@ -56,24 +57,152 @@
56
57
  * retargets the release, and then the click, to the button, which would add a
57
58
  * second step). A drag swallows the click that follows it; Escape,
58
59
  * `pointercancel` and a destroy each take the ghost away.
60
+ *
61
+ * ══ UNDER `connections` ═════════════════════════════════════════════════
62
+ *
63
+ * With the option every line is asked of `connectionRules` (the kernel's
64
+ * `ConnectorRouter` over the catalogue's port descriptors, the port types,
65
+ * then the host's `policy`); `dataPorts: 'connect'` makes data ports
66
+ * connectable, any number of lines out of one. An output can also be DRAGGED:
67
+ * released on an input it connects, on the empty canvas it opens the step
68
+ * picker with the types that have an input it may connect to, and the step
69
+ * picked arrives connected — one undo entry. While a line is being drawn the
70
+ * inputs it may end on carry `--can-drop`. A policy's `{ok: true, offer}` is
71
+ * shown with Accept and Not now; Accept makes the line (and the step) and runs
72
+ * the offer's `apply(draft)` on a copy of the graph, as one undo entry. Without
73
+ * the option none of this is drawn or bound.
74
+ *
75
+ * ══ MANY PICKED, ONLY WHEN ASKED (`multiSelect: true`) ══════════════════
76
+ *
77
+ * The pick becomes a set: a marquee over the empty surface picks every node
78
+ * it touches, Shift/Ctrl/⌘+click adds or takes one away, Ctrl+A picks all and
79
+ * Escape lets go. Dragging any picked node moves them all, Delete removes
80
+ * them all, an arrow key nudges them a grid step (Shift: ten), and the
81
+ * toolbar aligns and distributes them — each gesture ONE undo entry
82
+ * (`flow:nodes:move`, `flow:nodes:remove`, `flow:nodes:align`). Tab and
83
+ * Shift+Tab walk the nodes, the keyboard's way round the canvas. Without the
84
+ * option none of it is bound, drawn or reported.
85
+ *
86
+ * ══ A TYPE MAY DRAW ITS OWN BODY ════════════════════════════════════════
87
+ *
88
+ * A catalogue type with `render(body, node, ctx)` has a body element below
89
+ * its ports, built ONCE with the node and filled by `render`; its `update`
90
+ * runs after it and on every repaint, onto the same element, so a control
91
+ * inside keeps its focus and its value. `ctx.setConfig(patch)` is the body's
92
+ * one way to write: a `flow:node:config` edit, one undo entry. A `size` sets
93
+ * the node's box (its outputs are anchored at its own width) and an `accent`
94
+ * marks it in a tone. A `render` that throws leaves the node drawn as every
95
+ * other step is, and what it threw is one of the editor's own findings beside
96
+ * the consumer's. A type without these keys is drawn exactly as before.
97
+ * `setCatalogue` swaps the types while the editor is open: the palette is
98
+ * redrawn and only the nodes whose type's definition changed are rebuilt. A
99
+ * definition is compared by what the node is built from — known or not, its
100
+ * icon, label and tone, its ports, its `render` and `update` functions and
101
+ * its `inline` settings —
102
+ * never by object identity, so a new catalogue made from the same types plus
103
+ * one more keeps every existing node, its body and what was typed into it.
104
+ * A `size` or an `accent` is applied on every repaint and rebuilds nothing.
105
+ *
106
+ * ══ EDITED ON THE NODE, ONLY WHEN ASKED ═════════════════════════════════
107
+ *
108
+ * `renameInPlace: true`: a double-click on a node's title puts a text box in
109
+ * its place with the name selected. Enter or leaving it is ONE
110
+ * `flow:node:label` edit (none when the name is what it was); Escape puts the
111
+ * name back and records nothing; an empty name is refused with its sentence
112
+ * on the gesture line and the name it had stays. Read only, a double-click
113
+ * opens nothing. A type's `inline: ['key', …]` draws those settings on the
114
+ * node by the panel's own widget registry (`renderField`): a change there is
115
+ * the same `flow:node:config` edit as in the panel, the panel showing it at
116
+ * once, and a change in the panel redraws the node's control — unless the
117
+ * reader is typing in it, when it already holds what the graph does. Without
118
+ * the option and the key nothing is bound or drawn.
119
+ *
120
+ * ══ THE VIEWPORT IS THE CONSUMER'S TO KEEP ══════════════════════════════
121
+ *
122
+ * `viewport` opens there instead of fitting; `onViewportChange` hears ONE
123
+ * report per finished gesture of the reader's — a pan's release, a run of
124
+ * wheel turns once it has settled, a zoom button, Fit, Arrange, a minimap
125
+ * click, *Go to it* — never one per pointer move, and never what the consumer
126
+ * set itself (`load`, `setViewport`, `fit()`). The wheel has no release, so its
127
+ * report waits until the wheel has been still for `WHEEL_SETTLE_MS`; the next
128
+ * press, the next gesture and `destroy` each hand a waiting one on at once.
129
+ * That is the editor's only timer, and it writes nothing: it tells.
130
+ *
131
+ * ══ COPY, CUT, PASTE, DUPLICATE, ONLY WHEN ASKED (`clipboard`) ══════════
132
+ *
133
+ * `clipboard: true` keeps what is copied in the editor, for as long as it is
134
+ * open; `clipboard: {read, write}` hands it to the consumer — `write(text)`
135
+ * hears the copied JSON (the graph's own `{nodes, connections}` text: the
136
+ * picked steps and the lines among them, never a line to a step left out) and
137
+ * `read()` supplies what Ctrl+V pastes, as text or a promise of it. A paste
138
+ * gives every step a fresh id (`nextId`), takes the lines between them along,
139
+ * sets the copy off from where it was copied (one more step off at each paste
140
+ * of the same text, none for the first paste after a cut), picks it and is ONE
141
+ * `flow:paste` entry; Ctrl+D is the same without the clipboard, as one
142
+ * `flow:node:duplicate`. A label the flow already has is derived (`Title
143
+ * (copy)`, `(copy 2)`, …), and `prepareCopy(node, {from, via})` may change a
144
+ * copy's config and label before it is added. Read only, copy still copies
145
+ * and the rest are refused with the reason on the message line. With `notes`
146
+ * the picked notes go along (under fresh ids when pasted), and with `groups`
147
+ * each group wholly inside the copy is made again round the copies.
148
+ *
149
+ * ══ LIVE VALUES ARE PAINTED, NEVER EDITED ═══════════════════════════════
150
+ *
151
+ * `setNodeValues({[id]: {badge, tone, ports: {[name]: text}}})` is what the
152
+ * host is showing right now — a value on a node's header, a value beside a
153
+ * port — and it extends the run overlay: no node is rebuilt, nothing is
154
+ * recorded, `onChange` is not called and the panel is not touched, so a
155
+ * field being typed into keeps its focus however often the host paints. The
156
+ * values reach a type's own body through `ctx.values`, and its `update` runs
157
+ * for each node whose values changed. Their elements exist only while there
158
+ * is a value to show.
159
+ *
160
+ * ══ A NODE FOLDS TO ITS HEADER, ONLY WHEN ASKED (`collapsible: true`) ═══
161
+ *
162
+ * A button on each header folds its node away to the header and back, one
163
+ * `flow:node:collapse` entry each way. Its port rows, its body and what is
164
+ * drawn under them are hidden; its lines stay, meeting the header's middle on
165
+ * its two sides (as a kernel node's `collapsedPorts` stand in for the ports a
166
+ * folded node hides). A folded node is `collapsed: true` in the graph; an unfolded one has no such
167
+ * key. Without the option a `collapsed` flag is carried through untouched and
168
+ * the node is drawn unfolded.
169
+ * ══ GROUPS AND NOTES, ONLY WHEN ASKED (`groups: true`, `notes: true`) ════
170
+ *
171
+ * A group is a named, toned frame drawn BEHIND its nodes, round all of them;
172
+ * its title bar is its handle: dragging it moves every member (one
173
+ * `flow:nodes:move`), a double-click renames it in place. Groups are flat and
174
+ * a node is in one at most: Ctrl/⌘+G groups the pick (taking each node out of
175
+ * the group it was in), Ctrl/⌘+Shift+G ungroups the groups the pick is in,
176
+ * and removing a node shrinks its group, the last member taking the group with
177
+ * it. A note is free text on the canvas: added by the toolbar's Note, edited
178
+ * in place (a double-click, or Enter on it), moved by dragging it, removed by
179
+ * Delete. A marquee picks notes with the nodes, and a drag or a Delete takes
180
+ * every picked one along. Each is one closed-list edit — `flow:group:add`,
181
+ * `…:remove`, `…:label`, `…:members`, `flow:note:add`, `…:remove`, `…:edit`,
182
+ * `…:move` — and the graph carries them as `groups` and `notes` only when it
183
+ * has some, so a graph without them serialises exactly as before. Without the
184
+ * options a graph's groups and notes are kept, never drawn.
59
185
  */
60
186
 
61
187
  import { CanvasAdapter } from '../../canvas/canvas_adapter.js';
62
- import { returnEdge, routeEdge } from '../../canvas/edge_router.js';
63
- import { button, el, icon, toneClass, closeFlowPopovers } from '../kit/dom.js';
188
+ import { OBSTACLE_ROUTING_NODE_LIMIT, crowsFoot, footAnchor, returnEdge, routeEdge } from '../../canvas/edge_router.js';
189
+ import { layeredLayout } from '../../canvas/layered_layout.js';
190
+ import { TONES, button, el, icon, toneClass, closeFlowPopovers } from '../kit/dom.js';
64
191
  import { createStrings, say } from '../kit/strings.js';
65
192
  import { createStepCatalogue } from '../kit/catalogue.js';
66
- import { createWidgetRegistry } from '../kit/widgets.js';
193
+ import { createWidgetRegistry, renderField } from '../kit/widgets.js';
67
194
  import { createSettingsPanel } from '../kit/settings_panel.js';
68
195
  import { fieldsFromSchema } from '../kit/settings_schema.js';
69
196
  import { FORMULA_REFERENCES, PARAMETER_REFERENCES, TEMPLATE_REFERENCES } from '../kit/references.js';
70
197
  import { alwaysBefore, enclosingArms, enclosingLoops, DEFAULT_LOOP_PORTS } from '../kit/always_before.js';
71
198
  import { createFindingsStrip, findingsList, groupFindings } from '../kit/findings.js';
199
+ import { openStepPicker } from '../kit/step_picker.js';
72
200
  import { FlowHistory } from '../kit/history.js';
73
201
  import { bindFlowKeys } from '../kit/keys.js';
74
- import { addNode, connect, disconnect, emptyGraph, normalise, removeNode, sameEdge, serialise } from '../kit/logic_graph.js';
202
+ import { addConnection, addNode, connect, disconnect, emptyGraph, nextId, normalise, removeNode, sameEdge, serialise } from '../kit/logic_graph.js';
75
203
  import { CANVAS_STRINGS } from './strings.js';
76
- import { CANVAS_NODE, isReturnEdge, layoutGraph, nodeBottom, portAnchor } from './layout.js';
204
+ import { connectionRules } from './connections.js';
205
+ import { CANVAS_NODE, isReturnEdge, layoutGraph, nodeBottom, nodeWidth, pathMidpoint, portAnchor } from './layout.js';
77
206
 
78
207
  /** Every edit the canvas editor records (36 §5.4). Anything else is a programming error. */
79
208
  export const CANVAS_ACTIONS = Object.freeze([
@@ -86,8 +215,39 @@ export const CANVAS_ACTIONS = Object.freeze([
86
215
  'flow:node:config',
87
216
  'flow:settings',
88
217
  'flow:arrange',
218
+ 'flow:nodes:move',
219
+ 'flow:nodes:remove',
220
+ 'flow:nodes:align',
221
+ 'flow:paste',
222
+ 'flow:node:duplicate',
223
+ 'flow:node:collapse',
224
+ 'flow:group:add',
225
+ 'flow:group:remove',
226
+ 'flow:group:label',
227
+ 'flow:group:members',
228
+ 'flow:note:add',
229
+ 'flow:note:remove',
230
+ 'flow:note:edit',
231
+ 'flow:note:move',
89
232
  ]);
90
233
 
234
+ /** `editor.align(kind)` and the toolbar's align verbs, in toolbar order. */
235
+ const ALIGN = Object.freeze(['left', 'center', 'right', 'top', 'middle', 'bottom']);
236
+ const ALIGN_ICON = Object.freeze({ left: 'align_horizontal_left', center: 'align_horizontal_center',
237
+ right: 'align_horizontal_right', top: 'align_vertical_top',
238
+ middle: 'align_vertical_center', bottom: 'align_vertical_bottom' });
239
+ /** `editor.distribute(axis)`. */
240
+ const DISTRIBUTE = Object.freeze(['horizontal', 'vertical']);
241
+ const DISTRIBUTE_ICON = Object.freeze({ horizontal: 'horizontal_distribute', vertical: 'vertical_distribute' });
242
+ /** A paste or a duplicate is set off from what it copies by this many grid steps, right and down. */
243
+ const COPY_OFFSET = 2;
244
+ /** Shift+arrow nudges this many grid steps. */
245
+ const NUDGE_FAR = 10;
246
+ /** A group's frame: how far it stands clear of its members, and its title bar's height. */
247
+ const FRAME = Object.freeze({ pad: 16, head: 26 });
248
+ /** A note without a `size` of its own is this wide; its height is its text's. */
249
+ const NOTE_WIDTH = 192;
250
+
91
251
  /** The scale a fit frames at most: natural size. The kernel's own ceiling is
92
252
  * 4×, so a new flow — one step — was framed at it: the step drew 514 px wide
93
253
  * on a 634 px canvas, and every step dropped beside it landed on top of it.
@@ -96,10 +256,26 @@ const NATURAL = 1;
96
256
  /** Opening (and Arrange) keeps a long flow at this or above, from its first
97
257
  * step on the left; a press on Fit frames ALL of it, however small. */
98
258
  const READABLE = 0.8;
259
+ /** The zoom buttons' steps: from the kernel's floor to its ceiling, through
260
+ * natural size. A press goes to the next step, whatever scale the wheel left. */
261
+ const ZOOM_STEPS = Object.freeze([CanvasAdapter.MIN_SCALE, 0.25, 0.33, 0.5, 0.67, 0.8, 1, 1.25, 1.5, 2, 3,
262
+ CanvasAdapter.MAX_SCALE]);
263
+ const ZOOM_EPSILON = 1e-6;
264
+ /** How long the wheel is still before a run of turns is one finished zoom or pan. */
265
+ const WHEEL_SETTLE_MS = 300;
266
+ /** The minimap's drawing, in screen pixels, and its margin round what it shows, in canvas pixels. */
267
+ const MINIMAP = Object.freeze({ width: 180, height: 120, margin: 40 });
99
268
  /** How far a palette press must travel before it is a drag, not a click. */
100
269
  const DRAG_PX = 4;
101
270
  /** How far below the lower of its two ends a loop's return line runs. */
102
271
  const RETURN_DROP = 40;
272
+ /** How far clear of a step a routed line is kept (`routing: 'obstacles'`):
273
+ * each step is handed to the kernel this much larger on every side, so a
274
+ * line bent round one passes beside its border, not along it. */
275
+ const CLEARANCE = 16;
276
+ /** An arrowhead's depth along the line and its half-width across it. */
277
+ const ARROW = Object.freeze({ length: 9, half: 4.5 });
278
+ const ROUTINGS = Object.freeze(['simple', 'obstacles']);
103
279
  const SVG = 'http://www.w3.org/2000/svg';
104
280
 
105
281
  const DEFAULT_REFERENCES = Object.freeze({
@@ -114,8 +290,18 @@ function sortedKeys(value) {
114
290
  return value;
115
291
  }
116
292
 
293
+ const cap = (word) => word.charAt(0).toUpperCase() + word.slice(1);
117
294
  const plainObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
118
295
 
296
+ /** A step catalogue, or the raw list of types made into one. */
297
+ function toCatalogue(value, who) {
298
+ const catalogue = Array.isArray(value) ? createStepCatalogue(value) : value;
299
+ if (!catalogue?.get || !catalogue?.byCategory) {
300
+ throw new Error(`${who} needs a step catalogue (createStepCatalogue(types, …)).`);
301
+ }
302
+ return catalogue;
303
+ }
304
+
119
305
  /**
120
306
  * @param {HTMLElement|null} host the editor is appended here when given
121
307
  * @param {object} options 36 §3.14 and §5.2; the README lists every one
@@ -127,25 +313,55 @@ export function createCanvasEditor(host, options = {}) {
127
313
  slots = {}, strings = null, services = null, onChange = null, onSelect = null,
128
314
  history: historyOptions = {}, loop = {}, node: nodeOptions = {}, dataPortRead = null,
129
315
  removable = null, paletteTypes = null, label = null, gridSize = 16, waitsForAll = undefined,
316
+ connections: linkOptions = null,
317
+ multiSelect = false,
318
+ edgeStyle = null, routing = 'simple', hoverHighlight = false,
319
+ onViewportChange = null, zoomControls = false, minimap = false, renameInPlace = false,
320
+ clipboard = null, prepareCopy = null, collapsible = false,
321
+ groups: groupsOption = false, notes: notesOption = false,
322
+ layout = 'flow',
130
323
  } = options;
131
- const catalogue = Array.isArray(options.catalogue) ? createStepCatalogue(options.catalogue)
132
- : options.catalogue;
133
- if (!catalogue?.get || !catalogue?.byCategory) {
134
- throw new Error('createCanvasEditor needs a step catalogue (createStepCatalogue(types, …)).');
324
+ const folding = Boolean(collapsible);
325
+ const multi = Boolean(multiSelect);
326
+ const grouping = Boolean(groupsOption);
327
+ const noting = Boolean(notesOption);
328
+ if (layout !== 'flow' && layout !== 'layered' && typeof layout !== 'function') {
329
+ throw new Error(`createCanvasEditor's layout is 'flow', 'layered' or a function (graph, ctx) → Map; got ${String(layout)}.`);
135
330
  }
331
+ if (!ROUTINGS.includes(routing)) {
332
+ throw new Error(`createCanvasEditor: routing is ${ROUTINGS.map((r) => `'${r}'`).join(' or ')}, not '${routing}'.`);
333
+ }
334
+ if (clipboard && clipboard !== true
335
+ && (typeof clipboard.read !== 'function' || typeof clipboard.write !== 'function')) {
336
+ throw new Error('createCanvasEditor: clipboard is true or {read(), write(text)}.');
337
+ }
338
+ /** `'memory'`: what is copied stays in the editor; else the consumer's `{read, write}`; null: off. */
339
+ const clip = clipboard === true ? 'memory' : clipboard || null;
340
+ const styled = typeof edgeStyle === 'function';
341
+ let catalogue = toCatalogue(options.catalogue, 'createCanvasEditor');
136
342
  const S = createStrings(CANVAS_STRINGS, strings);
137
343
  const BOX = Object.freeze({
138
344
  width: Number(nodeOptions.width) || CANVAS_NODE.width,
139
345
  header: Number(nodeOptions.header) || CANVAS_NODE.header,
140
346
  row: Number(nodeOptions.row) || CANVAS_NODE.row,
347
+ /** Is this node drawn folded to its header? Only with `collapsible`. */
348
+ folded: (n) => folding && n?.collapsed === true,
141
349
  });
142
350
  const loopPorts = Object.freeze({ ...DEFAULT_LOOP_PORTS, ...plainObject(loop) });
143
351
  const canRemove = typeof removable === 'function' ? removable
144
352
  : (_node, type) => (type?.role || 'step') !== 'start';
145
353
  const offered = typeof paletteTypes === 'function' ? paletteTypes : (type) => type.role !== 'start';
354
+ /** The `connections` option: absent, the editor draws and binds exactly what it did without it. */
355
+ const linking = linkOptions && typeof linkOptions === 'object' ? linkOptions : null;
356
+ const dataConnect = linking?.dataPorts === 'connect';
357
+ /** The connection rules over the current catalogue: `setCatalogue` builds them again. */
358
+ const rulesFor = (types) => (linking
359
+ ? connectionRules({ catalogue: types, policy: linking.policy, dataPorts: dataConnect, strings: S }) : null);
360
+ let verdictOf = rulesFor(catalogue);
146
361
 
147
362
  // ── the frame ───────────────────────────────────────────────────────
148
363
  const root = el('div', 'twm-flow-graph');
364
+ if (linking) root.classList.add('twm-flow-graph--linking');
149
365
  if (BOX.width !== CANVAS_NODE.width) root.style.setProperty('--twm-canvas-node-width', `${BOX.width}px`);
150
366
  if (BOX.header !== CANVAS_NODE.header) root.style.setProperty('--twm-canvas-node-header', `${BOX.header}px`);
151
367
  if (BOX.row !== CANVAS_NODE.row) root.style.setProperty('--twm-canvas-node-row', `${BOX.row}px`);
@@ -161,6 +377,11 @@ export function createCanvasEditor(host, options = {}) {
161
377
  message.setAttribute('aria-live', 'polite');
162
378
  const banner = el('p', 'twm-flow-graph__banner');
163
379
  banner.hidden = true;
380
+ const offerBox = linking ? el('div', 'twm-flow-graph__offer') : null;
381
+ if (offerBox) {
382
+ offerBox.hidden = true;
383
+ offerBox.setAttribute('role', 'status');
384
+ }
164
385
  const body = el('div', 'twm-flow-graph__body');
165
386
  const palette = el('nav', 'twm-flow-graph__palette');
166
387
  palette.setAttribute('aria-label', say(S, 'palette'));
@@ -180,22 +401,76 @@ export function createCanvasEditor(host, options = {}) {
180
401
  let findings = [];
181
402
  let findingsBy = new Map();
182
403
  let overlay = null;
183
- /** {kind: 'flow'} | {kind: 'node', id} | {kind: 'edge', edge} | null */
404
+ /** What the host shows on each node now (`setNodeValues`), by node id: {badge, tone, ports}. */
405
+ let liveValues = new Map();
406
+ /** {kind: 'flow'} | {kind: 'node', id} | {kind: 'many', ids, notes?} (two or more,
407
+ * `multiSelect`; `notes` the picked notes' ids, with `notes`) | {kind: 'note', id}
408
+ * | {kind: 'edge', edge} | null */
184
409
  let selected = null;
410
+ /** The node a left press went down on (`multiSelect`), while it is held:
411
+ * the focus it brings is the press's, settled by its click. A port's
412
+ * press is the port's and sets nothing. */
413
+ let pressed = null;
414
+ /** That press once let go, for the click that follows it: a press that
415
+ * dragged does not narrow the pick. The next press replaces it. */
416
+ let released = null;
185
417
  /** The output a click-then-click connection started from: {source, sourcePort}. */
186
418
  let connecting = null;
419
+ /** A policy's offer on the screen: {offer, build, action, after}. It needs no staleness check:
420
+ * every commit, restore, load and read-only switch clears it. */
421
+ let pendingOffer = null;
187
422
  let suppressClick = false;
188
423
  let dragCleanup = null;
189
424
  let destroyed = false;
190
425
  let fitFrame = null;
191
426
  let travelling = null;
192
427
  let lastSelect = '';
428
+ /** What the pointer is over, with `hoverHighlight`: {kind: 'node', id} | {kind: 'edge', edge} | null. */
429
+ let hovered = null;
430
+ /** Every drawn line and the connection it draws, from the last paint. */
431
+ let edgeEls = [];
432
+ /** Whether `routing: 'obstacles'` is drawing plain curves, the graph being over the kernel's limit. */
433
+ let routingFallback = false;
193
434
  const nodeEls = new Map();
435
+ /** A group's frame and a note's box, by id (`groups`, `notes`). */
436
+ const frameEls = new Map();
437
+ const noteEls = new Map();
438
+ /** The note or the group being typed into in place: {kind, id, done(keep)} | null. */
439
+ let editing = null;
440
+ /** What each node element was built from (`buildKey`), by node id. */
441
+ const builtFrom = new Map();
442
+ /** A node's own body, by node id: {el, ctx, update} — built once with the node. */
443
+ const bodies = new Map();
444
+ /** A type's `inline` settings drawn on the node, by node id: {readOnly, fields: [{key, spec, shown, field}]}. */
445
+ const inlines = new Map();
446
+ /** The title being typed in place (`renameInPlace`): {id, finish(keep)}. */
447
+ let renaming = null;
448
+ /** What a type's own `render` or `update` threw, by node id: the editor's own findings. */
449
+ const bodyFindings = new Map();
450
+ let painting = false;
194
451
  const consumerState = new Map();
452
+ /** The `viewport` option, until the first `load` opens there instead of fitting. */
453
+ let startView = viewportOf(options.viewport);
454
+ /** The viewport the consumer last heard of (or set): a gesture ending there is no change. */
455
+ let reportedView = '';
456
+ let wheelTimer = null;
457
+ let pressEnd = null;
458
+ let zoomLimit = '';
459
+ let miniMap = null;
460
+ /** What `clipboard: true` holds: the last copied text. */
461
+ let copiedText = null;
462
+ /** The text pasted last and how many times, so each paste of it is set off one step more. */
463
+ let pasteRun = { text: null, times: 0 };
195
464
 
196
465
  const canvas = new CanvasAdapter({ container: canvasHost, gridSize });
466
+ if (startView) canvas.setViewport(startView);
197
467
  canvas.root.setAttribute('role', 'application');
198
468
  canvas.root.setAttribute('aria-label', label || say(S, 'canvasLabel'));
469
+ // A line's label lives in the NODE layer, above every node (the stylesheet
470
+ // stacks it), so a label is never hidden behind a step; a press on it is
471
+ // the editor's, never a pan.
472
+ const labelLayer = styled ? el('div', 'twm-flow-graph__edge-labels') : null;
473
+ if (labelLayer) canvas.nodeLayer.appendChild(labelLayer);
199
474
  // A press on the empty surface lets go of the pick (and of a connection
200
475
  // being drawn); with nothing but the flow picked it repaints nothing.
201
476
  canvas.onSelectionChange = () => {
@@ -207,6 +482,23 @@ export function createCanvasEditor(host, options = {}) {
207
482
  const readOnlyReason = () => (ro && typeof ro === 'object' && ro.reason ? String(ro.reason) : '');
208
483
  const nodeOf = (id) => graph.nodes.find((x) => x.id === id) || null;
209
484
  const typeOf = (n) => catalogue.get(n?.type);
485
+ /** The picked nodes' ids: none, one, or (`multiSelect`) several. */
486
+ const pickedIds = () => (selected?.kind === 'node' ? [selected.id]
487
+ : selected?.kind === 'many' ? selected.ids : []);
488
+ const pickedSet = () => new Set(pickedIds());
489
+ const isPicked = (id) => (selected?.kind === 'node' ? selected.id === id
490
+ : selected?.kind === 'many' ? selected.ids.includes(id) : false);
491
+ const toggles = (ev) => multi && Boolean(ev.shiftKey || ev.ctrlKey || ev.metaKey);
492
+ const groupList = () => (Array.isArray(graph.groups) ? graph.groups : []);
493
+ const noteList = () => (Array.isArray(graph.notes) ? graph.notes : []);
494
+ const groupOf = (id) => groupList().find((gr) => gr.id === id) || null;
495
+ const noteOf = (id) => noteList().find((x) => x.id === id) || null;
496
+ /** The picked notes' ids (`notes`). */
497
+ const pickedNotes = () => (selected?.kind === 'note' ? [selected.id]
498
+ : selected?.kind === 'many' ? selected.notes || [] : []);
499
+ const isNotePicked = (id) => pickedNotes().includes(id);
500
+ /** The consumer's findings, then the editor's own (a node body that threw). */
501
+ const allFindings = () => (bodyFindings.size ? [...findings, ...bodyFindings.values()] : findings);
210
502
 
211
503
  // ── state ───────────────────────────────────────────────────────────
212
504
 
@@ -230,18 +522,20 @@ export function createCanvasEditor(host, options = {}) {
230
522
 
231
523
  /** After every edit: one undo entry (the closed list), and the consumer told. */
232
524
  function commit(action, key = null) {
525
+ if (pendingOffer) clearOffer();
233
526
  if (history.commit(snapshot(), action, key)) emitChange(action, key);
234
527
  paintToolbar();
235
528
  }
236
529
 
237
530
  function restore(state) {
531
+ editing?.done(false);
238
532
  const p = JSON.parse(state);
239
533
  graph = normalise(p.graph);
240
534
  settingsValue = plainObject(p.flowSettings);
241
535
  if (connecting) cancelConnect();
242
- if (selected?.kind === 'node' && !nodeOf(selected.id)) selected = null;
243
- if (selected?.kind === 'edge' && !graph.connections.some((c) => sameEdge(c, selected.edge))) selected = null;
244
- findingsBy = groupFindings(findings, { graph });
536
+ if (pendingOffer) clearOffer();
537
+ prunePick();
538
+ findingsBy = groupFindings(allFindings(), { graph });
245
539
  paintNodes();
246
540
  paintPanel();
247
541
  keepFocus();
@@ -249,6 +543,25 @@ export function createCanvasEditor(host, options = {}) {
249
543
  announceSelection();
250
544
  }
251
545
 
546
+ /** What is picked and no longer there is let go of (an undo, a load). */
547
+ function prunePick() {
548
+ if (selected?.kind === 'node' && !nodeOf(selected.id)) selected = null;
549
+ if (selected?.kind === 'edge' && !graph.connections.some((c) => sameEdge(c, selected.edge))) selected = null;
550
+ if (selected?.kind === 'note' && !noteOf(selected.id)) selected = null;
551
+ if (selected?.kind === 'many') selected = pickOf(selected.ids, selected.notes || []);
552
+ }
553
+
554
+ /** The pick of these nodes and notes that are still there: none, one, or several. */
555
+ function pickOf(ids, noteIds = []) {
556
+ const live = new Set(graph.nodes.map((x) => x.id));
557
+ const list = [...new Set(ids)].filter((id) => live.has(id));
558
+ const liveNotes = new Set(noteList().map((x) => x.id));
559
+ const notes = [...new Set(noteIds)].filter((id) => liveNotes.has(id));
560
+ if (list.length + notes.length > 1) return { kind: 'many', ids: list, ...(notes.length ? { notes } : {}) };
561
+ if (list.length) return { kind: 'node', id: list[0] };
562
+ return notes.length ? { kind: 'note', id: notes[0] } : null;
563
+ }
564
+
252
565
  /** A verb that repainted away the focused control leaves focus on the body,
253
566
  * where the root's keys never arrive — so it goes back to the canvas. */
254
567
  function keepFocus() {
@@ -278,11 +591,52 @@ export function createCanvasEditor(host, options = {}) {
278
591
  const tools = {
279
592
  undo: tool('undo', { label: say(S, 'undo'), icon: 'undo', title: say(S, 'undoTitle'), onClick: () => undo() }),
280
593
  redo: tool('redo', { label: say(S, 'redo'), icon: 'redo', title: say(S, 'redoTitle'), onClick: () => redo() }),
281
- arrange: tool('arrange', { label: say(S, 'arrange'), icon: 'account_tree', title: say(S, 'arrangeTitle'),
282
- onClick: () => arrangeGraph() }),
594
+ arrange: tool('arrange', { label: say(S, 'arrange'), icon: 'account_tree',
595
+ title: say(S, layout === 'flow' ? 'arrangeTitle' : 'arrangeLayeredTitle'),
596
+ onClick: () => { if (arrangeGraph(arrangeTarget())) viewportSettled(); } }),
283
597
  fit: tool('fit', { label: say(S, 'fit'), icon: 'fit_screen', title: say(S, 'fitTitle'),
284
- onClick: () => fit({ whole: true }) }),
598
+ onClick: () => { fit({ whole: true }); viewportSettled(); } }),
285
599
  };
600
+ // The align and distribute verbs, icon-only, after Fit (`multiSelect`).
601
+ const pickTools = [];
602
+ if (multi) {
603
+ for (const kind of ALIGN) {
604
+ const id = `align-${kind}`;
605
+ const words = say(S, `align${cap(kind)}`);
606
+ tools[id] = tool(id, { label: words, title: words, icon: ALIGN_ICON[kind], iconOnly: true,
607
+ onClick: () => alignNodes(kind) });
608
+ pickTools.push(tools[id]);
609
+ }
610
+ for (const axis of DISTRIBUTE) {
611
+ const id = `distribute-${axis}`;
612
+ const words = say(S, `distribute${cap(axis)}`);
613
+ tools[id] = tool(id, { label: words, title: words, icon: DISTRIBUTE_ICON[axis], iconOnly: true,
614
+ onClick: () => distributeNodes(axis) });
615
+ pickTools.push(tools[id]);
616
+ }
617
+ }
618
+ // Group, Ungroup and Note, after the align verbs (`groups`, `notes`).
619
+ const itemTools = [];
620
+ if (grouping) {
621
+ tools.group = tool('group', { label: say(S, 'group'), title: say(S, 'groupTitle'), icon: 'select_all',
622
+ iconOnly: true, onClick: () => groupPicked() });
623
+ tools.ungroup = tool('ungroup', { label: say(S, 'ungroup'), title: say(S, 'ungroupTitle'), icon: 'deselect',
624
+ iconOnly: true, onClick: () => ungroupPicked() });
625
+ itemTools.push(tools.group, tools.ungroup);
626
+ }
627
+ if (noting) {
628
+ tools.note = tool('note', { label: say(S, 'note'), title: say(S, 'noteTitle'), icon: 'sticky_note_2',
629
+ onClick: () => { const n = addNote(); if (n) editNote(n.id); } });
630
+ itemTools.push(tools.note);
631
+ }
632
+ if (zoomControls) {
633
+ tools['zoom-out'] = tool('zoom-out', { label: say(S, 'zoomOut'), icon: 'zoom_out', title: say(S, 'zoomOutTitle'),
634
+ onClick: () => zoomStep(-1) });
635
+ tools['zoom-in'] = tool('zoom-in', { label: say(S, 'zoomIn'), icon: 'zoom_in', title: say(S, 'zoomInTitle'),
636
+ onClick: () => zoomStep(1) });
637
+ tools['zoom-reset'] = tool('zoom-reset', { label: say(S, 'zoomReset'), title: say(S, 'zoomResetTitle'),
638
+ onClick: () => zoomTo(NATURAL) });
639
+ }
286
640
  const own = new Set(Object.keys(tools));
287
641
  const consumerTools = [];
288
642
  for (const a of Array.isArray(actions) ? actions : []) {
@@ -292,18 +646,33 @@ export function createCanvasEditor(host, options = {}) {
292
646
  onClick: () => { if (!b.disabled) a.run?.(api); } });
293
647
  consumerTools.push({ spec: a, el: b });
294
648
  }
295
- toolbar.append(tools.undo, tools.redo, tools.arrange, tools.fit, ...consumerTools.map((t) => t.el),
296
- status, toolbarEnd);
649
+ toolbar.append(tools.undo, tools.redo, tools.arrange, tools.fit,
650
+ ...['zoom-out', 'zoom-in', 'zoom-reset'].filter((id) => tools[id]).map((id) => tools[id]),
651
+ ...pickTools, ...itemTools, ...consumerTools.map((t) => t.el), status, toolbarEnd);
297
652
  if (typeof slots.toolbarEnd === 'function') slots.toolbarEnd(toolbarEnd);
298
653
 
299
654
  function refusals() {
300
655
  const stop = isReadOnly() ? (readOnlyReason() || say(S, 'readOnlyRefusal')) : null;
301
- return {
656
+ const r = {
302
657
  undo: stop || (history.canUndo ? null : say(S, 'nothingToUndo')),
303
658
  redo: stop || (history.canRedo ? null : say(S, 'nothingToRedo')),
304
659
  arrange: stop || (graph.nodes.length < 2 ? say(S, 'arrangeNeedsTwo') : null),
305
660
  fit: null,
661
+ 'zoom-out': canvas.scale <= ZOOM_STEPS[0] + ZOOM_EPSILON ? say(S, 'zoomAtMin') : null,
662
+ 'zoom-in': canvas.scale >= ZOOM_STEPS[ZOOM_STEPS.length - 1] - ZOOM_EPSILON ? say(S, 'zoomAtMax') : null,
663
+ 'zoom-reset': null,
306
664
  };
665
+ if (multi) {
666
+ const n = pickedIds().length;
667
+ for (const kind of ALIGN) r[`align-${kind}`] = stop || (n < 2 ? say(S, 'alignNeedsTwo') : null);
668
+ for (const axis of DISTRIBUTE) r[`distribute-${axis}`] = stop || (n < 3 ? say(S, 'distributeNeedsThree') : null);
669
+ }
670
+ if (grouping) {
671
+ r.group = stop || (pickedIds().length ? null : say(S, 'groupNeedsOne'));
672
+ r.ungroup = stop || (groupsOfPick().length ? null : say(S, 'ungroupNeedsGroup'));
673
+ }
674
+ if (noting) r.note = stop;
675
+ return r;
307
676
  }
308
677
 
309
678
  function refuse(control, sentence) {
@@ -350,6 +719,135 @@ export function createCanvasEditor(host, options = {}) {
350
719
  try { return history.redo(); } finally { travelling = null; }
351
720
  }
352
721
 
722
+ // ── the viewport ────────────────────────────────────────────────────
723
+
724
+ const viewKey = (v) => `${v.scale}|${v.tx}|${v.ty}`;
725
+
726
+ /** What the consumer set is what it has: nothing to report back. */
727
+ function viewportBaseline() {
728
+ reportedView = viewKey(canvas.viewport());
729
+ }
730
+
731
+ /** A gesture of the reader's has finished: one report, if it moved the view. */
732
+ function viewportSettled() {
733
+ if (wheelTimer !== null) { clearTimeout(wheelTimer); wheelTimer = null; }
734
+ if (destroyed) return;
735
+ const now = canvas.viewport();
736
+ const key = viewKey(now);
737
+ if (key === reportedView) return;
738
+ reportedView = key;
739
+ onViewportChange?.({ ...now });
740
+ }
741
+
742
+ /** Scale to `scale` about the centre of the view, so what is in the middle stays there. */
743
+ function zoomTo(scale) {
744
+ const r = canvas.root.getBoundingClientRect();
745
+ const centre = canvas.clientToCanvas(r.left + r.width / 2, r.top + r.height / 2);
746
+ const s = Math.max(ZOOM_STEPS[0], Math.min(scale, ZOOM_STEPS[ZOOM_STEPS.length - 1]));
747
+ canvas.setViewport({ scale: s, tx: r.width / 2 - centre.x * s, ty: r.height / 2 - centre.y * s });
748
+ viewportSettled();
749
+ }
750
+
751
+ /** The next step up (+1) or down (-1) from the scale the view is at; none past a limit. */
752
+ function zoomStep(direction) {
753
+ const s = canvas.scale;
754
+ const next = direction > 0 ? ZOOM_STEPS.find((z) => z > s + ZOOM_EPSILON)
755
+ : [...ZOOM_STEPS].reverse().find((z) => z < s - ZOOM_EPSILON);
756
+ if (next === undefined) return false;
757
+ zoomTo(next);
758
+ return true;
759
+ }
760
+
761
+ /** Every kernel move: the zoom buttons' limits, and the minimap's frame. */
762
+ function viewportMoved() {
763
+ if (destroyed) return;
764
+ if (zoomControls) {
765
+ const r = refusals();
766
+ const limit = `${r['zoom-out'] || ''}|${r['zoom-in'] || ''}`;
767
+ if (limit !== zoomLimit) { zoomLimit = limit; paintToolbar(); }
768
+ }
769
+ paintMinimap();
770
+ }
771
+
772
+ if (zoomControls || minimap) canvas.onViewportChange = () => viewportMoved();
773
+
774
+ if (typeof onViewportChange === 'function') {
775
+ // A press on the canvas is a gesture until it is released, wherever it
776
+ // is released; capture, because a node's header stops its press.
777
+ canvas.root.addEventListener('pointerdown', () => {
778
+ viewportSettled();
779
+ pressEnd?.();
780
+ const end = () => { pressEnd?.(); viewportSettled(); };
781
+ window.addEventListener('pointerup', end, true);
782
+ window.addEventListener('pointercancel', end, true);
783
+ pressEnd = () => {
784
+ window.removeEventListener('pointerup', end, true);
785
+ window.removeEventListener('pointercancel', end, true);
786
+ pressEnd = null;
787
+ };
788
+ }, true);
789
+ canvas.root.addEventListener('wheel', () => {
790
+ if (wheelTimer !== null) clearTimeout(wheelTimer);
791
+ wheelTimer = setTimeout(() => { wheelTimer = null; viewportSettled(); }, WHEEL_SETTLE_MS);
792
+ }, { passive: true });
793
+ }
794
+
795
+ // ── the minimap ─────────────────────────────────────────────────────
796
+
797
+ const mini = minimap ? el('div', 'twm-flow-graph__minimap') : null;
798
+ const miniSvg = minimap ? document.createElementNS(SVG, 'svg') : null;
799
+ if (mini) {
800
+ miniSvg.setAttribute('class', 'twm-flow-graph__minimap-map');
801
+ miniSvg.setAttribute('width', String(MINIMAP.width));
802
+ miniSvg.setAttribute('height', String(MINIMAP.height));
803
+ mini.title = say(S, 'minimapTitle');
804
+ // A pointer's shortcut: the keyboard has the zoom buttons, Fit and *Go to it*.
805
+ mini.setAttribute('aria-hidden', 'true');
806
+ mini.appendChild(miniSvg);
807
+ mini.addEventListener('click', (ev) => {
808
+ if (!miniMap) return;
809
+ const r = miniSvg.getBoundingClientRect();
810
+ const x = (ev.clientX - r.left - miniMap.ox) / miniMap.k;
811
+ const y = (ev.clientY - r.top - miniMap.oy) / miniMap.k;
812
+ const view = canvas.root.getBoundingClientRect();
813
+ canvas.setViewport({ tx: view.width / 2 - x * canvas.scale, ty: view.height / 2 - y * canvas.scale });
814
+ viewportSettled();
815
+ });
816
+ canvasHost.appendChild(mini);
817
+ }
818
+ // The frame is the canvas's visible box: a pane or window resized, or a
819
+ // host laid out only after the editor was built, moves no viewport.
820
+ const miniSized = mini && typeof ResizeObserver === 'function' ? new ResizeObserver(() => paintMinimap()) : null;
821
+ miniSized?.observe(canvas.root);
822
+
823
+ /** The whole flow and the view's frame, small: what is drawn spans both. */
824
+ function paintMinimap() {
825
+ if (!mini || destroyed) return;
826
+ const view = canvas.visibleBox();
827
+ const boxes = graph.nodes.map((n) => ({ x: n.position.x, y: n.position.y, width: BOX.width,
828
+ height: bottomOf(n) - n.position.y }));
829
+ const all = view.width > 0 && view.height > 0 ? [...boxes, view] : boxes;
830
+ if (!all.length) { miniMap = null; miniSvg.replaceChildren(); return; }
831
+ const x0 = Math.min(...all.map((b) => b.x)) - MINIMAP.margin;
832
+ const y0 = Math.min(...all.map((b) => b.y)) - MINIMAP.margin;
833
+ const x1 = Math.max(...all.map((b) => b.x + b.width)) + MINIMAP.margin;
834
+ const y1 = Math.max(...all.map((b) => b.y + b.height)) + MINIMAP.margin;
835
+ const k = Math.min(MINIMAP.width / (x1 - x0), MINIMAP.height / (y1 - y0));
836
+ miniMap = { k, ox: (MINIMAP.width - (x1 - x0) * k) / 2 - x0 * k, oy: (MINIMAP.height - (y1 - y0) * k) / 2 - y0 * k };
837
+ const rect = (b, cls) => {
838
+ const r = document.createElementNS(SVG, 'rect');
839
+ r.setAttribute('class', cls);
840
+ r.setAttribute('x', String(b.x * k + miniMap.ox));
841
+ r.setAttribute('y', String(b.y * k + miniMap.oy));
842
+ r.setAttribute('width', String(b.width * k));
843
+ r.setAttribute('height', String(b.height * k));
844
+ return r;
845
+ };
846
+ const parts = boxes.map((b) => rect(b, 'twm-flow-graph__minimap-node'));
847
+ if (view.width > 0 && view.height > 0) parts.push(rect(view, 'twm-flow-graph__minimap-view'));
848
+ miniSvg.replaceChildren(...parts);
849
+ }
850
+
353
851
  // ── the palette ─────────────────────────────────────────────────────
354
852
 
355
853
  function paintPalette() {
@@ -392,8 +890,9 @@ export function createCanvasEditor(host, options = {}) {
392
890
  let where = at;
393
891
  if (!where) {
394
892
  const box = canvas.visibleBox();
893
+ const width = nodeWidth(catalogue, { type: typeId }, BOX);
395
894
  where = {
396
- x: canvas.snap((box.width ? box.x + box.width / 2 - BOX.width / 2 : 80) + (n % 4) * 24),
895
+ x: canvas.snap((box.width ? box.x + box.width / 2 - width / 2 : 80) + (n % 4) * 24),
397
896
  y: canvas.snap((box.height ? box.y + box.height / 3 : 80) + (n % 4) * 24),
398
897
  };
399
898
  }
@@ -456,7 +955,8 @@ export function createCanvasEditor(host, options = {}) {
456
955
  return;
457
956
  }
458
957
  const p = canvas.clientToCanvas(u.clientX, u.clientY);
459
- addStep(t.type_id, { x: canvas.snap(p.x - BOX.width / 2), y: canvas.snap(p.y - BOX.header / 2) });
958
+ const width = nodeWidth(catalogue, { type: t.type_id }, BOX);
959
+ addStep(t.type_id, { x: canvas.snap(p.x - width / 2), y: canvas.snap(p.y - BOX.header / 2) });
460
960
  }
461
961
  function onCancel() {
462
962
  finish();
@@ -480,11 +980,53 @@ export function createCanvasEditor(host, options = {}) {
480
980
  // ── the canvas ──────────────────────────────────────────────────────
481
981
 
482
982
  const anchor = (n, port, direction) => portAnchor(catalogue, n, port, direction, BOX);
483
- const bottomOf = (n) => nodeBottom(catalogue, n, BOX);
983
+ const widthOf = (n) => nodeWidth(catalogue, n, BOX);
984
+ /** A node with its own body and no `size.h` is as tall as it is drawn,
985
+ * measured (so is one with `inline` settings); jsdom (and a node not yet laid out) answers 0, and then the
986
+ * arithmetic box stands. */
987
+ const measured = (n) => ((bodies.has(n.id) || inlines.has(n.id)) && !catalogue.size?.(n.type)?.h && !BOX.folded(n)
988
+ ? nodeEls.get(n.id)?.offsetHeight || 0 : 0);
989
+ const bottomOf = (n) => Math.max(nodeBottom(catalogue, n, BOX), n.position.y + measured(n));
484
990
  const isReturn = (c, target) => isReturnEdge(c, target, catalogue, loopPorts.entry);
485
991
 
486
- function buildNode(nodeRow) {
992
+ /** A port as the node draws it and hands it on: its JSON, or the port
993
+ * itself when it has none (then only the same object is the same port). */
994
+ const portKey = (p) => {
995
+ try { return JSON.stringify(p); } catch { return p; }
996
+ };
997
+
998
+ /** What `buildNode` reads from the catalogue for this node — compared
999
+ * field by field (`sameBuild`), so an equal definition in a new
1000
+ * catalogue is the same build and a changed one is not. */
1001
+ function buildKey(nodeRow) {
487
1002
  const def = typeOf(nodeRow);
1003
+ const own = def ? catalogue.body?.(nodeRow.type) : null;
1004
+ return {
1005
+ known: Boolean(def),
1006
+ icon: def?.icon || '',
1007
+ label: def?.label ?? null,
1008
+ tone: catalogue.tone(nodeRow.type),
1009
+ ports: [...catalogue.inputs(nodeRow.type), ...catalogue.outputs(nodeRow.type)].map(portKey),
1010
+ render: own?.render ?? null,
1011
+ update: own?.update ?? null,
1012
+ inline: def ? portKey(inlineFields(nodeRow)) : '',
1013
+ };
1014
+ }
1015
+
1016
+ /** The settings a node's type draws on it (`inline`), as the panel's fields. */
1017
+ function inlineFields(nodeRow) {
1018
+ const keys = catalogue.inline?.(nodeRow.type) || [];
1019
+ if (!keys.length) return [];
1020
+ const all = fieldsFromSchema(typeOf(nodeRow)?.config_schema);
1021
+ return keys.map((k) => all.find((f) => f.key === k)).filter(Boolean);
1022
+ }
1023
+
1024
+ const sameBuild = (a, b) => Boolean(a && b) && a.known === b.known && a.icon === b.icon
1025
+ && a.label === b.label && a.tone === b.tone && a.render === b.render && a.update === b.update
1026
+ && a.inline === b.inline
1027
+ && a.ports.length === b.ports.length && a.ports.every((p, i) => p === b.ports[i]);
1028
+
1029
+ function buildNode(nodeRow, def) {
488
1030
  const tone = catalogue.tone(nodeRow.type);
489
1031
  const n = el('div', 'twm-flow-graph__node');
490
1032
  n.dataset.nodeId = nodeRow.id;
@@ -501,9 +1043,11 @@ export function createCanvasEditor(host, options = {}) {
501
1043
  n.setAttribute('aria-label', name);
502
1044
  const title = el('span', 'twm-flow-graph__node-title', name);
503
1045
  title.title = name;
1046
+ if (renameInPlace) title.addEventListener('dblclick', () => startRename(nodeRow.id));
504
1047
  const tag = el('span', 'twm-flow-graph__node-tag', nodeRow.id);
505
1048
  tag.title = say(S, 'stepId', nodeRow.id);
506
1049
  header.append(chip, title, tag);
1050
+ if (folding) header.appendChild(foldButton(nodeRow.id));
507
1051
  header.addEventListener('pointerdown', (ev) => startMove(ev, nodeRow.id));
508
1052
  n.appendChild(header);
509
1053
  const inputs = catalogue.inputs(nodeRow.type);
@@ -515,21 +1059,351 @@ export function createCanvasEditor(host, options = {}) {
515
1059
  outputs[i] ? port(nodeRow, outputs[i]) : el('span', 'twm-flow-graph__node-gap'));
516
1060
  n.appendChild(row);
517
1061
  }
1062
+ if (def) mountInline(n, nodeRow);
1063
+ const own = def ? catalogue.body?.(nodeRow.type) : null;
1064
+ if (own) mountBody(n, nodeRow, own);
518
1065
  if (!def) n.appendChild(el('p', 'twm-flow-graph__node-finding', say(S, 'unknownType', nodeRow.type)));
519
1066
  if (typeof summarise === 'function') n.appendChild(el('p', 'twm-flow-graph__node-summary'));
520
1067
  const notes = el('div', 'twm-flow-graph__node-findings');
521
1068
  const runLine = el('p', 'twm-flow-graph__node-run');
522
1069
  n.append(notes, runLine);
1070
+ if (multi) {
1071
+ // Ahead of the header's own press, which stops it from going on.
1072
+ n.addEventListener('pointerdown', (ev) => {
1073
+ released = null;
1074
+ if (ev.button !== 0 || ev.target.closest('.twm-flow-graph__port')) return;
1075
+ pressed = { id: nodeRow.id, moved: false };
1076
+ }, true);
1077
+ }
523
1078
  n.addEventListener('click', (ev) => {
524
1079
  if (ev.target.closest('.twm-flow-graph__port')) return;
1080
+ if (multi) {
1081
+ const press = pressed || released;
1082
+ pressed = released = null;
1083
+ if (toggles(ev)) { togglePick(nodeRow.id); return; }
1084
+ if (press?.id === nodeRow.id && press.moved) return; // the tail of a drag
1085
+ }
525
1086
  if (!(selected?.kind === 'node' && selected.id === nodeRow.id)) select({ kind: 'node', id: nodeRow.id });
526
1087
  });
527
1088
  n.addEventListener('focus', () => {
1089
+ // A press's focus is the press's to settle (its click); a node of
1090
+ // the picked set keeps the set when the keyboard arrives on it.
1091
+ if (multi && (pressed?.id === nodeRow.id || isPicked(nodeRow.id))) return;
528
1092
  if (!(selected?.kind === 'node' && selected.id === nodeRow.id)) select({ kind: 'node', id: nodeRow.id });
529
1093
  });
1094
+ if (hoverHighlight) {
1095
+ n.addEventListener('pointerenter', () => hover({ kind: 'node', id: nodeRow.id }));
1096
+ n.addEventListener('pointerleave', () => {
1097
+ if (hovered?.kind === 'node' && hovered.id === nodeRow.id) hover(null);
1098
+ });
1099
+ }
530
1100
  return n;
531
1101
  }
532
1102
 
1103
+ // ── edited on the node ──────────────────────────────────────────────
1104
+
1105
+ /** The type's `inline` settings under the node's ports, each drawn by the
1106
+ * panel's widget registry; their writes are the panel's `flow:node:config`. */
1107
+ function mountInline(n, nodeRow) {
1108
+ const list = inlineFields(nodeRow);
1109
+ if (!list.length) return;
1110
+ const box = el('div', 'twm-flow-graph__node-inline');
1111
+ const drawn = { readOnly: isReadOnly(), fields: [] };
1112
+ for (const f of list) {
1113
+ const slot = { key: f.key, spec: f, shown: shownKey(nodeRow.config[f.key]), field: null };
1114
+ slot.field = inlineField(nodeRow.id, slot, nodeRow.config[f.key]);
1115
+ drawn.fields.push(slot);
1116
+ box.appendChild(slot.field.el);
1117
+ }
1118
+ n.appendChild(box);
1119
+ inlines.set(nodeRow.id, drawn);
1120
+ }
1121
+
1122
+ const shownKey = (v) => (v === undefined ? '' : JSON.stringify(v));
1123
+
1124
+ function inlineField(id, slot, value) {
1125
+ return renderField(slot.spec, value, {
1126
+ widgets, referenceMap: references, strings: S, services, readOnly: isReadOnly(),
1127
+ step: nodeOf(id), type: typeOf(nodeOf(id)), path: '',
1128
+ set: (key, v) => {
1129
+ const before = slot.shown;
1130
+ slot.shown = shownKey(v);
1131
+ // Its own merge key: typing on the node, then in the panel, is two gestures.
1132
+ if (!setNodeConfig(id, { [key]: v }, 'inline')) slot.shown = before;
1133
+ },
1134
+ });
1135
+ }
1136
+
1137
+ /** The node's inline controls brought in step with its config: a control
1138
+ * whose value changed elsewhere (the panel, an undo) is drawn again, and
1139
+ * every one is when read only changed. The one being typed in already
1140
+ * shows what it wrote (its `set` moved `shown`), so it is left alone; a
1141
+ * focused one drawn again gets its focus and caret back. */
1142
+ function paintInline(nodeRow) {
1143
+ const drawn = inlines.get(nodeRow.id);
1144
+ if (!drawn) return;
1145
+ const roNow = isReadOnly();
1146
+ const roChanged = drawn.readOnly !== roNow;
1147
+ drawn.readOnly = roNow;
1148
+ for (const slot of drawn.fields) {
1149
+ const value = nodeRow.config[slot.key];
1150
+ const shown = shownKey(value);
1151
+ if (!roChanged && shown === slot.shown) continue;
1152
+ const held = heldCaret(slot.field);
1153
+ slot.field.destroy();
1154
+ const fresh = inlineField(nodeRow.id, slot, value);
1155
+ slot.field.el.replaceWith(fresh.el);
1156
+ slot.field = fresh;
1157
+ slot.shown = shown;
1158
+ if (held) restoreCaret(fresh, held.caret);
1159
+ }
1160
+ }
1161
+
1162
+ /** Whether `field` holds the focus, and where its caret is: {caret} | null. */
1163
+ function heldCaret(field) {
1164
+ const active = document.activeElement;
1165
+ if (!active || !field.el.contains(active)) return null;
1166
+ let caret = field.caret?.() ?? null;
1167
+ if (!caret) {
1168
+ try {
1169
+ if (Number.isInteger(active.selectionStart)) {
1170
+ caret = { start: active.selectionStart, end: active.selectionEnd, direction: active.selectionDirection };
1171
+ }
1172
+ } catch { /* a type without a caret */ }
1173
+ }
1174
+ return { caret };
1175
+ }
1176
+
1177
+ function restoreCaret(field, caret) {
1178
+ if (!field.focus() || !caret) return;
1179
+ if (field.caret?.()) { field.restoreCaret(caret); return; }
1180
+ const box = document.activeElement;
1181
+ try {
1182
+ const end = box.value.length;
1183
+ box.setSelectionRange(Math.min(caret.start, end), Math.min(caret.end, end), caret.direction || 'none');
1184
+ } catch { /* no caret */ }
1185
+ }
1186
+
1187
+ function dropInline(id) {
1188
+ inlines.get(id)?.fields.forEach((slot) => slot.field.destroy());
1189
+ inlines.delete(id);
1190
+ }
1191
+
1192
+ const shownName = (nodeRow) => nodeRow.label || typeOf(nodeRow)?.label || nodeRow.type;
1193
+
1194
+ /** The node's name as `text` (empty: back to its type's), in place: the
1195
+ * node's title and shape key, so the next paint does not rebuild a node
1196
+ * a press may be on. ONE `flow:node:label` edit. */
1197
+ function renameNode(id, text) {
1198
+ const nodeRow = nodeOf(id);
1199
+ if (!nodeRow) return;
1200
+ if (text) nodeRow.label = text; else delete nodeRow.label;
1201
+ const n = nodeEls.get(nodeRow.id);
1202
+ const name = shownName(nodeRow);
1203
+ const title = n?.querySelector('.twm-flow-graph__node-title');
1204
+ if (title) title.textContent = title.title = name;
1205
+ if (n) {
1206
+ n.dataset.shape = `${nodeRow.type}|${nodeRow.label || ''}`;
1207
+ n.setAttribute('aria-label', name);
1208
+ }
1209
+ commit('flow:node:label', `label:${id}`);
1210
+ }
1211
+
1212
+ /** `renameInPlace`: a text box over the node's title, the name selected. */
1213
+ function startRename(id) {
1214
+ if (!renameInPlace || isReadOnly() || destroyed) return false;
1215
+ renaming?.finish(false);
1216
+ const nodeRow = nodeOf(id);
1217
+ const n = nodeEls.get(id);
1218
+ const title = n?.querySelector('.twm-flow-graph__node-title');
1219
+ if (!nodeRow || !title) return false;
1220
+ const before = shownName(nodeRow);
1221
+ const input = el('input', 'twm-flow-graph__node-title-input');
1222
+ input.type = 'text';
1223
+ input.value = before;
1224
+ input.setAttribute('aria-label', say(S, 'rename'));
1225
+ const edit = { id };
1226
+ let done = false;
1227
+ edit.finish = (keep, { enter = false } = {}) => {
1228
+ if (done) return;
1229
+ const text = input.value.trim();
1230
+ if (keep && !text) {
1231
+ setMessage(say(S, 'nameEmpty'), 'warning');
1232
+ input.setAttribute('aria-invalid', 'true');
1233
+ if (enter && input.isConnected) return; // still open, to be typed again
1234
+ } else if (input.getAttribute('aria-invalid')) setMessage('');
1235
+ done = true;
1236
+ if (renaming === edit) renaming = null;
1237
+ const hadFocus = input === document.activeElement;
1238
+ input.remove();
1239
+ title.hidden = false;
1240
+ if (keep && text && text !== before && nodeOf(id)) {
1241
+ renameNode(id, text);
1242
+ if (selected?.kind === 'node' && selected.id === id) paintPanel();
1243
+ }
1244
+ if (hadFocus && n.isConnected) n.focus({ preventScroll: true });
1245
+ };
1246
+ input.addEventListener('keydown', (ev) => {
1247
+ if (ev.isComposing) return;
1248
+ if (ev.key === 'Enter') { ev.preventDefault(); edit.finish(true, { enter: true }); }
1249
+ else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); edit.finish(false); }
1250
+ });
1251
+ input.addEventListener('blur', () => edit.finish(true));
1252
+ // The box's press is the box's: no move, no pan.
1253
+ input.addEventListener('pointerdown', (ev) => ev.stopPropagation());
1254
+ title.hidden = true;
1255
+ title.after(input);
1256
+ renaming = edit;
1257
+ input.focus({ preventScroll: true });
1258
+ input.select();
1259
+ return true;
1260
+ }
1261
+
1262
+ /** A copy of the node for a body to read: it writes through `ctx.setConfig` only. */
1263
+ const nodeCopy = (nodeRow) => normalise({ nodes: [nodeRow], connections: [] }).nodes[0];
1264
+
1265
+ /** The node's own body: `render` fills it once. One that throws leaves the
1266
+ * node with no body of its own, and says so as one of the editor's findings. */
1267
+ function mountBody(n, nodeRow, own) {
1268
+ const id = nodeRow.id;
1269
+ const box = el('div', 'twm-flow-graph__node-body');
1270
+ const ctx = Object.freeze({
1271
+ get readOnly() { return isReadOnly(); },
1272
+ /** What the host shows on this node now (`setNodeValues`), a copy; null when nothing. */
1273
+ get values() { return valuesCopy(id); },
1274
+ services,
1275
+ setConfig: (patch) => setNodeConfig(id, patch),
1276
+ });
1277
+ try {
1278
+ own.render(box, nodeCopy(nodeRow), ctx);
1279
+ } catch (err) {
1280
+ bodyFailed(nodeRow, 'render', err);
1281
+ return;
1282
+ }
1283
+ n.appendChild(box);
1284
+ bodies.set(id, { el: box, ctx, update: own.update });
1285
+ }
1286
+
1287
+ function bodyFailed(nodeRow, stage, err) {
1288
+ const name = nodeRow.label || typeOf(nodeRow)?.label || nodeRow.type;
1289
+ const why = err && typeof err === 'object' && 'message' in err ? err.message : err;
1290
+ bodyFindings.set(nodeRow.id, Object.freeze({
1291
+ code: `node-${stage}`, severity: 'error', node_id: nodeRow.id,
1292
+ message: say(S, stage === 'render' ? 'renderFailed' : 'updateFailed', name, String(why ?? '')),
1293
+ }));
1294
+ }
1295
+
1296
+ /** A body's write: the node's config merged with `patch` (an `undefined`
1297
+ * value removes that key), as ONE `flow:node:config` edit. Refused while
1298
+ * read only, from inside a `render` or `update`, and whole (nothing
1299
+ * written) when a value in `patch` cannot be cloned. */
1300
+ function setNodeConfig(id, patch, from = 'config') {
1301
+ if (destroyed || painting || isReadOnly()) return false;
1302
+ const nodeRow = nodeOf(id);
1303
+ if (!nodeRow || !patch || typeof patch !== 'object' || Array.isArray(patch)) return false;
1304
+ const keys = Object.keys(patch).sort();
1305
+ if (!keys.length) return false;
1306
+ // Clone the whole patch before touching the node: a value that cannot
1307
+ // be cloned throws here, so a bad patch changes nothing.
1308
+ let next;
1309
+ try { next = structuredClone(patch); } catch { return false; }
1310
+ for (const k of keys) {
1311
+ if (next[k] === undefined) delete nodeRow.config[k]; else nodeRow.config[k] = next[k];
1312
+ }
1313
+ paintNodes();
1314
+ if (selected?.kind === 'node' && selected.id === id) paintPanel();
1315
+ commit('flow:node:config', `${from}:${id}:${keys.join(',')}`);
1316
+ return true;
1317
+ }
1318
+
1319
+ /** The header's fold button (`collapsible`): its press is its own, never a drag. */
1320
+ function foldButton(id) {
1321
+ const b = el('button', 'twm-flow-graph__node-fold');
1322
+ b.type = 'button';
1323
+ b.appendChild(icon('expand_less'));
1324
+ b.addEventListener('pointerdown', (ev) => ev.stopPropagation());
1325
+ b.addEventListener('click', (ev) => {
1326
+ ev.stopPropagation();
1327
+ const nodeRow = nodeOf(id);
1328
+ if (nodeRow) setCollapsed(id, nodeRow.collapsed !== true);
1329
+ });
1330
+ return b;
1331
+ }
1332
+
1333
+ /** Fold a node to its header, or unfold it: ONE `flow:node:collapse` entry.
1334
+ * False when refused: no `collapsible`, read only, no such node, no change. */
1335
+ function setCollapsed(id, collapsed) {
1336
+ if (!folding || destroyed || isReadOnly()) return false;
1337
+ const nodeRow = nodeOf(id);
1338
+ if (!nodeRow || (nodeRow.collapsed === true) === Boolean(collapsed)) return false;
1339
+ if (collapsed) nodeRow.collapsed = true; else delete nodeRow.collapsed;
1340
+ if (connecting?.source === id) cancelConnect();
1341
+ paintNodes();
1342
+ commit('flow:node:collapse');
1343
+ return true;
1344
+ }
1345
+
1346
+ /** One node's entry of `setNodeValues`, read into what is drawn; null when it shows nothing. */
1347
+ function valueEntry(v) {
1348
+ if (!v || typeof v !== 'object') return null;
1349
+ const text = (x) => (x === undefined || x === null || x === '' ? null : String(x));
1350
+ const ports = {};
1351
+ for (const [name, x] of Object.entries(plainObject(v.ports))) {
1352
+ const shown = text(x);
1353
+ if (shown !== null) ports[name] = shown;
1354
+ }
1355
+ const badge = text(v.badge);
1356
+ if (badge === null && !Object.keys(ports).length) return null;
1357
+ return Object.freeze({ badge, tone: TONES.includes(v.tone) ? v.tone : null, ports: Object.freeze(ports) });
1358
+ }
1359
+
1360
+ const valuesCopy = (id) => {
1361
+ const v = liveValues.get(id);
1362
+ return v ? { badge: v.badge, tone: v.tone, ports: { ...v.ports } } : null;
1363
+ };
1364
+
1365
+ /** A node's live values onto its header and its ports, in place. An
1366
+ * element is there only while it has a value to show. */
1367
+ function paintValues(n, nodeRow) {
1368
+ const v = liveValues.get(nodeRow.id) || null;
1369
+ const header = n.querySelector('.twm-flow-graph__node-head');
1370
+ let badge = header.querySelector('.twm-flow-graph__node-badge');
1371
+ if (v?.badge !== null && v?.badge !== undefined) {
1372
+ if (!badge) {
1373
+ badge = el('span', 'twm-flow-graph__node-badge');
1374
+ header.insertBefore(badge, header.querySelector('.twm-flow-graph__node-tag'));
1375
+ }
1376
+ if (badge.textContent !== v.badge) badge.textContent = badge.title = v.badge;
1377
+ if (v.tone) badge.dataset.tone = v.tone; else delete badge.dataset.tone;
1378
+ } else badge?.remove();
1379
+ for (const b of n.querySelectorAll('.twm-flow-graph__port')) {
1380
+ const shown = v && Object.hasOwn(v.ports, b.dataset.port) ? v.ports[b.dataset.port] : null;
1381
+ let tag = b.querySelector('.twm-flow-graph__port-value');
1382
+ if (shown === null) { tag?.remove(); continue; }
1383
+ if (!tag) {
1384
+ tag = el('span', 'twm-flow-graph__port-value');
1385
+ b.insertBefore(tag, b.querySelector('.twm-flow-graph__port-count'));
1386
+ }
1387
+ if (tag.textContent !== shown) tag.textContent = shown;
1388
+ }
1389
+ }
1390
+
1391
+ /** A node's own `update`, onto its body; true when its body's findings changed. */
1392
+ function runUpdate(nodeRow) {
1393
+ const own = bodies.get(nodeRow.id);
1394
+ if (typeof own?.update !== 'function') return false;
1395
+ try {
1396
+ own.update(own.el, nodeCopy(nodeRow), own.ctx);
1397
+ if (bodyFindings.get(nodeRow.id)?.code !== 'node-update') return false;
1398
+ bodyFindings.delete(nodeRow.id);
1399
+ return true;
1400
+ } catch (err) {
1401
+ const before = bodyFindings.get(nodeRow.id)?.message;
1402
+ bodyFailed(nodeRow, 'update', err);
1403
+ return before !== bodyFindings.get(nodeRow.id).message;
1404
+ }
1405
+ }
1406
+
533
1407
  function port(nodeRow, p) {
534
1408
  const b = el('button', `twm-flow-graph__port twm-flow-graph__port--${p.direction === 'input' ? 'in' : 'out'}`);
535
1409
  b.type = 'button';
@@ -537,24 +1411,30 @@ export function createCanvasEditor(host, options = {}) {
537
1411
  b.dataset.direction = p.direction;
538
1412
  b.append(el('span', 'twm-flow-graph__port-dot'), el('span', 'twm-flow-graph__port-label', p.label || p.name),
539
1413
  el('span', 'twm-flow-graph__port-count'));
540
- if ((p.port_type || 'FLOW') !== 'FLOW') {
1414
+ const isData = (p.port_type || 'FLOW') !== 'FLOW';
1415
+ if (isData) {
541
1416
  b.classList.add('twm-flow-graph__port--data');
542
- b.disabled = true;
543
- b.dataset.data = '';
544
- b.title = say(S, 'dataPort', dataPortRead ? dataPortRead(nodeRow, p) : null);
545
- return b;
1417
+ if (!dataConnect) {
1418
+ b.disabled = true;
1419
+ b.dataset.data = '';
1420
+ b.title = say(S, 'dataPort', dataPortRead ? dataPortRead(nodeRow, p) : null);
1421
+ return b;
1422
+ }
546
1423
  }
547
1424
  b.title = p.direction === 'output' ? say(S, 'connectFrom') : say(S, 'connectTo');
548
1425
  b.addEventListener('click', (ev) => {
549
1426
  ev.stopPropagation();
1427
+ if (linking && suppressClick) return; // the tail of a connection drag
550
1428
  portClicked(nodeRow.id, p);
551
1429
  });
1430
+ if (linking && p.direction === 'output') b.addEventListener('pointerdown', (ev) => startConnectDrag(ev, nodeRow.id, p));
552
1431
  return b;
553
1432
  }
554
1433
 
555
1434
  function portClicked(nodeId, p) {
556
1435
  if (isReadOnly()) return;
557
1436
  if (p.direction === 'output') {
1437
+ if (pendingOffer) clearOffer();
558
1438
  connecting = { source: nodeId, sourcePort: p.name };
559
1439
  root.classList.add('twm-flow-graph--connecting');
560
1440
  paintNodes();
@@ -566,6 +1446,11 @@ export function createCanvasEditor(host, options = {}) {
566
1446
  return;
567
1447
  }
568
1448
  const edge = { ...connecting, target: nodeId, targetPort: p.name };
1449
+ if (verdictOf) {
1450
+ cancelConnect();
1451
+ linkTo(edge);
1452
+ return;
1453
+ }
569
1454
  const verdict = connect(graph, catalogue, edge, { strings: S });
570
1455
  cancelConnect();
571
1456
  if (!verdict.ok) { setMessage(verdict.reason, 'warning'); return; }
@@ -580,15 +1465,220 @@ export function createCanvasEditor(host, options = {}) {
580
1465
  paintNodes();
581
1466
  }
582
1467
 
1468
+ // ── connections under the `connections` option ──────────────────────
1469
+
1470
+ /** Ask the rules; refuse in words, show an offer, or connect. */
1471
+ function linkTo(edge) {
1472
+ const verdict = verdictOf(graph, edge);
1473
+ if (!verdict.ok) { setMessage(verdict.reason, 'warning'); return; }
1474
+ const build = () => addConnection(graph, edge);
1475
+ if (verdict.offer) { showOffer(verdict.offer, build, 'flow:edge:connect'); return; }
1476
+ build();
1477
+ setMessage(isReturn(edge, nodeOf(edge.target)) ? say(S, 'connectedReturn') : '');
1478
+ paintEdges();
1479
+ commit('flow:edge:connect');
1480
+ }
1481
+
1482
+ /** A legal-once-something-else-changes line: its sentence, Accept and Not now. */
1483
+ function showOffer(offer, build, action, after = null) {
1484
+ const words = String(offer?.message ?? '');
1485
+ pendingOffer = { offer, build, action, after };
1486
+ const accept = button({ label: offer?.accept ? String(offer.accept) : say(S, 'offerAccept'), primary: true,
1487
+ className: 'twm-flow-graph__offer-accept', onClick: () => acceptOffer() });
1488
+ accept.dataset.action = 'accept-offer';
1489
+ const dismiss = button({ label: say(S, 'offerDismiss'), className: 'twm-flow-graph__offer-dismiss',
1490
+ onClick: () => { clearOffer(); keepFocus(); } });
1491
+ dismiss.dataset.action = 'dismiss-offer';
1492
+ offerBox.replaceChildren(el('p', 'twm-flow-graph__offer-text', words), accept, dismiss);
1493
+ offerBox.hidden = false;
1494
+ setMessage('');
1495
+ }
1496
+
1497
+ function clearOffer() {
1498
+ pendingOffer = null;
1499
+ if (!offerBox) return;
1500
+ offerBox.hidden = true;
1501
+ offerBox.replaceChildren();
1502
+ }
1503
+
1504
+ /** The line (and step) the offer was made for, then its `apply` on a copy — ONE undo entry. */
1505
+ function acceptOffer() {
1506
+ const held = pendingOffer;
1507
+ if (!held || destroyed) return;
1508
+ clearOffer();
1509
+ if (isReadOnly()) return;
1510
+ const made = held.build();
1511
+ if (typeof held.offer?.apply === 'function') {
1512
+ const draft = normalise(graph);
1513
+ const back = held.offer.apply(draft);
1514
+ graph = normalise(back && typeof back === 'object' ? back : draft);
1515
+ }
1516
+ held.after?.(made);
1517
+ prunePick();
1518
+ findingsBy = groupFindings(allFindings(), { graph });
1519
+ paintNodes();
1520
+ paintPanel();
1521
+ announceSelection();
1522
+ commit(held.action);
1523
+ keepFocus();
1524
+ }
1525
+
1526
+ /** An output dragged: released on an input it connects, on the empty canvas it asks for a step. */
1527
+ function startConnectDrag(ev, nodeId, p) {
1528
+ if (ev.button !== 0 || ev.isPrimary === false || isReadOnly()) return;
1529
+ dragCleanup?.();
1530
+ const start = { x: ev.clientX, y: ev.clientY };
1531
+ let draft = null;
1532
+ const onMove = (m) => {
1533
+ if (!draft) {
1534
+ if (Math.abs(m.clientX - start.x) + Math.abs(m.clientY - start.y) < DRAG_PX) return;
1535
+ if (pendingOffer) clearOffer();
1536
+ connecting = { source: nodeId, sourcePort: p.name };
1537
+ root.classList.add('twm-flow-graph--connecting');
1538
+ paintNodes();
1539
+ setMessage(say(S, 'connectDrag'));
1540
+ draft = document.createElementNS(SVG, 'path');
1541
+ draft.setAttribute('class', 'twm-flow-graph__edge twm-flow-graph__edge--draft');
1542
+ }
1543
+ const from = nodeOf(nodeId);
1544
+ if (!from) return;
1545
+ if (!draft.isConnected) canvas.edgeLayer.appendChild(draft);
1546
+ draft.setAttribute('d', routeEdge(anchor(from, p.name, 'output'),
1547
+ canvas.clientToCanvas(m.clientX, m.clientY), { simple: true }));
1548
+ };
1549
+ const finish = () => {
1550
+ window.removeEventListener('pointermove', onMove);
1551
+ window.removeEventListener('pointerup', onUp);
1552
+ window.removeEventListener('pointercancel', onCancel);
1553
+ window.removeEventListener('keydown', onKey, true);
1554
+ draft?.remove();
1555
+ dragCleanup = null;
1556
+ };
1557
+ function onUp(u) {
1558
+ const dragged = Boolean(draft);
1559
+ finish();
1560
+ if (!dragged) return; // a click: click-then-click
1561
+ swallowNextClick({ afterRelease: true });
1562
+ dropConnection(u);
1563
+ }
1564
+ function onCancel() {
1565
+ const dragged = Boolean(draft);
1566
+ finish();
1567
+ if (dragged) { cancelConnect(); setMessage(''); }
1568
+ }
1569
+ function onKey(k) {
1570
+ if (k.key !== 'Escape') return;
1571
+ k.preventDefault();
1572
+ k.stopPropagation();
1573
+ const dragged = Boolean(draft);
1574
+ finish();
1575
+ if (!dragged) return;
1576
+ cancelConnect();
1577
+ setMessage('');
1578
+ swallowNextClick(); // the port is still pressed
1579
+ }
1580
+ window.addEventListener('pointermove', onMove);
1581
+ window.addEventListener('pointerup', onUp);
1582
+ window.addEventListener('pointercancel', onCancel);
1583
+ window.addEventListener('keydown', onKey, true);
1584
+ dragCleanup = finish;
1585
+ }
1586
+
1587
+ /** Where a dragged line was let go. The element under the pointer, not the
1588
+ * release's target: a touch pointer is captured by the port it started on,
1589
+ * so `u.target` is the source port. Released outside the window the point
1590
+ * has no element (null) and the line is let go over nothing. */
1591
+ function dropConnection(u) {
1592
+ const from = connecting;
1593
+ const under = document.elementFromPoint(u.clientX, u.clientY);
1594
+ const nodeHit = under?.closest('[data-node-id]');
1595
+ const onNode = Boolean(nodeHit && canvas.nodeLayer.contains(nodeHit));
1596
+ const portHit = onNode ? under.closest('.twm-flow-graph__port') : null;
1597
+ if (portHit && portHit.dataset.direction === 'input') {
1598
+ portClicked(nodeHit.dataset.nodeId, { direction: 'input', name: portHit.dataset.port });
1599
+ return;
1600
+ }
1601
+ cancelConnect();
1602
+ if (onNode) { setMessage(say(S, 'dropOnInput')); return; }
1603
+ if ((under && canvas.root.contains(under)) || overCanvas(u.clientX, u.clientY)) { openDropPicker(from, u); return; }
1604
+ setMessage('');
1605
+ }
1606
+
1607
+ /** The step picker at the drop, offering only the types with an input `from` may connect to. */
1608
+ function openDropPicker(from, u) {
1609
+ const p = canvas.clientToCanvas(u.clientX, u.clientY);
1610
+ const where = { x: canvas.snap(p.x), y: canvas.snap(p.y - BOX.header / 2) };
1611
+ const fits = new Map();
1612
+ for (const t of catalogue.list()) {
1613
+ if (t.unavailable || !offered(t)) continue;
1614
+ const trial = normalise(graph);
1615
+ const added = addNode(trial, t.type_id, where, { catalogue });
1616
+ let pick = null;
1617
+ for (const inp of catalogue.inputs(t.type_id)) {
1618
+ const v = verdictOf(trial, { ...from, target: added.id, targetPort: inp.name });
1619
+ if (v.ok && (!pick || (pick.offer && !v.offer))) pick = { port: inp.name, offer: v.offer || null };
1620
+ if (pick && !pick.offer) break;
1621
+ }
1622
+ if (pick) fits.set(t.type_id, pick);
1623
+ }
1624
+ if (!fits.size) { setMessage(say(S, 'dropNothingFits'), 'warning'); return; }
1625
+ const mark = el('span', 'twm-flow-graph__drop-anchor');
1626
+ const box = canvasHost.getBoundingClientRect();
1627
+ mark.style.left = `${Math.round(u.clientX - box.left)}px`;
1628
+ mark.style.top = `${Math.round(u.clientY - box.top)}px`;
1629
+ canvasHost.appendChild(mark);
1630
+ const asked = snapshot();
1631
+ openStepPicker({
1632
+ anchor: mark, strings: S, title: say(S, 'dropPickerTitle'),
1633
+ categories: catalogue.categories.map((c) => ({ id: c.id, label: c.label, layout: c.layout })),
1634
+ entries: catalogue.list().filter((t) => fits.has(t.type_id)).map((t) => ({
1635
+ id: t.type_id, label: t.label, description: t.description, icon: t.icon || 'widgets',
1636
+ tone: catalogue.tone(t.type_id), category: t.category ?? '' })),
1637
+ }).then((choice) => {
1638
+ mark.remove();
1639
+ if (destroyed) return;
1640
+ if (!choice?.entry || isReadOnly() || snapshot() !== asked) { keepFocus(); return; }
1641
+ const fitting = fits.get(choice.entry.id);
1642
+ const build = () => {
1643
+ const added = addNode(graph, choice.entry.id, where, { catalogue });
1644
+ addConnection(graph, { ...from, target: added.id, targetPort: fitting.port });
1645
+ return added.id;
1646
+ };
1647
+ const after = (id) => { if (nodeOf(id)) selected = { kind: 'node', id }; };
1648
+ if (fitting.offer) { showOffer(fitting.offer, build, 'flow:node:add', after); keepFocus(); return; }
1649
+ after(build());
1650
+ paintNodes();
1651
+ paintPanel();
1652
+ announceSelection();
1653
+ commit('flow:node:add');
1654
+ keepFocus();
1655
+ });
1656
+ }
1657
+
1658
+ /** A press on a node's header: it picks the node and drags it — or, when
1659
+ * it is one of several picked (`multiSelect`), drags them all. A
1660
+ * Shift/Ctrl press is a toggle, settled by its click, and drags nothing. */
583
1661
  function startMove(ev, nodeId) {
584
1662
  if (ev.button !== 0) return;
585
1663
  ev.stopPropagation();
586
- select({ kind: 'node', id: nodeId });
1664
+ if (toggles(ev)) return;
1665
+ if (!(multi && isPicked(nodeId))) select({ kind: 'node', id: nodeId });
587
1666
  if (isReadOnly()) return;
588
- const nodeRow = nodeOf(nodeId);
589
- const n = nodeEls.get(nodeId);
590
- if (!nodeRow || !n) return;
591
- const from = { ...nodeRow.position };
1667
+ const ids = selected?.kind === 'many' ? selected.ids : [nodeId];
1668
+ if (!ids.includes(nodeId)) return;
1669
+ dragItems(ev, ids, pickedNotes(), () => { if (pressed?.id === nodeId) pressed.moved = true; });
1670
+ }
1671
+
1672
+ /** Drag these nodes and notes together by the pointer: ONE entry when it
1673
+ * moved — `flow:node:move` for one node, `flow:note:move` for notes only,
1674
+ * `flow:nodes:move` for anything more. */
1675
+ function dragItems(ev, nodeIds, noteIds, onFirstMove = null) {
1676
+ const rows = [
1677
+ ...nodeIds.map((id) => ({ item: nodeOf(id), n: nodeEls.get(id) })),
1678
+ ...noteIds.map((id) => ({ item: noteOf(id), n: noteEls.get(id), note: true })),
1679
+ ].filter((r) => r.item && r.n);
1680
+ if (!rows.length) return;
1681
+ const from = rows.map((r) => ({ ...r.item.position }));
592
1682
  const start = { x: ev.clientX, y: ev.clientY };
593
1683
  let moved = false;
594
1684
  const onMove = (m) => {
@@ -596,16 +1686,21 @@ export function createCanvasEditor(host, options = {}) {
596
1686
  const dy = (m.clientY - start.y) / canvas.scale;
597
1687
  if (!moved && Math.abs(dx) + Math.abs(dy) < 3) return;
598
1688
  moved = true;
599
- nodeRow.position = { x: canvas.snap(from.x + dx), y: canvas.snap(from.y + dy) };
600
- n.style.left = `${nodeRow.position.x}px`;
601
- n.style.top = `${nodeRow.position.y}px`;
1689
+ onFirstMove?.();
1690
+ rows.forEach(({ item, n }, i) => {
1691
+ item.position = { x: canvas.snap(from[i].x + dx), y: canvas.snap(from[i].y + dy) };
1692
+ n.style.left = `${item.position.x}px`;
1693
+ n.style.top = `${item.position.y}px`;
1694
+ });
602
1695
  paintEdges();
603
1696
  };
604
1697
  const onUp = () => {
605
1698
  window.removeEventListener('pointermove', onMove);
606
1699
  window.removeEventListener('pointerup', onUp);
607
1700
  window.removeEventListener('pointercancel', onUp);
608
- if (moved) commit('flow:node:move');
1701
+ if (!moved) return;
1702
+ const nodes = rows.filter((r) => !r.note).length;
1703
+ commit(!nodes ? 'flow:note:move' : rows.length > 1 ? 'flow:nodes:move' : 'flow:node:move');
609
1704
  };
610
1705
  window.addEventListener('pointermove', onMove);
611
1706
  window.addEventListener('pointerup', onUp);
@@ -613,65 +1708,200 @@ export function createCanvasEditor(host, options = {}) {
613
1708
  }
614
1709
 
615
1710
  /** Positions and classes onto the nodes that exist; a node is BUILT only
616
- * when it is new or its type or label changed, never on a press. */
1711
+ * when it is new or its type, its type's definition or its label changed,
1712
+ * never on a press. A node's own body is brought up to date in place. */
617
1713
  function paintNodes() {
618
1714
  if (destroyed) return;
619
- const live = new Set(graph.nodes.map((x) => x.id));
620
- for (const [id, n] of nodeEls) {
621
- if (!live.has(id)) { n.remove(); nodeEls.delete(id); }
622
- }
623
- for (const nodeRow of graph.nodes) {
624
- let n = nodeEls.get(nodeRow.id);
625
- const shape = `${nodeRow.type}|${nodeRow.label || ''}`;
626
- if (!n || n.dataset.shape !== shape) {
627
- const fresh = buildNode(nodeRow);
628
- fresh.dataset.shape = shape;
629
- const focused = n && n.contains(document.activeElement);
630
- if (n) n.replaceWith(fresh); else canvas.nodeLayer.appendChild(fresh);
631
- nodeEls.set(nodeRow.id, fresh);
632
- n = fresh;
633
- if (focused) fresh.focus({ preventScroll: true });
634
- }
635
- n.style.left = `${nodeRow.position.x}px`;
636
- n.style.top = `${nodeRow.position.y}px`;
637
- n.style.width = `${BOX.width}px`;
638
- const picked = selected?.kind === 'node' && selected.id === nodeRow.id;
639
- n.classList.toggle('twm-flow-graph__node--picked', picked);
640
- if (picked) n.setAttribute('aria-current', 'true'); else n.removeAttribute('aria-current');
641
- n.classList.toggle('twm-flow-graph__node--connect-source', connecting?.source === nodeRow.id);
642
- const mine = findingsBy.get(nodeRow.id) || [];
643
- const errors = mine.filter((f) => f.severity !== 'warning');
644
- n.classList.toggle('twm-flow-graph__node--error', errors.length > 0);
645
- n.classList.toggle('twm-flow-graph__node--warning', errors.length === 0 && mine.length > 0);
646
- n.querySelector('.twm-flow-graph__node-findings').replaceChildren(...mine.slice(0, 2).map((f) => el('p',
647
- `twm-flow-graph__node-finding${f.severity === 'warning' ? ' twm-flow-graph__node-finding--warning' : ''}`,
648
- String(f.message ?? ''))));
649
- const summary = n.querySelector('.twm-flow-graph__node-summary');
650
- if (summary) summary.textContent = summarise(nodeRow, typeOf(nodeRow)) || '';
651
- const step = overlay?.steps?.[nodeRow.id] || null;
652
- const line = n.querySelector('.twm-flow-graph__node-run');
653
- line.textContent = step ? String(step.line ?? step.state ?? '') : '';
654
- if (step?.state) line.dataset.state = String(step.state); else delete line.dataset.state;
655
- if (step?.tone) line.dataset.tone = String(step.tone); else delete line.dataset.tone;
656
- n.classList.toggle('twm-flow-graph__node--skipped', step?.state === 'skipped' || step?.state === 'not-reached');
657
- const ports = overlay?.ports?.[nodeRow.id] || null;
658
- for (const b of n.querySelectorAll('.twm-flow-graph__port')) {
659
- if (!('data' in b.dataset)) b.disabled = isReadOnly();
660
- const mark = b.dataset.direction === 'output' ? ports?.[b.dataset.port] : null;
661
- b.classList.toggle('twm-flow-graph__port--taken', Boolean(mark?.taken));
662
- b.classList.toggle('twm-flow-graph__port--untaken', Boolean(mark) && !mark.taken);
663
- const count = b.querySelector('.twm-flow-graph__port-count');
664
- count.textContent = mark?.taken && Number.isFinite(Number(mark.count)) && mark.count !== null
665
- ? say(S, 'portCount', mark.count) : mark && !mark.taken ? say(S, 'portNotTaken') : '';
1715
+ painting = true;
1716
+ let bodyChanged = false;
1717
+ try {
1718
+ const live = new Set(graph.nodes.map((x) => x.id));
1719
+ for (const [id, n] of nodeEls) {
1720
+ if (!live.has(id)) {
1721
+ n.remove();
1722
+ nodeEls.delete(id);
1723
+ builtFrom.delete(id);
1724
+ bodies.delete(id);
1725
+ dropInline(id);
1726
+ if (renaming?.id === id) renaming.finish(false);
1727
+ bodyChanged = bodyFindings.delete(id) || bodyChanged;
1728
+ }
666
1729
  }
1730
+ if (bodyChanged) findingsBy = groupFindings(allFindings(), { graph });
1731
+ const picks = pickedSet();
1732
+ for (const nodeRow of graph.nodes) bodyChanged = paintNode(nodeRow, picks) || bodyChanged;
1733
+ paintNotes();
1734
+ } finally {
1735
+ painting = false;
1736
+ }
1737
+ if (bodyChanged) {
1738
+ // The editor's own findings moved: the strip and the panel say so too.
1739
+ paintStrip();
1740
+ panel.setFindings(allFindings());
667
1741
  }
668
1742
  paintEdges();
669
1743
  }
670
1744
 
1745
+ /** The consumer's `edgeStyle` for one line, read into what the editor draws. */
1746
+ function edgeLook(c, source, target, back) {
1747
+ const look = edgeStyle({ ...c }, { source, target, back });
1748
+ if (!look || typeof look !== 'object') return null;
1749
+ const width = Number(look.width);
1750
+ return {
1751
+ tone: TONES.includes(look.tone) ? look.tone : null,
1752
+ dashed: Boolean(look.dashed),
1753
+ width: Number.isFinite(width) && width > 0 ? width : null,
1754
+ marker: typeof look.marker === 'string' && look.marker ? look.marker : null,
1755
+ label: look.label === undefined || look.label === null || look.label === '' ? null : String(look.label),
1756
+ };
1757
+ }
1758
+
1759
+ /** A line's colour and width are custom properties the stylesheet reads,
1760
+ * so a picked or highlighted line still wins over the consumer's. */
1761
+ function applyLook(element, look) {
1762
+ if (look.tone) element.style.setProperty('--twm-canvas-edge-stroke', `var(--twm-flow-${look.tone}-fg)`);
1763
+ if (look.width) element.style.setProperty('--twm-canvas-edge-width', String(look.width));
1764
+ }
1765
+
1766
+ /** The arrowhead: its tip on the input port, its base where the line ends. */
1767
+ function arrowHead(tip, side) {
1768
+ const out = side === 'right' ? 1 : -1;
1769
+ const baseX = tip.x + out * ARROW.length;
1770
+ const head = document.createElementNS(SVG, 'path');
1771
+ head.setAttribute('class', 'twm-flow-graph__edge-arrow');
1772
+ head.setAttribute('d', `M ${tip.x} ${tip.y} L ${baseX} ${tip.y - ARROW.half} L ${baseX} ${tip.y + ARROW.half} Z`);
1773
+ return head;
1774
+ }
1775
+
1776
+ /** Where a line into `to` stops: behind its marker, which ends it. */
1777
+ function lineEnd(to, marker) {
1778
+ if (marker === 'arrow') return { ...to, x: to.x + (to.side === 'right' ? 1 : -1) * ARROW.length };
1779
+ return marker ? footAnchor(to, to.side, marker) : to;
1780
+ }
1781
+
1782
+ /** Does the line drawing `c` touch what the pointer is over? */
1783
+ const touches = (c) => (hovered?.kind === 'node' ? c.source === hovered.id || c.target === hovered.id
1784
+ : hovered?.kind === 'edge' ? sameEdge(c, hovered.edge) : false);
1785
+
1786
+ /** The highlight follows the pointer IN PLACE: a line rebuilt under the
1787
+ * pointer would be entered again, and repaint again, on every move. */
1788
+ function hover(next) {
1789
+ hovered = next;
1790
+ for (const { c, line } of edgeEls) line.classList.toggle('twm-flow-graph__edge--highlighted', touches(c));
1791
+ }
1792
+
1793
+ /** The consumer's findings, and — while `routing: 'obstacles'` is over the
1794
+ * kernel's limit — the editor's own sentence saying the lines are plain. */
1795
+ function paintStrip() {
1796
+ strip.set(routingFallback ? [...allFindings(), { code: 'canvas-routing-simple', severity: 'warning',
1797
+ message: say(S, 'routingFallback', OBSTACLE_ROUTING_NODE_LIMIT) }]
1798
+ : allFindings());
1799
+ }
1800
+
1801
+ /** One node onto the canvas; true when its body's findings changed. */
1802
+ function paintNode(nodeRow, picks) {
1803
+ let bodyChanged = false;
1804
+ let n = nodeEls.get(nodeRow.id);
1805
+ const def = typeOf(nodeRow);
1806
+ const shape = `${nodeRow.type}|${nodeRow.label || ''}`;
1807
+ const key = buildKey(nodeRow);
1808
+ if (!n || n.dataset.shape !== shape || !sameBuild(builtFrom.get(nodeRow.id), key)) {
1809
+ if (renaming?.id === nodeRow.id) renaming.finish(false);
1810
+ bodies.delete(nodeRow.id);
1811
+ dropInline(nodeRow.id);
1812
+ bodyChanged = bodyFindings.delete(nodeRow.id);
1813
+ const fresh = buildNode(nodeRow, def);
1814
+ bodyChanged = bodyChanged || bodyFindings.has(nodeRow.id);
1815
+ fresh.dataset.shape = shape;
1816
+ const focused = n && n.contains(document.activeElement);
1817
+ if (n) n.replaceWith(fresh); else canvas.nodeLayer.appendChild(fresh);
1818
+ nodeEls.set(nodeRow.id, fresh);
1819
+ builtFrom.set(nodeRow.id, key);
1820
+ n = fresh;
1821
+ if (focused) fresh.focus({ preventScroll: true });
1822
+ } else paintInline(nodeRow);
1823
+ bodyChanged = runUpdate(nodeRow) || bodyChanged;
1824
+ if (bodyChanged) findingsBy = groupFindings(allFindings(), { graph });
1825
+ n.style.left = `${nodeRow.position.x}px`;
1826
+ n.style.top = `${nodeRow.position.y}px`;
1827
+ n.style.width = `${widthOf(nodeRow)}px`;
1828
+ const folded = BOX.folded(nodeRow);
1829
+ const h = folded ? null : catalogue.size?.(nodeRow.type)?.h;
1830
+ if (h) n.style.height = `${h}px`; else n.style.removeProperty('height');
1831
+ n.classList.toggle('twm-flow-graph__node--sized', Boolean(h));
1832
+ if (folding) paintFold(n, folded);
1833
+ if (liveValues.size || n.querySelector('.twm-flow-graph__node-badge, .twm-flow-graph__port-value')) {
1834
+ paintValues(n, nodeRow);
1835
+ }
1836
+ const accent = catalogue.accent?.(nodeRow.type) || null;
1837
+ if (accent) n.dataset.accent = accent; else delete n.dataset.accent;
1838
+ const picked = picks.has(nodeRow.id);
1839
+ n.classList.toggle('twm-flow-graph__node--picked', picked);
1840
+ if (picked) n.setAttribute('aria-current', 'true'); else n.removeAttribute('aria-current');
1841
+ n.classList.toggle('twm-flow-graph__node--connect-source', connecting?.source === nodeRow.id);
1842
+ const mine = findingsBy.get(nodeRow.id) || [];
1843
+ const errors = mine.filter((f) => f.severity !== 'warning');
1844
+ n.classList.toggle('twm-flow-graph__node--error', errors.length > 0);
1845
+ n.classList.toggle('twm-flow-graph__node--warning', errors.length === 0 && mine.length > 0);
1846
+ n.querySelector('.twm-flow-graph__node-findings').replaceChildren(...mine.slice(0, 2).map((f) => el('p',
1847
+ `twm-flow-graph__node-finding${f.severity === 'warning' ? ' twm-flow-graph__node-finding--warning' : ''}`,
1848
+ String(f.message ?? ''))));
1849
+ const summary = n.querySelector('.twm-flow-graph__node-summary');
1850
+ if (summary) summary.textContent = summarise(nodeRow, typeOf(nodeRow)) || '';
1851
+ const step = overlay?.steps?.[nodeRow.id] || null;
1852
+ const line = n.querySelector('.twm-flow-graph__node-run');
1853
+ line.textContent = step ? String(step.line ?? step.state ?? '') : '';
1854
+ if (step?.state) line.dataset.state = String(step.state); else delete line.dataset.state;
1855
+ if (step?.tone) line.dataset.tone = String(step.tone); else delete line.dataset.tone;
1856
+ n.classList.toggle('twm-flow-graph__node--skipped', step?.state === 'skipped' || step?.state === 'not-reached');
1857
+ const ports = overlay?.ports?.[nodeRow.id] || null;
1858
+ for (const b of n.querySelectorAll('.twm-flow-graph__port')) {
1859
+ if (!('data' in b.dataset)) b.disabled = isReadOnly();
1860
+ if (verdictOf && b.dataset.direction === 'input') {
1861
+ b.classList.toggle('twm-flow-graph__port--can-drop', Boolean(connecting) && verdictOf(graph, {
1862
+ ...connecting, target: nodeRow.id, targetPort: b.dataset.port }).ok);
1863
+ }
1864
+ const mark = b.dataset.direction === 'output' ? ports?.[b.dataset.port] : null;
1865
+ b.classList.toggle('twm-flow-graph__port--taken', Boolean(mark?.taken));
1866
+ b.classList.toggle('twm-flow-graph__port--untaken', Boolean(mark) && !mark.taken);
1867
+ const count = b.querySelector('.twm-flow-graph__port-count');
1868
+ count.textContent = mark?.taken && Number.isFinite(Number(mark.count)) && mark.count !== null
1869
+ ? say(S, 'portCount', mark.count) : mark && !mark.taken ? say(S, 'portNotTaken') : '';
1870
+ }
1871
+ return bodyChanged;
1872
+ }
1873
+
1874
+ /** A node folded or not (`collapsible`): its class and its button's state, in place. */
1875
+ function paintFold(n, folded) {
1876
+ n.classList.toggle('twm-flow-graph__node--collapsed', folded);
1877
+ const b = n.querySelector('.twm-flow-graph__node-fold');
1878
+ if (!b) return;
1879
+ b.disabled = isReadOnly();
1880
+ b.setAttribute('aria-expanded', String(!folded));
1881
+ b.title = say(S, folded ? 'expandNode' : 'collapseNode');
1882
+ b.setAttribute('aria-label', b.title);
1883
+ const glyph = b.querySelector('.material-symbols-outlined');
1884
+ const name = folded ? 'expand_more' : 'expand_less';
1885
+ if (glyph && glyph.textContent !== name) glyph.textContent = name;
1886
+ }
1887
+
671
1888
  function paintEdges() {
672
1889
  if (destroyed) return;
1890
+ const fallback = routing === 'obstacles' && graph.nodes.length > OBSTACLE_ROUTING_NODE_LIMIT;
1891
+ if (fallback !== routingFallback) {
1892
+ routingFallback = fallback;
1893
+ paintStrip();
1894
+ }
1895
+ const routed = routing === 'obstacles' && !fallback;
673
1896
  const byId = new Map(graph.nodes.map((x) => [x.id, x]));
1897
+ const copies = styled ? new Map(normalise(graph).nodes.map((x) => [x.id, x])) : null;
1898
+ const boxes = routed ? graph.nodes.map((x) => ({
1899
+ id: x.id, x: x.position.x - CLEARANCE, y: x.position.y - CLEARANCE,
1900
+ width: widthOf(x) + 2 * CLEARANCE, height: bottomOf(x) - x.position.y + 2 * CLEARANCE,
1901
+ })) : null;
674
1902
  const paths = [];
1903
+ const labels = [];
1904
+ edgeEls = [];
675
1905
  graph.connections.forEach((c) => {
676
1906
  const a = byId.get(c.source);
677
1907
  const b = byId.get(c.target);
@@ -679,19 +1909,25 @@ export function createCanvasEditor(host, options = {}) {
679
1909
  const from = anchor(a, c.sourcePort, 'output');
680
1910
  const to = anchor(b, c.targetPort, 'input');
681
1911
  const back = isReturn(c, b);
1912
+ const look = styled ? edgeLook(c, copies.get(c.source), copies.get(c.target), back) : null;
1913
+ const end = look?.marker ? lineEnd(to, look.marker) : to;
682
1914
  // A loop's return runs UNDER both steps: drawn as a forward curve it
683
1915
  // doubles back across them and reads as one more step.
684
- const d = back ? returnEdge(from, to, { floor: Math.max(bottomOf(a), bottomOf(b)) + RETURN_DROP })
685
- : routeEdge(from, to, { simple: true });
1916
+ const d = back ? returnEdge(from, end, { floor: Math.max(bottomOf(a), bottomOf(b)) + RETURN_DROP })
1917
+ : routed ? routeEdge(from, end, { obstacles: boxes.filter((x) => x.id !== a.id && x.id !== b.id) })
1918
+ : routeEdge(from, end, { simple: true });
686
1919
  const picked = selected?.kind === 'edge' && sameEdge(selected.edge, c);
687
1920
  const mark = overlay?.ports?.[c.source]?.[c.sourcePort];
688
1921
  const line = document.createElementNS(SVG, 'path');
689
1922
  line.setAttribute('class', ['twm-flow-graph__edge', back ? 'twm-flow-graph__edge--back' : '',
1923
+ look?.dashed ? 'twm-flow-graph__edge--dashed' : '',
690
1924
  picked ? 'twm-flow-graph__edge--picked' : '',
691
1925
  mark?.taken ? 'twm-flow-graph__edge--taken' : '',
692
- mark && !mark.taken ? 'twm-flow-graph__edge--untaken' : '']
1926
+ mark && !mark.taken ? 'twm-flow-graph__edge--untaken' : '',
1927
+ hovered && touches(c) ? 'twm-flow-graph__edge--highlighted' : '']
693
1928
  .filter(Boolean).join(' '));
694
1929
  line.setAttribute('d', d);
1930
+ if (look) applyLook(line, look);
695
1931
  const hit = document.createElementNS(SVG, 'path');
696
1932
  hit.setAttribute('class', 'twm-flow-graph__edge-hit');
697
1933
  hit.setAttribute('d', d);
@@ -700,13 +1936,40 @@ export function createCanvasEditor(host, options = {}) {
700
1936
  title.textContent = say(S, 'returnLine');
701
1937
  hit.appendChild(title);
702
1938
  }
703
- hit.addEventListener('pointerdown', (ev) => {
1939
+ const pick = (ev) => {
704
1940
  ev.stopPropagation();
705
1941
  select({ kind: 'edge', edge: { ...c } });
706
- });
707
- paths.push(line, hit);
1942
+ };
1943
+ hit.addEventListener('pointerdown', pick);
1944
+ if (hoverHighlight) {
1945
+ hit.addEventListener('pointerenter', () => hover({ kind: 'edge', edge: { ...c } }));
1946
+ hit.addEventListener('pointerleave', () => {
1947
+ if (hovered?.kind === 'edge' && sameEdge(hovered.edge, c)) hover(null);
1948
+ });
1949
+ }
1950
+ paths.push(line);
1951
+ if (look?.marker) {
1952
+ const head = look.marker === 'arrow' ? arrowHead(to, to.side) : crowsFoot(to, to.side, look.marker);
1953
+ if (look.marker !== 'arrow') head.classList.add('twm-flow-graph__edge-foot');
1954
+ applyLook(head, look);
1955
+ paths.push(head);
1956
+ }
1957
+ paths.push(hit);
1958
+ if (look?.label !== null && look?.label !== undefined) {
1959
+ const at = pathMidpoint(d);
1960
+ const tag = el('div', 'twm-flow-graph__edge-label', look.label);
1961
+ tag.style.left = `${at.x}px`;
1962
+ tag.style.top = `${at.y}px`;
1963
+ applyLook(tag, look);
1964
+ tag.addEventListener('pointerdown', pick);
1965
+ labels.push(tag);
1966
+ }
1967
+ edgeEls.push({ c, line });
708
1968
  });
709
1969
  canvas.edgeLayer.replaceChildren(...paths);
1970
+ labelLayer?.replaceChildren(...labels);
1971
+ paintFrames();
1972
+ paintMinimap();
710
1973
  }
711
1974
 
712
1975
  /** Frame the graph. Never above NATURAL: a short flow is not blown up until
@@ -718,12 +1981,15 @@ export function createCanvasEditor(host, options = {}) {
718
1981
  * it was looked like a button that does nothing. */
719
1982
  function fit({ whole = false } = {}) {
720
1983
  if (!graph.nodes.length || destroyed) return;
721
- const xs = graph.nodes.map((x) => x.position.x);
722
- const ys = graph.nodes.map((x) => x.position.y);
723
- const x = Math.min(...xs);
724
- const y = Math.min(...ys);
725
- const height = Math.max(...graph.nodes.map(bottomOf)) - y;
726
- canvas.fit({ x, y, width: Math.max(...xs) + BOX.width - x, height });
1984
+ // A drawn note is framed with the steps.
1985
+ const notes = noting ? noteList().map((n) => ({ x: n.position.x, y: n.position.y,
1986
+ right: n.position.x + (n.size?.w || NOTE_WIDTH),
1987
+ bottom: n.position.y + (n.size?.h || noteEls.get(n.id)?.offsetHeight || 0) })) : [];
1988
+ const x = Math.min(...graph.nodes.map((n) => n.position.x), ...notes.map((n) => n.x));
1989
+ const y = Math.min(...graph.nodes.map((n) => n.position.y), ...notes.map((n) => n.y));
1990
+ const height = Math.max(...graph.nodes.map(bottomOf), ...notes.map((n) => n.bottom)) - y;
1991
+ const right = Math.max(...graph.nodes.map((n) => n.position.x + widthOf(n)), ...notes.map((n) => n.right));
1992
+ canvas.fit({ x, y, width: right - x, height });
727
1993
  const scale = canvas.scale > NATURAL ? NATURAL
728
1994
  : !whole && canvas.scale < READABLE ? READABLE : null;
729
1995
  if (scale !== null) {
@@ -732,23 +1998,399 @@ export function createCanvasEditor(host, options = {}) {
732
1998
  }
733
1999
  }
734
2000
 
735
- /** ARRANGE: `layoutGraph`, anchored at the graph's current top-left corner
736
- * so nothing jumps off screen, and ONE undo entry. */
737
- function arrangeGraph() {
2001
+ /** Where the `layout` option puts each of `part`'s steps: a Map of id →
2002
+ * `{x, y}` from the origin. */
2003
+ function placeSteps(part, ids) {
2004
+ if (layout === 'flow') {
2005
+ return layoutGraph(part, catalogue, { ...BOX, loopEntry: loopPorts.entry, heightOf: (n) => measured(n) || null });
2006
+ }
2007
+ const sizeOf = (n) => ({ width: widthOf(n), height: heightOf(n) });
2008
+ if (layout === 'layered') {
2009
+ return layeredLayout(part.nodes, part.connections.map((c) => ({ from: c.source, to: c.target })), {
2010
+ rank: (n) => catalogue.rank(n.type), sizeOf, columnGap: 96, rowGap: 40, componentGap: 80 });
2011
+ }
2012
+ const at = layout(normalise(part), { catalogue, node: BOX, ids, sizeOf, layeredLayout });
2013
+ if (!(at instanceof Map)) throw new Error('The canvas editor\'s layout function must return a Map of id → {x, y}.');
2014
+ for (const [id, p] of at) {
2015
+ if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) {
2016
+ throw new Error(`The canvas editor's layout function placed "${id}" at ${JSON.stringify(p)}; a place must be finite numbers {x, y}.`);
2017
+ }
2018
+ }
2019
+ return at;
2020
+ }
2021
+
2022
+ /** What the toolbar's Arrange lays out: the picked steps when several are
2023
+ * picked (`multiSelect`), else the whole graph. Picked notes do not count:
2024
+ * one step and a note arrange the whole graph. */
2025
+ const arrangeTarget = () => (selected?.kind === 'many' && selected.ids.length >= 2 ? { ids: [...selected.ids] } : {});
2026
+
2027
+ /** ARRANGE: the `layout` option's places, anchored at the current top-left
2028
+ * corner of what is arranged so nothing jumps off screen, and ONE undo
2029
+ * entry. With `ids`, only those steps (two or more) and the lines between
2030
+ * them are laid out, and only they move. */
2031
+ function arrangeGraph({ ids = null } = {}) {
738
2032
  if (refusals().arrange) return false;
739
- const at = layoutGraph(graph, catalogue, { ...BOX, loopEntry: loopPorts.entry });
740
- const ox = Math.min(...graph.nodes.map((x) => x.position.x));
741
- const oy = Math.min(...graph.nodes.map((x) => x.position.y));
742
- for (const nodeRow of graph.nodes) {
2033
+ const only = Array.isArray(ids) ? new Set(ids.map(String)) : null;
2034
+ const nodes = only ? graph.nodes.filter((n) => only.has(n.id)) : graph.nodes;
2035
+ if (nodes.length < 2) return false;
2036
+ const part = only ? { nodes, connections: graph.connections.filter((c) => only.has(c.source) && only.has(c.target)) }
2037
+ : graph;
2038
+ const at = placeSteps(part, only ? nodes.map((n) => n.id) : null);
2039
+ const ox = Math.min(...nodes.map((x) => x.position.x));
2040
+ const oy = Math.min(...nodes.map((x) => x.position.y));
2041
+ for (const nodeRow of nodes) {
743
2042
  const p = at.get(nodeRow.id);
744
- if (p) nodeRow.position = { x: canvas.snap(ox + p.x), y: canvas.snap(oy + p.y) };
2043
+ if (p) {
2044
+ nodeRow.position = { x: canvas.snap(ox + p.x), y: canvas.snap(oy + p.y) };
2045
+ }
745
2046
  }
746
2047
  paintNodes();
747
2048
  commit('flow:arrange');
748
- fit();
2049
+ if (!only) fit();
2050
+ return true;
2051
+ }
2052
+
2053
+ // ── groups and notes (`groups`, `notes`) ────────────────────────────
2054
+
2055
+ /** A fresh id among `list`'s: `stem`, `stem-2`, … */
2056
+ function freshId(list, stem) {
2057
+ const taken = new Set(list.map((x) => x.id));
2058
+ if (!taken.has(stem)) return stem;
2059
+ for (let i = 2; ; i += 1) if (!taken.has(`${stem}-${i}`)) return `${stem}-${i}`;
2060
+ }
2061
+
2062
+ /** The groups the picked nodes are in, in graph order. */
2063
+ const groupsOfPick = () => {
2064
+ const ids = pickedSet();
2065
+ return groupList().filter((gr) => gr.nodes.some((id) => ids.has(id)));
2066
+ };
2067
+
2068
+ /** Take `ids` out of every group (a node is in one at most); a group left
2069
+ * with no member goes. */
2070
+ function ungroupNodes(ids) {
2071
+ const out = new Set(ids);
2072
+ for (const gr of groupList()) gr.nodes = gr.nodes.filter((id) => !out.has(id));
2073
+ setGroups(groupList().filter((gr) => gr.nodes.length));
2074
+ }
2075
+
2076
+ function setGroups(list) {
2077
+ if (list.length) graph.groups = list; else delete graph.groups;
2078
+ }
2079
+
2080
+ /** Group these nodes (default: the pick) as ONE `flow:group:add`; each
2081
+ * leaves the group it was in. The new group's id, or null when refused. */
2082
+ function groupNodes(ids, { label = '', tone = null } = {}) {
2083
+ if (!grouping || isReadOnly()) return null;
2084
+ const want = new Set(ids);
2085
+ const members = graph.nodes.filter((x) => want.has(x.id)).map((x) => x.id);
2086
+ if (!members.length) return null;
2087
+ ungroupNodes(members);
2088
+ const id = freshId(groupList(), 'group');
2089
+ setGroups([...groupList(), { id, ...(label ? { label: String(label) } : {}),
2090
+ ...(tone ? { tone: String(tone) } : {}), nodes: members }]);
2091
+ paintFrames();
2092
+ commit('flow:group:add');
2093
+ paintToolbar();
2094
+ return id;
2095
+ }
2096
+
2097
+ function groupPicked() {
2098
+ if (refusals().group) return false;
2099
+ return groupNodes(pickedIds()) !== null;
2100
+ }
2101
+
2102
+ /** Ungroup these groups (default: the ones the pick is in) as ONE `flow:group:remove`. */
2103
+ function ungroupGroups(groupIds) {
2104
+ if (!grouping || isReadOnly()) return false;
2105
+ const gone = new Set(groupIds.filter((id) => groupOf(id)));
2106
+ if (!gone.size) return false;
2107
+ setGroups(groupList().filter((gr) => !gone.has(gr.id)));
2108
+ paintFrames();
2109
+ commit('flow:group:remove');
2110
+ paintToolbar();
2111
+ return true;
2112
+ }
2113
+
2114
+ function ungroupPicked() {
2115
+ if (refusals().ungroup) return false;
2116
+ return ungroupGroups(groupsOfPick().map((gr) => gr.id));
2117
+ }
2118
+
2119
+ /** A group's name and tone, as ONE `flow:group:label` (typing merges). */
2120
+ function setGroupLook(id, { label, tone } = {}) {
2121
+ const gr = grouping && !isReadOnly() ? groupOf(id) : null;
2122
+ if (!gr) return false;
2123
+ const name = label === undefined ? gr.label || '' : String(label ?? '');
2124
+ const look = tone === undefined ? gr.tone || '' : String(tone ?? '');
2125
+ if (name === (gr.label || '') && look === (gr.tone || '')) return false;
2126
+ setGroups(groupList().map((x) => (x.id !== id ? x : {
2127
+ id, ...(name ? { label: name } : {}), ...(look ? { tone: look } : {}), nodes: x.nodes })));
2128
+ paintFrames();
2129
+ commit('flow:group:label', `group:${id}`);
749
2130
  return true;
750
2131
  }
751
2132
 
2133
+ /** Exactly these members, as ONE `flow:group:members`: each leaves the
2134
+ * group it was in, and none left removes the group. */
2135
+ function setGroupMembers(id, ids) {
2136
+ const gr = grouping && !isReadOnly() ? groupOf(id) : null;
2137
+ if (!gr) return false;
2138
+ const want = new Set(ids);
2139
+ const members = graph.nodes.filter((x) => want.has(x.id)).map((x) => x.id);
2140
+ if (members.length === gr.nodes.length && members.every((m, i) => m === gr.nodes[i])) return false;
2141
+ const taken = new Set(members);
2142
+ setGroups(groupList().map((x) => (x.id === id ? { ...x, nodes: members }
2143
+ : { ...x, nodes: x.nodes.filter((n) => !taken.has(n)) })).filter((x) => x.nodes.length));
2144
+ paintFrames();
2145
+ commit('flow:group:members');
2146
+ paintToolbar();
2147
+ return true;
2148
+ }
2149
+
2150
+ /** The box round a group's members, its title bar included. */
2151
+ function frameBox(gr) {
2152
+ const rows = gr.nodes.map(nodeOf).filter(Boolean);
2153
+ if (!rows.length) return null;
2154
+ const x = Math.min(...rows.map((n) => n.position.x)) - FRAME.pad;
2155
+ const y = Math.min(...rows.map((n) => n.position.y)) - FRAME.pad - FRAME.head;
2156
+ const right = Math.max(...rows.map((n) => n.position.x + widthOf(n))) + FRAME.pad;
2157
+ const bottom = Math.max(...rows.map(bottomOf)) + FRAME.pad;
2158
+ return { x, y, width: right - x, height: bottom - y };
2159
+ }
2160
+
2161
+ function buildFrame(id) {
2162
+ const f = el('div', 'twm-flow-graph__group');
2163
+ f.dataset.groupId = id;
2164
+ const head = el('div', 'twm-flow-graph__group-head');
2165
+ head.appendChild(el('span', 'twm-flow-graph__group-label'));
2166
+ head.addEventListener('pointerdown', (ev) => {
2167
+ if (ev.button !== 0 || (editing?.kind === 'group' && editing.id === id)) return;
2168
+ ev.stopPropagation();
2169
+ const gr = groupOf(id);
2170
+ if (!gr) return;
2171
+ if (multi) pickNodes(gr.nodes);
2172
+ if (!isReadOnly()) dragItems(ev, gr.nodes, []);
2173
+ });
2174
+ head.addEventListener('dblclick', () => editGroupLabel(id));
2175
+ f.appendChild(head);
2176
+ return f;
2177
+ }
2178
+
2179
+ /** The frames, behind the nodes, round their members wherever they are now. */
2180
+ function paintFrames() {
2181
+ if (!grouping || destroyed) return;
2182
+ const live = new Set(groupList().map((gr) => gr.id));
2183
+ // A group gone while its name is being typed: the typing ends, unsaved.
2184
+ if (editing?.kind === 'group' && !live.has(editing.id)) editing.done(false);
2185
+ for (const [id, f] of frameEls) if (!live.has(id)) { f.remove(); frameEls.delete(id); }
2186
+ for (const gr of groupList()) {
2187
+ let f = frameEls.get(gr.id);
2188
+ if (!f) {
2189
+ f = buildFrame(gr.id);
2190
+ frameEls.set(gr.id, f);
2191
+ }
2192
+ const box = frameBox(gr);
2193
+ Object.assign(f.style, { left: `${box.x}px`, top: `${box.y}px`, width: `${box.width}px`,
2194
+ height: `${box.height}px` });
2195
+ if (TONES.includes(gr.tone)) f.dataset.tone = gr.tone; else delete f.dataset.tone;
2196
+ const name = gr.label || say(S, 'groupUntitled');
2197
+ f.setAttribute('aria-label', name);
2198
+ if (editing?.kind !== 'group' || editing.id !== gr.id) {
2199
+ const tag = f.querySelector('.twm-flow-graph__group-label');
2200
+ tag.textContent = name;
2201
+ tag.title = name;
2202
+ tag.classList.toggle('twm-flow-graph__group-label--untitled', !gr.label);
2203
+ }
2204
+ }
2205
+ // Behind every node: first in the node layer, in group order (the
2206
+ // first group lowest), and moved only when out of that order.
2207
+ const order = groupList().map((gr) => frameEls.get(gr.id));
2208
+ const lead = [...canvas.nodeLayer.children].slice(0, order.length);
2209
+ if (order.some((f, i) => lead[i] !== f)) canvas.nodeLayer.prepend(...order);
2210
+ }
2211
+
2212
+ /** Type into a box in place of `shown`: Enter (a note: Ctrl/⌘+Enter) or
2213
+ * leaving keeps it, Escape puts it back. */
2214
+ function typeInPlace(kind, id, shown, input, keep) {
2215
+ editing?.done(true);
2216
+ input.value = kind === 'note' ? noteOf(id)?.text ?? '' : groupOf(id)?.label ?? '';
2217
+ shown.hidden = true;
2218
+ shown.after(input);
2219
+ let over = false;
2220
+ const done = (save) => {
2221
+ if (over) return;
2222
+ over = true;
2223
+ editing = null;
2224
+ const text = input.value;
2225
+ input.remove();
2226
+ shown.hidden = false;
2227
+ if (save) keep(text);
2228
+ if (kind === 'note') paintNotes(); else paintFrames();
2229
+ keepFocus();
2230
+ };
2231
+ input.addEventListener('keydown', (k) => {
2232
+ if (k.key === 'Escape') { k.preventDefault(); k.stopPropagation(); done(false); return; }
2233
+ if (k.key === 'Enter' && (kind !== 'note' || k.ctrlKey || k.metaKey)) {
2234
+ k.preventDefault();
2235
+ k.stopPropagation();
2236
+ done(true);
2237
+ }
2238
+ });
2239
+ input.addEventListener('blur', () => done(true));
2240
+ input.addEventListener('pointerdown', (p) => p.stopPropagation());
2241
+ editing = { kind, id, done };
2242
+ input.focus({ preventScroll: true });
2243
+ input.select?.();
2244
+ }
2245
+
2246
+ function editGroupLabel(id) {
2247
+ const f = frameEls.get(id);
2248
+ if (!f || !groupOf(id) || isReadOnly()) return false;
2249
+ if (editing?.kind === 'group' && editing.id === id) return true;
2250
+ const input = el('input', 'twm-flow-graph__group-input');
2251
+ input.type = 'text';
2252
+ input.setAttribute('aria-label', say(S, 'groupName'));
2253
+ typeInPlace('group', id, f.querySelector('.twm-flow-graph__group-label'), input,
2254
+ (text) => setGroupLook(id, { label: text.trim() }));
2255
+ return true;
2256
+ }
2257
+
2258
+ /** Add a note as ONE `flow:note:add`: at `position`, or at the centre of the view. */
2259
+ function addNote({ text = '', position = null, size = null } = {}) {
2260
+ if (!noting || isReadOnly()) return null;
2261
+ let where = position;
2262
+ if (!where) {
2263
+ const box = canvas.visibleBox();
2264
+ where = { x: canvas.snap(box.width ? box.x + box.width / 2 - NOTE_WIDTH / 2 : 80),
2265
+ y: canvas.snap(box.height ? box.y + box.height / 3 : 80) };
2266
+ }
2267
+ const id = freshId(noteList(), 'note');
2268
+ const next = normalise({ nodes: [], connections: [], notes: [{ id, text, position: where, size }] }).notes[0];
2269
+ graph.notes = [...noteList(), next];
2270
+ selected = { kind: 'note', id };
2271
+ paintNodes();
2272
+ paintPanel();
2273
+ announceSelection();
2274
+ commit('flow:note:add');
2275
+ return structuredClone(next);
2276
+ }
2277
+
2278
+ /** A note's text, as ONE `flow:note:edit`. */
2279
+ function setNoteText(id, text) {
2280
+ const note = noting && !isReadOnly() ? noteOf(id) : null;
2281
+ if (!note || note.text === String(text ?? '')) return false;
2282
+ note.text = String(text ?? '');
2283
+ paintNotes();
2284
+ if (selected?.kind === 'note' && selected.id === id) paintPanel();
2285
+ commit('flow:note:edit', `note:${id}`);
2286
+ return true;
2287
+ }
2288
+
2289
+ /** A note put somewhere, as ONE `flow:note:move`. */
2290
+ function moveNote(id, position) {
2291
+ const note = noting && !isReadOnly() ? noteOf(id) : null;
2292
+ const x = Math.round(Number(position?.x));
2293
+ const y = Math.round(Number(position?.y));
2294
+ if (!note || !Number.isFinite(x) || !Number.isFinite(y)) return false;
2295
+ if (note.position.x === x && note.position.y === y) return false;
2296
+ note.position = { x, y };
2297
+ paintNotes();
2298
+ commit('flow:note:move');
2299
+ return true;
2300
+ }
2301
+
2302
+ /** Take these notes away, as ONE `flow:note:remove` (the editor's own,
2303
+ * so no commit when `commitAs` is null: the caller's edit carries it). */
2304
+ function dropNotes(ids, commitAs = 'flow:note:remove') {
2305
+ const gone = new Set(ids);
2306
+ const kept = noteList().filter((x) => !gone.has(x.id));
2307
+ if (kept.length === noteList().length) return false;
2308
+ if (kept.length) graph.notes = kept; else delete graph.notes;
2309
+ if (commitAs) {
2310
+ prunePick();
2311
+ paintNodes();
2312
+ paintPanel();
2313
+ announceSelection();
2314
+ commit(commitAs);
2315
+ }
2316
+ return true;
2317
+ }
2318
+
2319
+ function editNote(id) {
2320
+ const box = noteEls.get(id);
2321
+ if (!box || !noteOf(id) || isReadOnly()) return false;
2322
+ if (editing?.kind === 'note' && editing.id === id) return true;
2323
+ const input = el('textarea', 'twm-flow-graph__note-input');
2324
+ input.setAttribute('aria-label', say(S, 'noteText'));
2325
+ typeInPlace('note', id, box.querySelector('.twm-flow-graph__note-text'), input,
2326
+ (text) => setNoteText(id, text));
2327
+ return true;
2328
+ }
2329
+
2330
+ function buildNote(id) {
2331
+ const box = el('div', 'twm-flow-graph__note');
2332
+ box.dataset.noteId = id;
2333
+ box.setAttribute('data-twm-flow-item', '');
2334
+ box.setAttribute('role', 'note');
2335
+ box.tabIndex = 0;
2336
+ box.appendChild(el('p', 'twm-flow-graph__note-text'));
2337
+ box.addEventListener('pointerdown', (ev) => {
2338
+ if (ev.button !== 0 || (editing?.kind === 'note' && editing.id === id)) return;
2339
+ ev.stopPropagation();
2340
+ if (toggles(ev)) return; // its click toggles it
2341
+ if (!isNotePicked(id)) select({ kind: 'note', id });
2342
+ if (isReadOnly()) return;
2343
+ dragItems(ev, selected?.kind === 'many' ? selected.ids : [], pickedNotes());
2344
+ });
2345
+ box.addEventListener('click', (ev) => {
2346
+ if (!toggles(ev)) return;
2347
+ const notes = pickedNotes();
2348
+ pickItems(pickedIds(), notes.includes(id) ? notes.filter((x) => x !== id) : [...notes, id]);
2349
+ });
2350
+ box.addEventListener('dblclick', () => editNote(id));
2351
+ return box;
2352
+ }
2353
+
2354
+ /** The notes, in place: a note being typed into keeps its box and its text. */
2355
+ function paintNotes() {
2356
+ if (!noting || destroyed) return;
2357
+ const live = new Set(noteList().map((x) => x.id));
2358
+ // A note gone while it is being typed into: the typing ends, unsaved.
2359
+ if (editing?.kind === 'note' && !live.has(editing.id)) editing.done(false);
2360
+ for (const [id, box] of noteEls) if (!live.has(id)) { box.remove(); noteEls.delete(id); }
2361
+ const picks = new Set(pickedNotes());
2362
+ for (const note of noteList()) {
2363
+ let box = noteEls.get(note.id);
2364
+ if (!box) {
2365
+ box = buildNote(note.id);
2366
+ noteEls.set(note.id, box);
2367
+ canvas.nodeLayer.appendChild(box);
2368
+ }
2369
+ box.style.left = `${note.position.x}px`;
2370
+ box.style.top = `${note.position.y}px`;
2371
+ box.style.width = `${note.size?.w || NOTE_WIDTH}px`;
2372
+ if (note.size?.h) box.style.height = `${note.size.h}px`; else box.style.removeProperty('height');
2373
+ const picked = picks.has(note.id);
2374
+ box.classList.toggle('twm-flow-graph__note--picked', picked);
2375
+ if (picked) box.setAttribute('aria-current', 'true'); else box.removeAttribute('aria-current');
2376
+ const shown = box.querySelector('.twm-flow-graph__note-text');
2377
+ shown.textContent = note.text || say(S, 'noteEmpty');
2378
+ shown.classList.toggle('twm-flow-graph__note-text--empty', !note.text);
2379
+ box.setAttribute('aria-label', note.text || say(S, 'noteEmpty'));
2380
+ }
2381
+ }
2382
+
2383
+ /** The notes a marquee box (canvas units) touches, in graph order. */
2384
+ function notesIn(box) {
2385
+ return noteList().filter((x) => {
2386
+ const el2 = noteEls.get(x.id);
2387
+ const w = x.size?.w || NOTE_WIDTH;
2388
+ const h = x.size?.h || el2?.offsetHeight || FRAME.head;
2389
+ return x.position.x < box.x + box.width && x.position.x + w > box.x
2390
+ && x.position.y < box.y + box.height && x.position.y + h > box.y;
2391
+ }).map((x) => x.id);
2392
+ }
2393
+
752
2394
  // ── the panel ───────────────────────────────────────────────────────
753
2395
 
754
2396
  const panel = createSettingsPanel({
@@ -768,20 +2410,7 @@ export function createCanvasEditor(host, options = {}) {
768
2410
  },
769
2411
  onRename: (stepId, text) => {
770
2412
  if (isReadOnly()) return;
771
- const nodeRow = nodeOf(stepId);
772
- if (!nodeRow) return;
773
- if (text) nodeRow.label = text; else delete nodeRow.label;
774
- // In place: the node's title and its shape key, so the next paint
775
- // does not rebuild a node a press may be on.
776
- const n = nodeEls.get(nodeRow.id);
777
- const name = text || typeOf(nodeRow)?.label || nodeRow.type;
778
- const title = n?.querySelector('.twm-flow-graph__node-title');
779
- if (title) title.textContent = title.title = name;
780
- if (n) {
781
- n.dataset.shape = `${nodeRow.type}|${nodeRow.label || ''}`;
782
- n.setAttribute('aria-label', name);
783
- }
784
- commit('flow:node:label', `label:${stepId}`);
2413
+ renameNode(stepId, text);
785
2414
  },
786
2415
  onFocusLost: () => keepFocus(),
787
2416
  values: typeof values === 'function' ? (q) => values({
@@ -809,8 +2438,9 @@ export function createCanvasEditor(host, options = {}) {
809
2438
  select(target);
810
2439
  if (target.kind === 'node') {
811
2440
  const n = nodeOf(target.id);
812
- canvas.bringIntoView({ x: n.position.x, y: n.position.y, width: BOX.width,
2441
+ canvas.bringIntoView({ x: n.position.x, y: n.position.y, width: widthOf(n),
813
2442
  height: bottomOf(n) - n.position.y });
2443
+ viewportSettled();
814
2444
  }
815
2445
  if (!(f.field && panel.focusField(f.field))) focusFirstField();
816
2446
  },
@@ -826,24 +2456,53 @@ export function createCanvasEditor(host, options = {}) {
826
2456
 
827
2457
  function select(next) {
828
2458
  selected = next;
2459
+ const picks = pickedSet();
829
2460
  for (const [id, n] of nodeEls) {
830
- const picked = next?.kind === 'node' && next.id === id;
2461
+ const picked = picks.has(id);
831
2462
  n.classList.toggle('twm-flow-graph__node--picked', picked);
832
2463
  if (picked) n.setAttribute('aria-current', 'true'); else n.removeAttribute('aria-current');
833
2464
  }
2465
+ paintNotes();
834
2466
  paintEdges();
835
2467
  paintPanel();
836
2468
  announceSelection();
2469
+ if (multi || grouping) paintToolbar();
2470
+ }
2471
+
2472
+ /** Pick exactly these nodes and notes (`multiSelect`): none, one, or several. */
2473
+ function pickItems(ids, noteIds = []) {
2474
+ const next = pickOf(ids, noteIds);
2475
+ // A marquee asks on every pointer move: only a change repaints.
2476
+ if (JSON.stringify(next) !== JSON.stringify(selected)) select(next);
2477
+ }
2478
+
2479
+ const pickNodes = (ids) => pickItems(ids);
2480
+
2481
+ function togglePick(id) {
2482
+ const ids = pickedIds();
2483
+ pickItems(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id], pickedNotes());
2484
+ }
2485
+
2486
+ /** The nodes a marquee box (canvas units) touches, in graph order. */
2487
+ function nodesIn(box) {
2488
+ return graph.nodes.filter((x) => x.position.x < box.x + box.width && x.position.x + widthOf(x) > box.x
2489
+ && x.position.y < box.y + box.height && bottomOf(x) > box.y)
2490
+ .map((x) => x.id);
837
2491
  }
838
2492
 
839
2493
  function announceSelection() {
840
2494
  const now = selected?.kind === 'node' ? `step:${selected.id}`
841
- : selected?.kind === 'edge' ? `connection:${JSON.stringify(selected.edge)}`
842
- : selected?.kind === 'flow' ? 'flow' : '';
2495
+ : selected?.kind === 'many' ? `many:${JSON.stringify([selected.ids, selected.notes || []])}`
2496
+ : selected?.kind === 'note' ? `note:${selected.id}`
2497
+ : selected?.kind === 'edge' ? `connection:${JSON.stringify(selected.edge)}`
2498
+ : selected?.kind === 'flow' ? 'flow' : '';
843
2499
  if (now === lastSelect) return;
844
2500
  lastSelect = now;
845
2501
  if (!onSelect || destroyed) return;
846
2502
  if (selected?.kind === 'node') onSelect({ kind: 'step', id: selected.id });
2503
+ else if (selected?.kind === 'many') {
2504
+ onSelect({ kind: 'many', ids: [...selected.ids], ...(selected.notes ? { notes: [...selected.notes] } : {}) });
2505
+ } else if (selected?.kind === 'note') onSelect({ kind: 'note', id: selected.id });
847
2506
  else if (selected?.kind === 'edge') onSelect({ kind: 'connection', id: null, connection: { ...selected.edge } });
848
2507
  else if (selected?.kind === 'flow') onSelect({ kind: 'flow', id: null });
849
2508
  else onSelect({ kind: null, id: null });
@@ -851,11 +2510,23 @@ export function createCanvasEditor(host, options = {}) {
851
2510
 
852
2511
  function removeSelected() {
853
2512
  if (!selected || isReadOnly()) return false;
854
- if (selected.kind === 'node') {
2513
+ if (selected.kind === 'many') {
2514
+ const doomed = removableOf(selected.ids);
2515
+ const notes = selected.notes || [];
2516
+ if (!doomed.length && !notes.length) return false;
2517
+ for (const id of doomed) removeNode(graph, id);
2518
+ dropNotes(notes, null);
2519
+ findingsBy = groupFindings(allFindings(), { graph });
2520
+ select(null);
2521
+ paintNodes();
2522
+ commit(doomed.length ? 'flow:nodes:remove' : 'flow:note:remove');
2523
+ } else if (selected.kind === 'note') {
2524
+ if (!dropNotes([selected.id])) return false;
2525
+ } else if (selected.kind === 'node') {
855
2526
  const nodeRow = nodeOf(selected.id);
856
2527
  if (!nodeRow || !canRemove(nodeRow, typeOf(nodeRow))) return false;
857
2528
  removeNode(graph, selected.id);
858
- findingsBy = groupFindings(findings, { graph });
2529
+ findingsBy = groupFindings(allFindings(), { graph });
859
2530
  select(null);
860
2531
  paintNodes();
861
2532
  commit('flow:node:remove');
@@ -869,18 +2540,48 @@ export function createCanvasEditor(host, options = {}) {
869
2540
  return true;
870
2541
  }
871
2542
 
872
- function paintConnection() {
873
- const e = selected.edge;
874
- const remove = button({ label: say(S, 'removeConnection'), icon: 'link_off', danger: true,
2543
+ /** The picked ids that may be removed. */
2544
+ const removableOf = (ids) => ids.filter((id) => {
2545
+ const nodeRow = nodeOf(id);
2546
+ return nodeRow && canRemove(nodeRow, typeOf(nodeRow));
2547
+ });
2548
+
2549
+ /** The box in place of the settings for a pick that has none: a line, or
2550
+ * several steps — what it is, and its Remove. */
2551
+ function paintPickBox({ title, line, removeLabel, removeIcon, action, refused = false }) {
2552
+ const remove = button({ label: removeLabel, icon: removeIcon, danger: true,
875
2553
  className: 'twm-flow-graph__connection-remove', onClick: () => removeSelected() });
876
- remove.dataset.action = 'remove-connection';
877
- remove.disabled = isReadOnly();
878
- connectionBox.replaceChildren(el('h3', 'twm-flow-graph__connection-title', say(S, 'connection')),
879
- el('p', 'twm-flow-graph__connection-line', say(S, 'connectionLine', e)),
880
- remove);
2554
+ remove.dataset.action = action;
2555
+ remove.disabled = isReadOnly() || refused;
2556
+ connectionBox.replaceChildren(el('h3', 'twm-flow-graph__connection-title', title),
2557
+ el('p', 'twm-flow-graph__connection-line', line), remove);
881
2558
  connectionBox.hidden = false;
882
2559
  }
883
2560
 
2561
+ function paintConnection() {
2562
+ paintPickBox({ title: say(S, 'connection'), line: say(S, 'connectionLine', selected.edge),
2563
+ removeLabel: say(S, 'removeConnection'), removeIcon: 'link_off', action: 'remove-connection' });
2564
+ }
2565
+
2566
+ function paintMany() {
2567
+ const n = removableOf(selected.ids).length;
2568
+ const notes = selected.notes?.length || 0;
2569
+ if (!notes) {
2570
+ paintPickBox({ title: say(S, 'stepsPicked', selected.ids.length), line: say(S, 'stepsPickedLine'),
2571
+ removeLabel: say(S, 'removeSteps', n), removeIcon: 'delete', action: 'remove-steps',
2572
+ refused: n === 0 });
2573
+ return;
2574
+ }
2575
+ paintPickBox({ title: say(S, 'itemsPicked', selected.ids.length, notes), line: say(S, 'stepsPickedLine'),
2576
+ removeLabel: say(S, 'removeItems', n + notes), removeIcon: 'delete', action: 'remove-steps' });
2577
+ }
2578
+
2579
+ function paintNote() {
2580
+ const text = noteOf(selected.id)?.text || '';
2581
+ paintPickBox({ title: say(S, 'noteKind'), line: text || say(S, 'noteEmpty'), removeLabel: say(S, 'removeNote'),
2582
+ removeIcon: 'delete', action: 'remove-note' });
2583
+ }
2584
+
884
2585
  /**
885
2586
  * The panel for whatever is picked: a step's settings, a line's, or — with
886
2587
  * nothing (or the flow) picked — the flow's own. The kit's panel puts focus
@@ -889,10 +2590,10 @@ export function createCanvasEditor(host, options = {}) {
889
2590
  */
890
2591
  function paintPanel() {
891
2592
  if (destroyed) return;
892
- if (selected?.kind === 'edge') {
2593
+ if (selected?.kind === 'edge' || selected?.kind === 'many' || selected?.kind === 'note') {
893
2594
  panel.clear();
894
2595
  panel.el.hidden = true;
895
- paintConnection();
2596
+ if (selected.kind === 'edge') paintConnection(); else if (selected.kind === 'note') paintNote(); else paintMany();
896
2597
  return;
897
2598
  }
898
2599
  connectionBox.hidden = true;
@@ -924,9 +2625,340 @@ export function createCanvasEditor(host, options = {}) {
924
2625
  slots: { after },
925
2626
  });
926
2627
  }
927
- panel.setFindings(findings);
2628
+ panel.setFindings(allFindings());
2629
+ }
2630
+
2631
+ // ── many picked (`multiSelect`) ─────────────────────────────────────
2632
+
2633
+ const heightOf = (n) => bottomOf(n) - n.position.y;
2634
+ const pickedRows = () => pickedIds().map(nodeOf).filter(Boolean);
2635
+
2636
+ /** Line the picked nodes up on one edge (or centre) of the box around
2637
+ * them: ONE `flow:nodes:align` entry. */
2638
+ function alignNodes(kind) {
2639
+ if (!ALIGN.includes(kind)) throw new Error(`align("${kind}"): the kinds are ${ALIGN.join(', ')}.`);
2640
+ if (refusals()[`align-${kind}`]) return false;
2641
+ const rows = pickedRows();
2642
+ const left = Math.min(...rows.map((n) => n.position.x));
2643
+ const right = Math.max(...rows.map((n) => n.position.x + widthOf(n)));
2644
+ const top = Math.min(...rows.map((n) => n.position.y));
2645
+ const bottom = Math.max(...rows.map(bottomOf));
2646
+ for (const n of rows) {
2647
+ const { x, y } = n.position;
2648
+ const w = widthOf(n);
2649
+ const h = heightOf(n);
2650
+ n.position = {
2651
+ x: kind === 'left' ? left : kind === 'right' ? right - w
2652
+ : kind === 'center' ? Math.round((left + right - w) / 2) : x,
2653
+ y: kind === 'top' ? top : kind === 'bottom' ? bottom - h
2654
+ : kind === 'middle' ? Math.round((top + bottom - h) / 2) : y,
2655
+ };
2656
+ }
2657
+ paintNodes();
2658
+ commit('flow:nodes:align');
2659
+ return true;
2660
+ }
2661
+
2662
+ /** Spread the picked nodes so the gaps between them are equal, the first
2663
+ * and the last staying where they are: ONE `flow:nodes:align` entry. */
2664
+ function distributeNodes(axis) {
2665
+ if (!DISTRIBUTE.includes(axis)) throw new Error(`distribute("${axis}"): the axes are ${DISTRIBUTE.join(', ')}.`);
2666
+ if (refusals()[`distribute-${axis}`]) return false;
2667
+ const across = axis === 'horizontal';
2668
+ const at = (n) => (across ? n.position.x : n.position.y);
2669
+ const size = (n) => (across ? widthOf(n) : heightOf(n));
2670
+ const rows = pickedRows().sort((a, b) => at(a) - at(b));
2671
+ const first = at(rows[0]);
2672
+ const end = Math.max(...rows.map((n) => at(n) + size(n)));
2673
+ const gap = (end - first - rows.reduce((sum, n) => sum + size(n), 0)) / (rows.length - 1);
2674
+ let cursor = first;
2675
+ for (const n of rows) {
2676
+ const p = Math.round(cursor);
2677
+ n.position = across ? { x: p, y: n.position.y } : { x: n.position.x, y: p };
2678
+ cursor += size(n) + gap;
2679
+ }
2680
+ paintNodes();
2681
+ commit('flow:nodes:align');
2682
+ return true;
2683
+ }
2684
+
2685
+ /** An arrow key on the canvas: the picked nodes one grid step along (ten
2686
+ * with Shift). Held down, it is one entry (a keyed commit merges). */
2687
+ function nudge(dx, dy, ev) {
2688
+ if (!multi || !canvas.root.contains(ev.target)) return false;
2689
+ const rows = pickedRows();
2690
+ const notes = pickedNotes().map(noteOf).filter(Boolean);
2691
+ if (!(rows.length + notes.length) || isReadOnly()) return false;
2692
+ const step = (canvas.gridSize || 1) * (ev.shiftKey ? NUDGE_FAR : 1);
2693
+ for (const n of [...rows, ...notes]) {
2694
+ n.position = { x: n.position.x + dx * step, y: n.position.y + dy * step };
2695
+ const box = (rows.includes(n) ? nodeEls : noteEls).get(n.id);
2696
+ if (box) { box.style.left = `${n.position.x}px`; box.style.top = `${n.position.y}px`; }
2697
+ }
2698
+ paintEdges();
2699
+ const action = !rows.length ? 'flow:note:move' : rows.length + notes.length > 1 ? 'flow:nodes:move' : 'flow:node:move';
2700
+ commit(action, `nudge:${[...rows, ...notes].map((n) => n.id).join(',')}`);
2701
+ return undefined;
2702
+ }
2703
+
2704
+ /** Tab and Shift+Tab from a node (or from the canvas itself) go to the
2705
+ * next node and the one before, in graph order. Past either end it is
2706
+ * the browser's Tab again, so the keyboard can leave the canvas. */
2707
+ function onTab(ev) {
2708
+ if (ev.key !== 'Tab' || ev.ctrlKey || ev.metaKey || ev.altKey || ev.defaultPrevented) return;
2709
+ const from = ev.target.closest?.('[data-node-id]');
2710
+ if (!from && (ev.target !== canvas.root || ev.shiftKey)) return;
2711
+ const ids = graph.nodes.map((x) => x.id);
2712
+ const i = from ? ids.indexOf(from.dataset.nodeId) : -1;
2713
+ const next = nodeEls.get(ids[ev.shiftKey ? i - 1 : i + 1]);
2714
+ if (!next) return;
2715
+ ev.preventDefault();
2716
+ next.focus({ preventScroll: true });
2717
+ const n = nodeOf(next.dataset.nodeId);
2718
+ canvas.bringIntoView({ x: n.position.x, y: n.position.y, width: widthOf(n), height: heightOf(n) });
2719
+ }
2720
+
2721
+ /** A press lasts until it is let go: whatever it was, the keyboard's
2722
+ * focus picks again after it. */
2723
+ function pressLetGo(ev) {
2724
+ released = ev.type === 'pointerup' ? pressed : null;
2725
+ pressed = null;
2726
+ }
2727
+
2728
+ if (multi) {
2729
+ canvas.onMarquee = (box) => pickItems(nodesIn(box), noting ? notesIn(box) : []);
2730
+ canvas.root.addEventListener('keydown', onTab);
2731
+ window.addEventListener('pointerup', pressLetGo, true);
2732
+ window.addEventListener('pointercancel', pressLetGo, true);
2733
+ }
2734
+
2735
+ // ── copy, cut, paste, duplicate (`clipboard`) ───────────────────────
2736
+
2737
+ /** What a method answers without the `clipboard` option: nothing done, nothing said. */
2738
+ const CLIPBOARD_OFF = Object.freeze({ ok: false, reason: null });
2739
+
2740
+ /** A refusal, said on the message line and handed back. */
2741
+ function refused(reason) {
2742
+ setMessage(reason, 'warning');
2743
+ return { ok: false, reason };
2744
+ }
2745
+
2746
+ const roRefusal = () => refused(readOnlyReason() || say(S, 'readOnlyRefusal'));
2747
+ const titleOf = (n) => n.label || typeOf(n)?.label || n.type;
2748
+
2749
+ /** May a step of this type be added again: a type the palette offers, and available here. */
2750
+ const addable = (type) => Boolean(type) && !type.unavailable && offered(type);
2751
+
2752
+ /** The picked steps a copy is made of, in graph order. */
2753
+ const copyRows = () => {
2754
+ const picks = pickedSet();
2755
+ return graph.nodes.filter((n) => picks.has(n.id) && addable(typeOf(n)));
2756
+ };
2757
+
2758
+ /** The picked notes a copy takes along (`notes`), in graph order. */
2759
+ const copyNoteRows = () => {
2760
+ if (!noting) return [];
2761
+ const picks = new Set(pickedNotes());
2762
+ return noteList().filter((x) => picks.has(x.id));
2763
+ };
2764
+
2765
+ /** Is anything picked a copy could be made of: a step, or (`notes`) a note. */
2766
+ const pickedAny = () => pickedIds().length > 0 || copyNoteRows().length > 0;
2767
+
2768
+ /** The steps, the lines among them, (`groups`) the groups whose every
2769
+ * member is copied and (`notes`) the picked notes, as the graph's own
2770
+ * text; a line to a step left out is not copied, nor a group with a
2771
+ * member left out. */
2772
+ function copyText(rows, notes = []) {
2773
+ const ids = new Set(rows.map((n) => n.id));
2774
+ const groups = grouping ? groupList().filter((gr) => gr.nodes.every((id) => ids.has(id))) : [];
2775
+ return serialise({ nodes: rows,
2776
+ connections: graph.connections.filter((c) => ids.has(c.source) && ids.has(c.target)),
2777
+ ...(groups.length ? { groups } : {}), ...(notes.length ? { notes } : {}) });
2778
+ }
2779
+
2780
+ /** What a copy or a cut says it took: steps, or steps and notes. */
2781
+ const tookSay = (key, rows, notes) => (notes.length ? say(S, `${key}Items`, rows.length, notes.length)
2782
+ : say(S, key, rows.length));
2783
+ /** What a copy or a cut answers: the steps' ids, and the notes' when there are some. */
2784
+ const tookIds = (rows, notes) => ({ ok: true, ids: rows.map((n) => n.id),
2785
+ ...(notes.length ? { notes: notes.map((x) => x.id) } : {}) });
2786
+
2787
+ /** Onto the clipboard: the editor's own, or the consumer's `write`. */
2788
+ function writeClipboard(text, times) {
2789
+ pasteRun = { text, times };
2790
+ if (clip === 'memory') { copiedText = text; return true; }
2791
+ const failed = () => setMessage(say(S, 'clipboardFailed'), 'warning');
2792
+ try {
2793
+ const done = clip.write(text);
2794
+ if (done && typeof done.then === 'function') done.then(null, failed);
2795
+ } catch {
2796
+ failed();
2797
+ return false;
2798
+ }
2799
+ return true;
2800
+ }
2801
+
2802
+ /** COPY: the picked steps and the lines among them (and the picked notes,
2803
+ * and the groups wholly inside the pick) onto the clipboard. Read only, too. */
2804
+ function copyNodes() {
2805
+ if (!clip) return CLIPBOARD_OFF;
2806
+ if (!pickedAny()) return refused(say(S, 'nothingPicked'));
2807
+ const rows = copyRows();
2808
+ const notes = copyNoteRows();
2809
+ if (!rows.length && !notes.length) return refused(say(S, 'cannotCopy'));
2810
+ if (!writeClipboard(copyText(rows, notes), 0)) return { ok: false, reason: say(S, 'clipboardFailed') };
2811
+ setMessage(tookSay('copied', rows, notes), 'ok');
2812
+ return tookIds(rows, notes);
2813
+ }
2814
+
2815
+ /** CUT: a copy, then the copied steps removed with their lines — one remove entry. */
2816
+ function cutNodes() {
2817
+ if (!clip) return CLIPBOARD_OFF;
2818
+ if (isReadOnly()) return roRefusal();
2819
+ if (!pickedAny()) return refused(say(S, 'nothingPicked'));
2820
+ const rows = copyRows().filter((n) => canRemove(n, typeOf(n)));
2821
+ const notes = copyNoteRows();
2822
+ if (!rows.length && !notes.length) return refused(say(S, 'cannotCut'));
2823
+ // The first paste after a cut puts the steps back where they were.
2824
+ if (!writeClipboard(copyText(rows, notes), -1)) return { ok: false, reason: say(S, 'clipboardFailed') };
2825
+ for (const n of rows) removeNode(graph, n.id);
2826
+ dropNotes(notes.map((x) => x.id), null);
2827
+ findingsBy = groupFindings(allFindings(), { graph });
2828
+ select(null);
2829
+ paintNodes();
2830
+ commit(!rows.length ? 'flow:note:remove'
2831
+ : rows.length + notes.length > 1 ? 'flow:nodes:remove' : 'flow:node:remove');
2832
+ setMessage(tookSay('cutDone', rows, notes), 'ok');
2833
+ keepFocus();
2834
+ return tookIds(rows, notes);
2835
+ }
2836
+
2837
+ /** What is on the clipboard, read as steps: the graph text, or a refusal. */
2838
+ function stepsOf(text) {
2839
+ if (typeof text !== 'string' || !text.trim()) return { reason: say(S, 'nothingToPaste') };
2840
+ let raw;
2841
+ try { raw = JSON.parse(text); } catch { return { reason: say(S, 'pasteNotSteps') }; }
2842
+ // Notes alone paste too, where the editor draws notes.
2843
+ const notesToo = noting && Array.isArray(raw?.notes) && raw.notes.length > 0;
2844
+ if (!raw || !Array.isArray(raw.nodes) || (!raw.nodes.length && !notesToo)
2845
+ || raw.nodes.some((n) => !n || typeof n !== 'object' || !n.id || !n.type)) {
2846
+ return { reason: say(S, 'pasteNotSteps') };
2847
+ }
2848
+ const g = normalise(raw);
2849
+ // Nothing usable once read: no steps and no notes left to paste.
2850
+ if (!g.nodes.length && !g.notes?.length) return { reason: say(S, 'pasteNotSteps') };
2851
+ const odd = g.nodes.find((n) => !addable(typeOf(n)));
2852
+ if (odd) return { reason: typeOf(odd) ? say(S, 'notAddable', titleOf(odd)) : say(S, 'unknownType', odd.type) };
2853
+ return { g };
928
2854
  }
929
2855
 
2856
+ /**
2857
+ * The copies into the graph, `offset` grid steps off: fresh ids, a label
2858
+ * derived when the flow already has it, `prepareCopy`, the lines among
2859
+ * them, (`notes`) the notes under fresh ids, (`groups`) each group made
2860
+ * again round the copies of its members, then picked — ONE `action`
2861
+ * entry. Nothing is written until every copy is made, so a `prepareCopy`
2862
+ * that throws changes nothing.
2863
+ */
2864
+ function addCopies(g, offset, via, action) {
2865
+ const step = (canvas.gridSize || CANVAS_NODE.row) * offset;
2866
+ const titles = new Set(graph.nodes.map(titleOf));
2867
+ const made = [];
2868
+ const idOf = new Map();
2869
+ for (const from of g.nodes) {
2870
+ const id = nextId({ nodes: [...graph.nodes, ...made] }, from.type, { catalogue });
2871
+ const title = titleOf(from);
2872
+ let label = from.label || null;
2873
+ if (titles.has(title)) {
2874
+ let i = 1;
2875
+ label = say(S, 'copyLabel', title, i);
2876
+ while (titles.has(label)) { i += 1; label = say(S, 'copyLabel', title, i); }
2877
+ titles.add(label);
2878
+ }
2879
+ const copy = { id, type: from.type, ...(label ? { label } : {}), config: structuredClone(from.config),
2880
+ position: { x: from.position.x + step, y: from.position.y + step } };
2881
+ if (typeof prepareCopy === 'function') prepareCopy(copy, { from: from.id, via });
2882
+ const [clean] = normalise({ nodes: [{ ...copy, id, type: from.type, position: { ...copy.position } }],
2883
+ connections: [] }).nodes;
2884
+ made.push(clean);
2885
+ idOf.set(from.id, id);
2886
+ }
2887
+ graph.nodes.push(...made);
2888
+ for (const c of g.connections) {
2889
+ const source = idOf.get(c.source);
2890
+ const target = idOf.get(c.target);
2891
+ if (source && target) connect(graph, catalogue, { ...c, source, target }, { strings: S, dataPorts: dataConnect });
2892
+ }
2893
+ const noteIds = [];
2894
+ for (const from of noting ? g.notes || [] : []) {
2895
+ const id = freshId(noteList(), 'note');
2896
+ graph.notes = [...noteList(), { ...structuredClone(from), id,
2897
+ position: { x: from.position.x + step, y: from.position.y + step } }];
2898
+ noteIds.push(id);
2899
+ }
2900
+ const groupIds = [];
2901
+ for (const gr of grouping ? g.groups || [] : []) {
2902
+ const members = gr.nodes.map((id) => idOf.get(id)).filter(Boolean);
2903
+ if (!members.length || members.length !== gr.nodes.length) continue;
2904
+ const id = freshId(groupList(), 'group');
2905
+ setGroups([...groupList(), { id, ...(gr.label ? { label: gr.label } : {}),
2906
+ ...(gr.tone ? { tone: gr.tone } : {}), nodes: members }]);
2907
+ groupIds.push(id);
2908
+ }
2909
+ const ids = made.map((n) => n.id);
2910
+ if (connecting) cancelConnect();
2911
+ paintNodes();
2912
+ if (multi) pickItems(ids, noteIds);
2913
+ else select(ids.length ? { kind: 'node', id: ids[0] } : { kind: 'note', id: noteIds[0] });
2914
+ commit(action);
2915
+ setMessage('');
2916
+ return { ok: true, ids, ...(noteIds.length ? { notes: noteIds } : {}),
2917
+ ...(groupIds.length ? { groups: groupIds } : {}) };
2918
+ }
2919
+
2920
+ /** PASTE `text` — or, without it, what the clipboard holds (a promise of
2921
+ * the answer when the consumer's `read` is one). */
2922
+ function pasteNodes(text) {
2923
+ if (!clip) return CLIPBOARD_OFF;
2924
+ if (isReadOnly()) return roRefusal();
2925
+ const settle = (t) => {
2926
+ if (destroyed) return CLIPBOARD_OFF;
2927
+ if (isReadOnly()) return roRefusal();
2928
+ const { g, reason } = stepsOf(t);
2929
+ if (!g) return refused(reason);
2930
+ const times = pasteRun.text === t ? pasteRun.times + 1 : 1;
2931
+ pasteRun = { text: t, times };
2932
+ return addCopies(g, COPY_OFFSET * times, 'paste', 'flow:paste');
2933
+ };
2934
+ if (text !== undefined) return settle(text);
2935
+ if (clip === 'memory') return settle(copiedText);
2936
+ let got;
2937
+ try { got = clip.read(); } catch { return refused(say(S, 'clipboardFailed')); }
2938
+ if (got && typeof got.then === 'function') {
2939
+ return got.then(settle, () => (destroyed ? CLIPBOARD_OFF : refused(say(S, 'clipboardFailed'))));
2940
+ }
2941
+ return settle(got);
2942
+ }
2943
+
2944
+ /** DUPLICATE: a copy of the picked steps beside them, the clipboard untouched. */
2945
+ function duplicateNodes() {
2946
+ if (!clip) return CLIPBOARD_OFF;
2947
+ if (isReadOnly()) return roRefusal();
2948
+ if (!pickedAny()) return refused(say(S, 'nothingPicked'));
2949
+ const rows = copyRows();
2950
+ const notes = copyNoteRows();
2951
+ if (!rows.length && !notes.length) return refused(say(S, 'cannotCopy'));
2952
+ return addCopies(JSON.parse(copyText(rows, notes)), COPY_OFFSET, 'duplicate', 'flow:node:duplicate');
2953
+ }
2954
+
2955
+ /** A clipboard key: with nothing picked, Ctrl+C, X and D are not the editor's. */
2956
+ const clipKey = (run, needsPick = true) => () => {
2957
+ if (!clip || (needsPick && !pickedAny())) return false;
2958
+ run();
2959
+ return undefined;
2960
+ };
2961
+
930
2962
  // ── keys ────────────────────────────────────────────────────────────
931
2963
 
932
2964
  const unbindKeys = bindFlowKeys(root, {
@@ -943,6 +2975,8 @@ export function createCanvasEditor(host, options = {}) {
943
2975
  },
944
2976
  open: (ev) => {
945
2977
  if (ev.target.closest?.('.twm-flow-graph__port')) return false; // a port's Enter is its own
2978
+ const noteEl = noting ? ev.target.closest?.('[data-note-id]') : null;
2979
+ if (noteEl && canvas.nodeLayer.contains(noteEl)) return editNote(noteEl.dataset.noteId) ? undefined : false;
946
2980
  const nodeEl = ev.target.closest?.('[data-node-id]');
947
2981
  if (nodeEl && canvas.nodeLayer.contains(nodeEl)) {
948
2982
  if (!(selected?.kind === 'node' && selected.id === nodeEl.dataset.nodeId)) {
@@ -953,18 +2987,42 @@ export function createCanvasEditor(host, options = {}) {
953
2987
  return false;
954
2988
  },
955
2989
  escape: () => {
956
- if (!connecting) return false;
2990
+ if (!connecting) {
2991
+ if (!pickedNotes().length && !(multi && pickedIds().length)) return false;
2992
+ select(null);
2993
+ return undefined;
2994
+ }
957
2995
  cancelConnect();
958
2996
  setMessage('');
959
2997
  return undefined;
960
2998
  },
2999
+ copy: clipKey(copyNodes),
3000
+ cut: clipKey(cutNodes),
3001
+ paste: clipKey(() => pasteNodes(), false),
3002
+ duplicate: clipKey(duplicateNodes),
3003
+ selectAll: () => {
3004
+ if (!multi) return false;
3005
+ pickItems(graph.nodes.map((x) => x.id), noting ? noteList().map((x) => x.id) : []);
3006
+ return undefined;
3007
+ },
3008
+ group: () => (grouping && groupPicked() ? undefined : false),
3009
+ ungroup: () => (grouping && ungroupPicked() ? undefined : false),
3010
+ up: (ev) => nudge(0, -1, ev),
3011
+ down: (ev) => nudge(0, 1, ev),
3012
+ left: (ev) => nudge(-1, 0, ev),
3013
+ right: (ev) => nudge(1, 0, ev),
961
3014
  });
962
3015
 
963
3016
  // ── the page ────────────────────────────────────────────────────────
964
3017
 
965
- root.append(toolbar, why, readonlyBanner, message, strip.el, banner, body);
3018
+ root.append(toolbar, why, readonlyBanner, message, ...(offerBox ? [offerBox] : []), strip.el, banner, body);
966
3019
  if (host) host.appendChild(root);
967
3020
 
3021
+ function cancelFit() {
3022
+ if (fitFrame !== null) globalThis.cancelAnimationFrame?.(fitFrame);
3023
+ fitFrame = null;
3024
+ }
3025
+
968
3026
  function paintReadOnly() {
969
3027
  root.classList.toggle('twm-flow-graph--readonly', isReadOnly());
970
3028
  readonlyBanner.textContent = readOnlyReason() || (isReadOnly() ? say(S, 'readOnlyRefusal') : '');
@@ -982,18 +3040,20 @@ export function createCanvasEditor(host, options = {}) {
982
3040
  * was loaded is where undo starts. With `baseline: false` the stacks are
983
3041
  * kept and the loaded content is the present they undo from.
984
3042
  */
985
- load({ graph: g = null, flowSettings: fs } = {}, { baseline = true, fit: andFit = true } = {}) {
3043
+ load({ graph: g = null, flowSettings: fs } = {}, { baseline = true, fit: andFit = true, viewport } = {}) {
986
3044
  if (destroyed) return;
3045
+ // Typing open in a note or a group name belongs to the graph being replaced.
3046
+ editing?.done(false);
987
3047
  if (connecting) cancelConnect();
3048
+ if (pendingOffer) clearOffer();
988
3049
  graph = normalise(g);
989
3050
  if (fs !== undefined) {
990
3051
  settingsSchema = fs?.schema ?? settingsSchema;
991
3052
  settingsTitle = fs?.title ?? settingsTitle;
992
3053
  settingsValue = structuredClone(plainObject(fs?.value));
993
3054
  }
994
- if (selected?.kind === 'node' && !nodeOf(selected.id)) selected = null;
995
- if (selected?.kind === 'edge' && !graph.connections.some((c) => sameEdge(c, selected.edge))) selected = null;
996
- findingsBy = groupFindings(findings, { graph });
3055
+ prunePick();
3056
+ findingsBy = groupFindings(allFindings(), { graph });
997
3057
  paintNodes();
998
3058
  paintPanel();
999
3059
  const state = snapshot();
@@ -1001,12 +3061,49 @@ export function createCanvasEditor(host, options = {}) {
1001
3061
  else if (state !== history.settled) history.adopt({ ...history.keep(), settled: state }, state);
1002
3062
  paintToolbar();
1003
3063
  announceSelection();
1004
- if (andFit) {
1005
- if (fitFrame !== null) globalThis.cancelAnimationFrame?.(fitFrame);
3064
+ // A viewport to open at is where the consumer left the reader: no fit.
3065
+ const at = viewportOf(viewport) || startView;
3066
+ startView = null;
3067
+ if (at) {
3068
+ cancelFit();
3069
+ canvas.setViewport(at);
3070
+ viewportBaseline();
3071
+ } else if (andFit) {
3072
+ cancelFit();
3073
+ const fitNow = () => { fit(); viewportBaseline(); };
1006
3074
  fitFrame = globalThis.requestAnimationFrame
1007
- ? globalThis.requestAnimationFrame(() => { fitFrame = null; fit(); }) : (fit(), null);
3075
+ ? globalThis.requestAnimationFrame(() => { fitFrame = null; fitNow(); }) : (fitNow(), null);
1008
3076
  }
1009
3077
  },
3078
+ /**
3079
+ * Swap the step types while the editor is open — a type registered
3080
+ * after `load`. The palette is redrawn; a node whose type's definition
3081
+ * changed — what it is built from, not the object — is rebuilt (so a node
3082
+ * of a type that was unknown now draws); the rest keep their elements;
3083
+ * the graph, and so the history, are untouched.
3084
+ */
3085
+ setCatalogue(next) {
3086
+ if (destroyed) return;
3087
+ catalogue = toCatalogue(next, 'setCatalogue');
3088
+ verdictOf = rulesFor(catalogue);
3089
+ if (connecting) cancelConnect();
3090
+ if (pendingOffer) clearOffer();
3091
+ paintPalette();
3092
+ paintNodes();
3093
+ paintPanel();
3094
+ },
3095
+ /** `{scale, tx, ty}`, as the kernel holds it. */
3096
+ getViewport: () => canvas.viewport(),
3097
+ /** Put the view there (clamped to the kernel's limits) — a fit still waiting
3098
+ * for its frame is dropped, and nothing is reported back. */
3099
+ setViewport(next) {
3100
+ const at = viewportOf(next);
3101
+ if (!at || destroyed) return false;
3102
+ cancelFit();
3103
+ canvas.setViewport(at);
3104
+ viewportBaseline();
3105
+ return true;
3106
+ },
1010
3107
  getGraph: () => normalise(graph),
1011
3108
  serialise: () => serialise(graph),
1012
3109
  /** The text `onChange` hands on and the history holds: the graph and the flow's settings. */
@@ -1014,11 +3111,53 @@ export function createCanvasEditor(host, options = {}) {
1014
3111
  getFlowSettings: () => structuredClone(settingsValue),
1015
3112
  setFindings(list) {
1016
3113
  findings = findingsList(list);
1017
- findingsBy = groupFindings(findings, { graph });
1018
- strip.set(findings);
3114
+ findingsBy = groupFindings(allFindings(), { graph });
3115
+ paintStrip();
1019
3116
  paintNodes();
1020
- panel.setFindings(findings);
3117
+ panel.setFindings(allFindings());
3118
+ },
3119
+ /**
3120
+ * What the host shows on the nodes now — `{[id]: {badge, tone, ports:
3121
+ * {[name]: text}}}`, or null for none — replacing what it showed
3122
+ * before. Painted in place: nothing is rebuilt or recorded, `onChange`
3123
+ * is not called and the panel is not touched. A type's own `update`
3124
+ * runs for each node whose values changed, and reads them as `ctx.values`.
3125
+ */
3126
+ setNodeValues(next) {
3127
+ if (destroyed) return;
3128
+ const fresh = new Map();
3129
+ for (const [id, v] of Object.entries(plainObject(next))) {
3130
+ const entry = valueEntry(v);
3131
+ if (entry) fresh.set(String(id), entry);
3132
+ }
3133
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
3134
+ const changed = [...new Set([...liveValues.keys(), ...fresh.keys()])]
3135
+ .filter((id) => !same(liveValues.get(id), fresh.get(id)));
3136
+ liveValues = fresh;
3137
+ let bodyChanged = false;
3138
+ painting = true;
3139
+ try {
3140
+ for (const id of changed) {
3141
+ const nodeRow = nodeOf(id);
3142
+ const n = nodeEls.get(id);
3143
+ if (!nodeRow || !n) continue;
3144
+ paintValues(n, nodeRow);
3145
+ bodyChanged = runUpdate(nodeRow) || bodyChanged;
3146
+ }
3147
+ } finally {
3148
+ painting = false;
3149
+ }
3150
+ // A body's `update` that started or stopped throwing: its finding is drawn.
3151
+ if (bodyChanged) {
3152
+ findingsBy = groupFindings(allFindings(), { graph });
3153
+ paintNodes();
3154
+ paintStrip();
3155
+ panel.setFindings(allFindings());
3156
+ }
1021
3157
  },
3158
+ /** `collapsible`: fold a node to its header (true) or unfold it, one
3159
+ * `flow:node:collapse` entry; false when refused or nothing changes. */
3160
+ setCollapsed: (id, collapsed) => setCollapsed(String(id), collapsed),
1022
3161
  /** Draw a run on the canvas — never changes the graph and rebuilds no field. */
1023
3162
  setRunOverlay(next) {
1024
3163
  overlay = next && typeof next === 'object' ? next : null;
@@ -1030,6 +3169,9 @@ export function createCanvasEditor(host, options = {}) {
1030
3169
  setReadOnly(next) {
1031
3170
  ro = next;
1032
3171
  if (ro && connecting) cancelConnect();
3172
+ if (ro) editing?.done(false);
3173
+ if (isReadOnly()) renaming?.finish(false);
3174
+ if (ro && pendingOffer) clearOffer();
1033
3175
  dragCleanup?.();
1034
3176
  paintReadOnly();
1035
3177
  paintPalette();
@@ -1047,6 +3189,7 @@ export function createCanvasEditor(host, options = {}) {
1047
3189
  select(target) {
1048
3190
  if (target === null || target === undefined) select(null);
1049
3191
  else if (target === 'flow') select({ kind: 'flow' });
3192
+ else if (multi && Array.isArray(target)) pickNodes(target.map(String));
1050
3193
  else if (nodeOf(String(target))) select({ kind: 'node', id: String(target) });
1051
3194
  },
1052
3195
  /** Rebuild the panel's fields, keeping the focus and the caret — for a
@@ -1055,14 +3198,58 @@ export function createCanvasEditor(host, options = {}) {
1055
3198
  focus() { canvas.root.focus({ preventScroll: true }); },
1056
3199
  undo,
1057
3200
  redo,
1058
- arrange: () => arrangeGraph(),
1059
- fit,
3201
+ arrange: (opts) => { const done = arrangeGraph(opts); viewportBaseline(); return done; },
3202
+ /** `multiSelect`: line the picked nodes up — left, center, right, top,
3203
+ * middle or bottom. False when refused (fewer than two, read only). */
3204
+ align: (kind) => (multi ? alignNodes(kind) : false),
3205
+ /** `multiSelect`: equal gaps, horizontal or vertical (three or more). */
3206
+ distribute: (axis) => (multi ? distributeNodes(axis) : false),
3207
+ fit: (opts) => { fit(opts); viewportBaseline(); },
3208
+ /** `groups`: group these steps (default: the picked ones), each leaving
3209
+ * the group it was in — one `flow:group:add`. The new group's id, or null. */
3210
+ group: (ids = null, look = {}) => (ids === null ? (refusals().group ? null : groupNodes(pickedIds(), look))
3211
+ : groupNodes(Array.isArray(ids) ? ids.map(String) : [], look)),
3212
+ /** `groups`: remove these groups (default: the ones the pick is in), their
3213
+ * steps staying where they are — one `flow:group:remove`. */
3214
+ ungroup: (ids = null) => (ids === null ? ungroupPicked()
3215
+ : ungroupGroups((Array.isArray(ids) ? ids : [ids]).map(String))),
3216
+ /** `groups`: a group's `{label, tone}` — one `flow:group:label`. */
3217
+ setGroup: (id, look) => setGroupLook(String(id), look || {}),
3218
+ /** `groups`: exactly these steps in the group — one `flow:group:members`. */
3219
+ setGroupMembers: (id, ids) => setGroupMembers(String(id), Array.isArray(ids) ? ids.map(String) : []),
3220
+ /** `notes`: add a note — one `flow:note:add`. A copy of it, or null. */
3221
+ addNote: (note) => addNote(note || {}),
3222
+ /** `notes`: a note's text — one `flow:note:edit`. */
3223
+ editNote: (id, text) => setNoteText(String(id), text),
3224
+ /** `notes`: put a note at `{x, y}` — one `flow:note:move`. */
3225
+ moveNote: (id, position) => moveNote(String(id), position),
3226
+ /** `notes`: take a note away — one `flow:note:remove`. */
3227
+ removeNote: (id) => (noting && !isReadOnly() ? dropNotes([String(id)]) : false),
3228
+ /** `clipboard`: copy the picked steps and the lines among them (and the picked notes, the groups wholly
3229
+ * inside); `{ok, ids, notes?}` or `{ok: false, reason}`. */
3230
+ copy: () => copyNodes(),
3231
+ /** `clipboard`: copy, then remove what was copied (one remove entry). Refused read only. */
3232
+ cut: () => cutNodes(),
3233
+ /** `clipboard`: paste `text`, or what the clipboard holds — a promise when the consumer's `read` is async. */
3234
+ paste: (text) => pasteNodes(text),
3235
+ /** `clipboard`: a copy of the picked steps beside them, one `flow:node:duplicate` entry. */
3236
+ duplicate: () => duplicateNodes(),
1060
3237
  destroy() {
1061
3238
  if (destroyed) return;
3239
+ editing?.done(false);
3240
+ // A wheel's report still waiting is handed on: the reader did zoom.
3241
+ if (wheelTimer !== null) viewportSettled();
3242
+ pressEnd?.();
1062
3243
  dragCleanup?.();
1063
- if (fitFrame !== null) globalThis.cancelAnimationFrame?.(fitFrame);
3244
+ cancelFit();
3245
+ renaming?.finish(false);
3246
+ for (const id of [...inlines.keys()]) dropInline(id);
3247
+ miniSized?.disconnect();
1064
3248
  closeFlowPopovers();
1065
3249
  unbindKeys();
3250
+ canvas.root.removeEventListener('keydown', onTab);
3251
+ window.removeEventListener('pointerup', pressLetGo, true);
3252
+ window.removeEventListener('pointercancel', pressLetGo, true);
1066
3253
  panel.destroy();
1067
3254
  strip.destroy();
1068
3255
  destroyed = true;
@@ -1076,6 +3263,17 @@ export function createCanvasEditor(host, options = {}) {
1076
3263
  paintNodes();
1077
3264
  paintPanel();
1078
3265
  paintToolbar();
3266
+ viewportBaseline();
1079
3267
  history.baseline(snapshot());
1080
3268
  return api;
1081
3269
  }
3270
+
3271
+ /** A viewport worth opening at: `{scale, tx, ty}`, every one a finite number, the scale above 0. */
3272
+ function viewportOf(v) {
3273
+ if (!v || typeof v !== 'object') return null;
3274
+ const scale = Number(v.scale);
3275
+ const tx = Number(v.tx);
3276
+ const ty = Number(v.ty);
3277
+ if (!(scale > 0) || !Number.isFinite(scale) || !Number.isFinite(tx) || !Number.isFinite(ty)) return null;
3278
+ return { scale, tx, ty };
3279
+ }