flexdesk 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/LICENSE +0 -0
- package/README.md +2261 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3373 -238
- package/css/overrides.css +16 -0
- package/css/reset.css +0 -0
- package/css/tokens.css +0 -0
- package/dist/canvas.js +6 -239
- package/dist/canvas.js.map +3 -3
- package/dist/charts.js +709 -18
- package/dist/charts.js.map +4 -4
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- package/dist/chunk-4A7SJC3T.js +503 -0
- package/dist/chunk-4A7SJC3T.js.map +7 -0
- package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
- package/dist/chunk-CVMCZZX2.js.map +7 -0
- package/dist/chunk-D3COLZZ2.js +1311 -0
- package/dist/chunk-D3COLZZ2.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
- package/dist/chunk-GOQLR5WR.js.map +7 -0
- package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
- package/dist/chunk-HNU6MM2B.js.map +7 -0
- package/dist/chunk-HYJYYEPK.js +375 -0
- package/dist/chunk-HYJYYEPK.js.map +7 -0
- package/dist/chunk-JKZLNKG6.js +2041 -0
- package/dist/chunk-JKZLNKG6.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
- package/dist/chunk-MCIXFNVF.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
- package/dist/chunk-Q36EPT2R.js.map +7 -0
- package/dist/chunk-QWQJUDPD.js +610 -0
- package/dist/chunk-QWQJUDPD.js.map +7 -0
- package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
- package/dist/chunk-UURZ37SD.js.map +7 -0
- package/dist/chunk-UWO43BZU.js +111 -0
- package/dist/chunk-UWO43BZU.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +0 -0
- package/dist/chunk-WVFGV5FT.js.map +0 -0
- package/dist/core.js +170 -118
- package/dist/core.js.map +2 -2
- package/dist/editor.js +156 -70
- package/dist/editor.js.map +2 -2
- package/dist/flexdesk.css +3373 -238
- package/dist/flow.js +2789 -306
- package/dist/flow.js.map +4 -4
- package/dist/host.js +109 -3
- package/dist/host.js.map +3 -3
- package/dist/reset.css +0 -0
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +1131 -253
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3254 -2112
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -345
- package/dist/wm.js.map +4 -4
- package/package.json +3 -1
- package/src/canvas/canvas_adapter.js +5 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +1 -0
- package/src/canvas/layered_layout.js +244 -0
- package/src/charts/chart_types.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +2 -2
- package/src/charts/plot_config.js +53 -29
- package/src/charts/plot_config_editor.js +627 -0
- package/src/charts/plot_grid.js +449 -0
- package/src/charts/plot_popout_window.js +103 -317
- package/src/charts/plotly_wrapper.js +22 -2
- package/src/charts/series_transforms.js +206 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +1 -1
- package/src/core/settings.js +105 -19
- package/src/core/state_guard.js +93 -51
- package/src/core/state_guard_config.js +85 -74
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +3 -3
- package/src/editor/monaco_editor_factory.js +124 -60
- package/src/editor/monaco_loader.js +103 -28
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_tab_bar.js +38 -52
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +6 -3
- package/src/flow/canvas_editor/connections.js +90 -0
- package/src/flow/canvas_editor/editor.js +2351 -153
- package/src/flow/canvas_editor/index.js +2 -1
- package/src/flow/canvas_editor/layout.js +119 -12
- package/src/flow/canvas_editor/strings.js +80 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +97 -3
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +0 -0
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +3 -3
- package/src/flow/kit/keys.js +7 -1
- package/src/flow/kit/logic_graph.js +97 -15
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +2 -1
- package/src/flow/kit/step_picker.js +0 -0
- package/src/flow/kit/strings.js +3 -1
- package/src/flow/kit/value_picker.js +0 -0
- package/src/flow/kit/widgets.js +86 -109
- package/src/flow/lanes/editor.js +136 -14
- package/src/flow/lanes/index.js +3 -1
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +44 -2
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +1 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +0 -0
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- package/src/help/help_modal.js +168 -42
- package/src/help/help_registry.js +12 -1
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +117 -0
- package/src/host/host.js +10 -2
- package/src/host/photino_host.js +9 -0
- package/src/host/photino_host.md +21 -3
- package/src/host/pywebview_host.js +10 -0
- package/src/tiles/chart_tiles.js +53 -245
- package/src/tiles/config_schema.js +8 -0
- package/src/tiles/figure_tile.js +322 -0
- package/src/tiles/layout_persistence.js +3 -3
- package/src/tiles/list_tiles.js +422 -0
- package/src/tiles/scope_picker.js +96 -0
- package/src/tiles/tile_base.js +12 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +38 -1
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +67 -99
- package/src/tiling/command_palette.js +64 -19
- package/src/tiling/content_registry.js +4 -1
- package/src/tiling/desktops.js +11 -1
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +403 -128
- package/src/tiling/kind_taxonomy.js +78 -0
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +59 -10
- package/src/tiling/page_factory.js +5 -5
- package/src/tiling/panel_availability.js +49 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +114 -12
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_breadcrumb.js +0 -0
- package/src/tiling/tile_renderer.js +105 -9
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/tile_tree.js +0 -0
- package/src/tiling/wm.js +562 -30
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +30 -2
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +211 -0
- package/src/ui/components/cell_grid.js +648 -0
- package/src/ui/components/cell_selection.js +220 -0
- package/src/ui/components/choice_controls.js +351 -0
- package/src/ui/components/color_field.js +211 -0
- package/src/ui/components/color_list_field.js +210 -0
- package/src/ui/components/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +314 -0
- package/src/ui/components/column_order.js +142 -0
- package/src/ui/components/computing_status_window.js +98 -25
- package/src/ui/components/confirm_dialog.js +81 -38
- package/src/ui/components/context_menu.js +2 -2
- package/src/ui/components/conversation_view.js +440 -0
- package/src/ui/components/crosstab.js +289 -0
- package/src/ui/components/data_table.js +601 -62
- package/src/ui/components/detail_header.js +22 -11
- package/src/ui/components/document_outline.js +397 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +322 -0
- package/src/ui/components/gallery_picker.js +19 -8
- package/src/ui/components/header_actions.js +55 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +51 -0
- package/src/ui/components/managed_window.js +27 -4
- package/src/ui/components/menu_bar.js +745 -0
- package/src/ui/components/modal.js +449 -75
- package/src/ui/components/notification_history.js +3 -3
- package/src/ui/components/progress.js +483 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +298 -0
- package/src/ui/components/series.js +173 -0
- package/src/ui/components/settings_view.js +634 -0
- package/src/ui/components/slide_out_panel.js +43 -6
- package/src/ui/components/slider_field.js +275 -18
- package/src/ui/components/sortable_list.js +26 -4
- package/src/ui/components/staged_edits.js +343 -0
- package/src/ui/components/staged_history.js +172 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +182 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +497 -0
- package/src/ui/components/tag_input.js +215 -0
- package/src/ui/components/toast.js +25 -8
- package/src/ui/components/toggle_field.js +175 -0
- package/src/ui/components/tree_view.js +55 -34
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/wizard.js +563 -0
- package/src/ui/controllers/window_chrome_controller.js +2 -2
- package/src/ui/notification_center.js +375 -119
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/markdown.js +709 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +16 -12
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +55 -0
- package/src/ui/utils/tooltip_service.js +0 -8
- package/dist/chunk-6JSOVNID.js.map +0 -7
- package/dist/chunk-CWPMLX67.js +0 -475
- package/dist/chunk-CWPMLX67.js.map +0 -7
- package/dist/chunk-FBGP5KN5.js.map +0 -7
- package/dist/chunk-FL5KFNQH.js.map +0 -7
- package/dist/chunk-NLTOY57M.js.map +0 -7
- package/dist/chunk-NNFTETK3.js +0 -633
- package/dist/chunk-NNFTETK3.js.map +0 -7
- package/dist/chunk-O5OHMWBB.js +0 -284
- package/dist/chunk-O5OHMWBB.js.map +0 -7
- package/dist/chunk-RQM5Z2MU.js.map +0 -7
- package/dist/chunk-SN4LSOAR.js.map +0 -7
- 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 {
|
|
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 {
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
243
|
-
|
|
244
|
-
findingsBy = groupFindings(
|
|
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',
|
|
282
|
-
|
|
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,
|
|
296
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1414
|
+
const isData = (p.port_type || 'FLOW') !== 'FLOW';
|
|
1415
|
+
if (isData) {
|
|
541
1416
|
b.classList.add('twm-flow-graph__port--data');
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
1664
|
+
if (toggles(ev)) return;
|
|
1665
|
+
if (!(multi && isPicked(nodeId))) select({ kind: 'node', id: nodeId });
|
|
587
1666
|
if (isReadOnly()) return;
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
if (
|
|
591
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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)
|
|
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,
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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,
|
|
685
|
-
: routeEdge(from,
|
|
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
|
-
|
|
1939
|
+
const pick = (ev) => {
|
|
704
1940
|
ev.stopPropagation();
|
|
705
1941
|
select({ kind: 'edge', edge: { ...c } });
|
|
706
|
-
}
|
|
707
|
-
|
|
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
|
-
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const
|
|
726
|
-
|
|
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
|
-
/**
|
|
736
|
-
*
|
|
737
|
-
function
|
|
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
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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 === '
|
|
842
|
-
: selected?.kind === '
|
|
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 === '
|
|
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(
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
const
|
|
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 =
|
|
877
|
-
remove.disabled = isReadOnly();
|
|
878
|
-
connectionBox.replaceChildren(el('h3', 'twm-flow-graph__connection-title',
|
|
879
|
-
el('p', 'twm-flow-graph__connection-line',
|
|
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(
|
|
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 });
|
|
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)
|
|
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
|
-
|
|
995
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
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;
|
|
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(
|
|
1018
|
-
|
|
3114
|
+
findingsBy = groupFindings(allFindings(), { graph });
|
|
3115
|
+
paintStrip();
|
|
1019
3116
|
paintNodes();
|
|
1020
|
-
panel.setFindings(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|