flexdesk 0.17.3 → 0.17.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +104 -65
  2. package/README.md +27 -4544
  3. package/css/base.css +18 -1
  4. package/css/flexdesk.css +18 -1
  5. package/dist/charts.js +20 -3
  6. package/dist/charts.js.map +2 -2
  7. package/dist/{chunk-4A7SJC3T.js → chunk-RKNFBX5K.js} +15 -4
  8. package/dist/{chunk-4A7SJC3T.js.map → chunk-RKNFBX5K.js.map} +2 -2
  9. package/dist/flexdesk.css +18 -1
  10. package/dist/tiles.js +1 -1
  11. package/dist/widgets.js +14 -2
  12. package/dist/widgets.js.map +2 -2
  13. package/docs/README.md +97 -0
  14. package/docs/best-practices.md +45 -0
  15. package/docs/components/charts.md +235 -0
  16. package/docs/components/command-palette.md +102 -0
  17. package/docs/components/conversation.md +72 -0
  18. package/docs/components/data-table-grid.md +328 -0
  19. package/docs/components/data-table.md +596 -0
  20. package/docs/components/editor.md +22 -0
  21. package/docs/components/flow-canvas-editor.md +482 -0
  22. package/docs/components/flow-editors.md +176 -0
  23. package/docs/components/flow-lane-editor.md +165 -0
  24. package/docs/components/flow-outline-editor.md +176 -0
  25. package/docs/components/forms-and-dialogs.md +294 -0
  26. package/docs/components/input-fields.md +72 -0
  27. package/docs/components/keymap.md +143 -0
  28. package/docs/components/layout-widgets.md +163 -0
  29. package/docs/components/markdown-and-outline.md +106 -0
  30. package/docs/components/menus.md +86 -0
  31. package/docs/components/notifications.md +194 -0
  32. package/docs/components/panels-and-desktops.md +127 -0
  33. package/docs/components/record-list-and-fact-table.md +128 -0
  34. package/docs/components/settings-view.md +112 -0
  35. package/docs/components/shell.md +189 -0
  36. package/docs/components/slide-out-panel.md +68 -0
  37. package/docs/components/tiles.md +210 -0
  38. package/docs/components/wizard.md +54 -0
  39. package/docs/concepts/building-and-packaging.md +33 -0
  40. package/docs/concepts/domain-neutrality.md +110 -0
  41. package/docs/concepts/host-port.md +137 -0
  42. package/docs/concepts/packages-and-entry-points.md +32 -0
  43. package/docs/concepts/peer-dependencies.md +42 -0
  44. package/docs/concepts/state-guard.md +58 -0
  45. package/docs/concepts/styling.md +47 -0
  46. package/docs/quickstart.md +179 -0
  47. package/package.json +2 -1
  48. package/src/charts/data_series_plot_window.js +17 -3
  49. package/src/ui/components/slide_out_panel.js +14 -3
  50. package/src/ui/utils/auto_scrollbars.js +16 -2
@@ -0,0 +1,482 @@
1
+ # Flow canvas editor
2
+
3
+ > **Packages:** `@flexdesk/flow`, `@flexdesk/canvas` (the kernel) · **Shared kit:** [Flow editors and the flow kit](flow-editors.md) · [All docs](../README.md)
4
+
5
+ ## The canvas editor
6
+
7
+ A flow drawn as nodes and lines: a palette of step types, a pannable canvas and
8
+ the settings of whatever is picked, under a toolbar. It stands on the
9
+ `@flexdesk/canvas` kernel, which is also usable on its own for any diagram.
10
+
11
+ **The kernel** (`@flexdesk/canvas`).
12
+
13
+ | Part | What it does |
14
+ |---|---|
15
+ | `new CanvasAdapter({container, gridSize = 16, onViewportChange, onBackgroundMenu, nodeSelector})` | A surface you put nodes and lines on: right-, middle- or Shift-drag pans, Ctrl + the wheel zooms about the pointer, a left press on the background draws a marquee (`onMarquee(box)`, `onSelectionChange(selection)`). Fields `root`, `surface`, `edgeLayer` (an SVG), `nodeLayer`, `marquee`, `selection`, `scale`, `tx`, `ty`, `gridSize`. Methods `clientToCanvas`, `canvasToClient`, `snap`, `setViewport`, `viewport`, `zoomAt`, `fit`, `visibleBox`, `contains`, `bringIntoView` (pans and never zooms, and does nothing when the box is already in view), `consumeRightDrag`, `clear`, `destroy`. **A node is whatever you put in `nodeLayer`**: a press inside one starts no pan and no marquee, and a right-click on one keeps your menu (or the browser's). Pass `nodeSelector` when the node layer holds other things too. `onViewportChange` fires once, synchronously, from the constructor, so declare what it reads before you build the adapter. `CanvasAdapter.MIN_SCALE` (0.15) and `CanvasAdapter.MAX_SCALE` (4) are the limits every zoom clamps to (0.17.0) |
16
+ | `routeEdge(from, to, {obstacles, simple})`, `selfLoop`, `returnEdge(from, to, {floor})` | SVG path data between two anchors `{x, y, side}`: a line with horizontal lead-outs (bent round obstacles up to `OBSTACLE_ROUTING_NODE_LIMIT` nodes), a node's line to itself, and a loop's line back, which runs under both nodes |
17
+ | `crowsFoot(point, side, kind)`, `footAnchor(point, side, kind)`, `cardinality({kind, optional})` | Crow's-foot ends (`many`, `one`, `zero-or-many`, `one-or-many`, `zero-or-one`, `exactly-one`) as an SVG group `twm-edge__marker twm-edge__marker--<kind>`, where the line should start so it meets the mark, and the two ends a reference implies |
18
+ | `arrange(nodes, edges, {columnGap, rowGap})` | A layered layout. **Two quirks, kept on purpose:** an edge points from child to parent (a node sits one column right of what it points at), and each column is sorted by `label`, which every node must have |
19
+ | `layeredLayout(nodes, edges, {rank, sizeOf, columnGap = 64, rowGap = 32, componentGap = 80, passes = 8})` (0.17.0) | A layered layout for any directed graph, as a diagram is read: `{from, to}` puts `from` left of `to`. Longest-path columns (cycles broken, never hung on); `rank(node)` answering `'source'` or `'sink'` puts a node in the first or the last column; barycentre sweeps reduce crossings, keeping the order with the fewest (the order given included); `sizeOf(node)` → `{width, height}` (default the node's own `width`/`height`, else 160 × 80) sizes the columns; components no line joins are stacked top to bottom, the largest first, `componentGap` apart. Returns a Map of id → the top-left `{x, y}` from (0, 0). Pure; 300 nodes and 400 edges take a few milliseconds |
20
+ | `new NodePlatform({collapsedPorts})` | The nodes and their ports: `hydrate(nodes)`, `get`, `all`, `port`, `setPosition`, `setCollapsed`, `visiblePorts(id)`, `on(event, fn)` (`hydrated`, `moved`, `collapsed`). A node is `{id, position, size, collapsed, ports, …}`; any other key is kept |
21
+ | `new ConnectorRouter({platform, selfReference, policy, endsOf, disabledEnds, strings})` | Edges and whether a drop may become one. `canConnect(source, target, {pendingEdges})` refuses, in order, a node that is gone, a port that is gone, a disabled port (with its `disabledReason`), a self-reference (unless `selfReference`), and a port at its `maxConnections`. Only then does it ask your `policy(ctx)`, which may refuse, allow, or answer `{ok: true, offer}`. Also `hydrate`, `edges` (a Map), `edgesFor`, `stage`, `unstage`, `remapNodeConnections(nodeId, portIds)` (keeps the edges whose ports survive and reports the rest) |
22
+ | `new HistoryService({actions, apply, onState, limit, requires, refusal})` | Undo over recorded edits (deltas), behind a closed action list: `record` throws on any action you did not declare. Nothing is recorded before `hydrated()`, and a batch (`startBatch`/`endBatch`) is one entry. Also `undo`, `redo`, `reset`, `adopt`, `undoStack`, `redoStack`, `canUndo`, `canRedo`. A missing `actions`, `apply` or anything named in `requires` throws at construction, not at the first silent no-op |
23
+
24
+ A diagram with its own rules subclasses the three and passes them in. For
25
+ example, a schema diagram's router answers the generic refusals in its own
26
+ words and keeps its foreign-key rules in the policy:
27
+
28
+ ```js
29
+ class ConnectorRouter extends Base.ConnectorRouter {
30
+ constructor({ platform }) {
31
+ super({ platform, selfReference: true, disabledEnds: ['source'],
32
+ endsOf: (e) => ({ from: `col:${e.from_col}`, to: `col:${e.to_col}` }),
33
+ strings: { nodeGone: 'That table is no longer on the diagram.' },
34
+ policy: (ctx) => myForeignKeyRules(ctx) });
35
+ }
36
+ }
37
+ ```
38
+
39
+ `tests/canvas_graph.test.mjs` §7 builds such a diagram on the generic layer.
40
+
41
+ **The editor.**
42
+
43
+ ```js
44
+ import { createCanvasEditor, createStepCatalogue } from '@flexdesk/flow';
45
+
46
+ const editor = createCanvasEditor(host, {
47
+ catalogue: createStepCatalogue(types, { categories }),
48
+ flowSettings: { title: 'Sync', schema, value }, // the flow's own settings, shown when nothing is picked
49
+ actions: [{ id: 'publish', label: 'Publish', icon: 'publish', primary: true, run: (ed) => publish(ed) }],
50
+ onChange: ({ graph, flowSettings, text, action, key, via }) => autosave(text),
51
+ values: async ({ stepId, field, before, loops }) => groups, // Insert a value
52
+ dataPortRead: (node, port) => `\${steps.${node.id}.${port.name}}`,
53
+ summariseReads: (node) => `Later steps read it as \${steps.${node.id}.…}`,
54
+ });
55
+ editor.load({ graph }); // a baseline: there is nothing to undo after it
56
+ editor.setFindings(validatorResult); // on the nodes, under the fields, in the strip
57
+ editor.setRunOverlay({ steps, ports, banner });
58
+ ```
59
+
60
+ | Option | Meaning |
61
+ |---|---|
62
+ | `catalogue` | `createStepCatalogue(...)`, or the raw list of types. The palette shows them by category, except start-role types (`paletteTypes(type)` decides). A type with `unavailable` is shown greyed, with its sentence under it |
63
+ | `widgets`, `references`, `strings`, `services` | The kit's widget registry, the reference syntaxes by name, your words (`CANVAS_STRINGS` and the kit's), and your bag, handed to every widget unchanged |
64
+ | `values` | *Insert a value*: called with `{graph, stepId, field, key, before, loops}`, where `before` is the steps that always run before this one (`alwaysBefore`, nearest first) and `loops` the loops around it. Returns groups, or `{groups, note}` |
65
+ | `summarise(node, type)` | A line under a node's ports. `summariseReads(node, type)` is the panel's id line |
66
+ | `dataPortRead(node, port)` | How a data port is read, in your syntax. It is shown on the port, which cannot be connected (unless `connections.dataPorts` is `'connect'`) |
67
+ | `connections` | `{dataPorts: 'connect', policy(ctx)}`, absent by default, and then nothing below is drawn or bound. With it every line is asked, in order: what the canvas cannot draw (`canConnect` with `dataPorts`: a data port connects only to a data port, and a data output carries any number of lines); the kernel's `ConnectorRouter` over the catalogue ports, which take its descriptor keys (`disabled`, `disabledReason`, `maxConnections`, `allowMultiple`); the port types (an input's `accepts: ['number']` refuses an output whose `provides` names none of them, in a sentence); and your `policy(ctx)` — the router's context plus `connection` and a copy of the `graph` — which may refuse with a `reason`, allow, or answer `{ok: true, offer: {message, accept, apply(draft)}}`. An offer is shown with Accept and Not now: Accept makes the line and runs `apply` on a copy of the graph (change it in place or return a new one), as one undo entry. An output can also be dragged: while it is, the inputs it may end on carry `twm-flow-graph__port--can-drop`; released on one it connects; released on the empty canvas it opens the step picker with the types that have an input it may connect to, and the step picked is added with the line as one `flow:node:add` |
68
+ | `flowSettings` | `{title, schema, value}` — the flow's own settings, edited as `flow:settings` |
69
+ | `readOnly` | `false`, or `{reason}`, which is shown above the editor. Every verb is then refused with that reason, and no gesture writes |
70
+ | `actions` | Your toolbar verbs, `[{id, label, icon, primary, danger, title, run(editor)}]`, after Undo, Redo, Arrange and Fit (and the zoom buttons, with `zoomControls`) |
71
+ | `slots` | `toolbarEnd(el)`, `flowPanel(el, editor)` and `stepPanel(el, node, editor)`, drawn by you; `setRunOverlay` redraws the panel slots and nothing else |
72
+ | `onChange`, `onSelect` | After every committed edit. An undo or a redo is a change too (`action: null`, `via: 'undo' \| 'redo'`), because you autosave what is undone. `onSelect({kind, id})`: `step`, `flow`, `connection` (with `connection`) or `null`; with `multiSelect`, two or more picked steps are `{kind: 'many', ids}`; with `notes`, a picked note is `{kind: 'note', id}` and a pick with notes in it `{kind: 'many', ids, notes}` |
73
+ | `multiSelect` (0.17.0) | `true` makes the pick a set (below, *Many picked*). Without it none of it is bound or drawn |
74
+ | `groups`, `notes` (0.17.0) | `true` draws the graph's groups as frames behind their steps and its notes as text on the canvas, and lets the reader make and edit them (below, *Groups and notes*). Without them none of it is bound or drawn, and a graph's groups and notes are kept as they are |
75
+ | `clipboard`, `prepareCopy(node, {from, via})` (0.17.0) | `true` keeps what Ctrl/⌘+C copies inside the editor while it is open; `{read(), write(text)}` hands it to you: `write` hears the copied JSON and `read` (text, or a promise of it) supplies what Ctrl/⌘+V pastes. `prepareCopy` may change a copy's `config` and `label` before it is added (`via`: `'paste'` or `'duplicate'`). Unset, Ctrl/⌘+C, X, V and D are not the editor's (below, *Copy and paste*) |
76
+ | `loop`, `waitsForAll` | The loop's port names (`{entry: 'in', next, body, done}`): a line into a loop-role step on any input but `entry` is its body going back round, drawn dashed under both steps. `waitsForAll(join)` goes to `alwaysBefore` |
77
+ | `removable(node, type)` | Whether a step may be removed. By default every step except a start step |
78
+ | `node` | `{width: 208, header: 34, row: 22}` (`CANVAS_NODE`). Lines are anchored by arithmetic over these numbers, and the stylesheet is held to them |
79
+ | `edgeStyle(connection, {source, target, back})` | How a line is drawn, asked for each line on every paint with copies of its two steps (`back`: it is a loop's line back). Returns `null` or `{tone, dashed, width, marker, label}`: `tone` is one of `TONES`, `width` a stroke width, `marker` `'arrow'` or a crow's-foot kind (`'many'`, `'one'`, `'zero-or-many'`, `'one-or-many'`, `'zero-or-one'`, `'exactly-one'`) drawn at the input end, and `label` text drawn at the line's midpoint, above the steps (a press on it picks the line). A picked or highlighted line still shows as one. Unset, the lines are drawn exactly as before |
80
+ | `routing` | `'simple'` (the default: direct curves) or `'obstacles'`: each line is bent round the steps between its ends by the kernel's `routeEdge`, up to `OBSTACLE_ROUTING_NODE_LIMIT` steps. Above that the lines are direct curves and the findings strip says so (code `canvas-routing-simple`) |
81
+ | `hoverHighlight` | `true`: the pointer over a step gives its lines `twm-flow-graph__edge--highlighted`, and over a line that line; leaving takes it away |
82
+ | `history`, `gridSize`, `label` | `{limit, mergeMs}`, the snap grid, and the canvas's accessible name |
83
+ | `viewport`, `onViewportChange(viewport)` | Where the reader looks, kept by you: the editor stores none of it. `viewport` (`{scale, tx, ty}`, as `getViewport()` gives it) is where the first `load` opens, instead of fitting. `onViewportChange` hears ONE report per finished gesture of the reader's (a pan's release, a run of wheel turns once the wheel has been still for 300 ms, Fit, Arrange, a zoom button, a minimap click, *Go to it*), never one per pointer move, never a gesture that ended where it began, and never what you set yourself (`load`, `setViewport`, `fit()`, `arrange()`). A wheel report still waiting is handed on by the next press and by `destroy` (0.17.0) |
84
+ | `zoomControls` | `true` adds Zoom out, Zoom in and 100% after Fit. They step through 15%, 25%, 33%, 50%, 67%, 80%, 100%, 125%, 150%, 200%, 300% and 400%, about the centre of the view. At the kernel's limits the button is refused in place with its sentence. Off by default (0.17.0) |
85
+ | `renameInPlace` | `true`: a double-click on a step's title edits its name on the node ([Edited on the node](#edited-on-the-node-0170)). Off by default, and then a double-click does nothing (0.17.0) |
86
+ | `collapsible` | `true` puts a fold button on every step's header: it folds the step to its header and back, one `flow:node:collapse` entry each way (below, *Live values and folded steps*). Off by default, and then there is no button in the DOM and a `collapsed` flag in a graph is carried through untouched, the step drawn unfolded (0.17.0) |
87
+ | `minimap` | `true` draws a small overview over the canvas's lower right corner, with the steps and the view's frame, redrawn when the canvas changes size as well as when the view moves. A click on it centres the view on that point, at the same scale. Off by default, and then there is no minimap in the DOM (0.17.0) |
88
+ | `layout` (0.17.0) | What Arrange does. `'flow'` (the default): in the order a run takes the steps, from the start step(s). `'layered'`: the kernel's `layeredLayout` over the lines, with each type's `rank` key (`'source'` or `'sink'`, read by the catalogue's `rank(typeId)`) as its hint. Or your own `fn(graph, ctx)` returning a Map of id → `{x, y}` from the origin (a step it leaves out stays where it is; a place that is not finite numbers `{x, y}`, like anything but a Map, throws), handed a copy of what is arranged and `ctx = {catalogue, node, ids, sizeOf(node), layeredLayout}` (`ids` is `null` for the whole graph). With `multiSelect` and several steps picked, the toolbar's Arrange lays out only those steps and the lines between them, and only they move. The places are anchored at the top-left corner of what is arranged and snapped to the grid |
89
+
90
+ | Method | Does |
91
+ |---|---|
92
+ | `load({graph, flowSettings}, {baseline = true, fit = true, viewport})` | Replaces the content. A baseline clears both stacks. With `baseline: false` the stacks are kept, and the loaded content becomes the present they undo from. With `viewport` it opens there and does not fit |
93
+ | `getViewport()`, `setViewport({scale, tx, ty})` | The kernel's viewport, as a copy; and putting the view there, clamped to the kernel's limits. `setViewport` drops a fit still waiting for its frame, is not reported to `onViewportChange`, and answers `false` (changing nothing) for anything that is not three finite numbers with a scale above 0 (0.17.0) |
94
+ | `getGraph()`, `serialise()`, `snapshot()`, `getFlowSettings()` | A copy of the graph; its byte-stable text; the text the history holds (graph and settings); the settings |
95
+ | `setFindings(list)`, `setRunOverlay(overlay \| null)`, `setReadOnly(false \| {reason})` | — |
96
+ | `setNodeValues({[id]: {badge, tone, ports: {[name]: text}}} \| null)` (0.17.0) | What you show on the steps right now, replacing what you showed before. Painted in place: no undo entry, no `onChange`, nothing rebuilt and the panel untouched (below) |
97
+ | `setCollapsed(id, collapsed)` (0.17.0) | With `collapsible`: fold a step to its header (`true`) or unfold it, one `flow:node:collapse` entry. `false` when refused (no `collapsible`, read only, no such step, or no change) |
98
+ | `setActionState(id, {disabled, reason, busy})`, `setStatus(text)`, `setMessage(text, tone)` | A verb refused in place, with its reason printed beside the toolbar; the status line; the gesture line |
99
+ | `align(kind)`, `distribute(axis)` (0.17.0) | With `multiSelect`: line the picked steps up (`'left'`, `'center'`, `'right'`, `'top'`, `'middle'`, `'bottom'`; two or more picked) or give them equal gaps (`'horizontal'`, `'vertical'`; three or more), one `flow:nodes:align` entry. `false` when refused (too few picked, read only, or no `multiSelect`); an unknown kind or axis throws |
100
+ | `copy()`, `cut()`, `paste(text?)`, `duplicate()` (0.17.0) | With `clipboard`: the keys' verbs from code. Each answers `{ok: true, ids}` (the steps copied, cut or made) or `{ok: false, reason}`, the reason also on the message line; `paste()` answers a promise when your `read` is async, and `paste(text)` pastes that text. Without `clipboard`: `{ok: false, reason: null}` |
101
+ | `select(id \| [id, …] \| 'flow' \| null)`, `repaintPanel()`, `focus()`, `undo()`, `redo()`, `fit({whole})` | `select([id, …])` (0.17.0, `multiSelect`) picks several steps at once (unknown ids are left out; one left is one step picked, none lets go) |
102
+ | `group(ids?, {label, tone})`, `ungroup(groupIds?)`, `setGroup(id, {label, tone})`, `setGroupMembers(id, ids)` (0.17.0) | With `groups`: group these steps (by default the picked ones), each leaving the group it was in, and answer the new group's id (`group`, `group-2`, …) or `null`; remove these groups (by default the ones the picked steps are in), their steps staying put; a group's name and tone; exactly these members, none removing the group. One `flow:group:add`, `…:remove`, `…:label` or `…:members` entry each, `false`/`null` when refused (read only, nothing to do) |
103
+ | `addNote({text, position, size})`, `editNote(id, text)`, `moveNote(id, {x, y})`, `removeNote(id)` (0.17.0) | With `notes`: a note at `position` (by default the centre of the view), answered as a copy (`note`, `note-2`, …) or `null`; its text; where it is; taking it away. One `flow:note:add`, `…:edit`, `…:move` or `…:remove` entry each |
104
+ | `setCatalogue(catalogue \| types)` | Swaps the step types while the editor is open, for a type registered after `load`: the palette is redrawn and a node whose type's definition changed is rebuilt, so a node of a type that was unknown now draws. A definition is compared by what the node is drawn from (known or not, `icon`, `label`, tone, ports, `render`, `update` and `inline`), not by object identity: a new catalogue of the same types plus one more rebuilds no node and calls no `render` again. Under the `connections` option the connection rules and the empty-drop picker judge by the new catalogue, and a pending offer is taken away. The graph and the history are untouched (0.17.0) |
105
+ | `arrange({ids})` | Arrange the whole graph, as the toolbar's button does when fewer than two steps are picked. With `ids` (0.17.0), only those steps and the lines between them are laid out and only they move, still one `flow:arrange` entry; fewer than two known steps, or a read-only editor, answers `false` |
106
+ | `history`, `canvas` | The editor's `FlowHistory` (`keep()`, `adopt(kept, editor.snapshot())`, `baseline(editor.snapshot())` for your publish) and its `CanvasAdapter` |
107
+ | `destroy()` | Removes every listener, the ghost of a drag, the minimap and any picker it opened. It saves nothing, because the editor holds nothing; a viewport report still waiting for the wheel to settle is handed to `onViewportChange` first |
108
+
109
+ Its edits are the closed list `CANVAS_ACTIONS`: `flow:node:add`,
110
+ `flow:node:remove`, `flow:node:move` (one per drag), `flow:edge:connect`,
111
+ `flow:edge:disconnect`, `flow:node:label`, `flow:node:config`,
112
+ `flow:settings`, `flow:arrange` (one entry for the whole layout), and with
113
+ `multiSelect` `flow:nodes:move`, `flow:nodes:remove` and `flow:nodes:align`
114
+ (0.17.0; one entry per gesture on several steps), with `clipboard`
115
+ `flow:paste` and `flow:node:duplicate` (0.17.0; one entry per paste or
116
+ duplicate, however many steps it makes), with `collapsible`
117
+ `flow:node:collapse` (0.17.0), and with `groups` and `notes`
118
+ `flow:group:add`, `flow:group:remove`, `flow:group:label`,
119
+ `flow:group:members`, `flow:note:add`, `flow:note:remove`, `flow:note:edit` and
120
+ `flow:note:move` (0.17.0). Typing a name
121
+ or a setting is one entry. The geometry under it is exported as well, all of it
122
+ pure: `CANVAS_NODE`, `arrangeCanvasGraph(graph, catalogue, {loopEntry})` (where
123
+ Arrange puts each step: a Map of id → `{x, y}` from the origin),
124
+ `canvasPortAnchor`, `canvasNodeBottom` (both fold a node with `collapsed: true`
125
+ to its header, or as the box's `folded(node)` says; 0.17.0), `canvasNodeWidth` (a type's own
126
+ `size.w`, else the box's; 0.17.0), `isCanvasReturnEdge`,
127
+ `canvasPathPoints(d, {steps})` (the points along a drawn line's path `d`:
128
+ absolute `M`, `L`, `C`, `S` and `Z`, each curve sampled at `steps` points) and
129
+ `canvasPathMidpoint(d)` (the point halfway along it by length, where a style's
130
+ `label` sits).
131
+
132
+ ### Node bodies (0.17.0)
133
+
134
+ A catalogue type can draw its own controls and values inside its node:
135
+
136
+ ```js
137
+ const gauge = {
138
+ type_id: 'gauge', label: 'Gauge', ports, size: { w: 280, h: 170 }, accent: 'teal',
139
+ render(body, node, ctx) { // once, when the node is built
140
+ const range = document.createElement('input');
141
+ range.type = 'range';
142
+ range.addEventListener('change', () => ctx.setConfig({ level: Number(range.value) }));
143
+ body.append(range);
144
+ },
145
+ update(body, node, ctx) { // after render, and on every repaint
146
+ body.querySelector('input').value = node.config.level ?? 50;
147
+ body.querySelector('input').disabled = ctx.readOnly;
148
+ },
149
+ };
150
+ editor.setCatalogue(createStepCatalogue([...types, gauge], { categories })); // after load too
151
+ ```
152
+
153
+ - **The body sits below the ports** and is built once with the node. `update`
154
+ runs on the same element on every repaint, so a control inside it keeps its
155
+ focus and what was typed into it. `node` is a copy: the body writes only
156
+ through `ctx`. A later `setCatalogue` keeps that body too, unless the type's
157
+ own definition changed (its ports, `label`, `icon`, category tone, `render`
158
+ or `update`): then the node is built again and `render` runs again. A new
159
+ `size` or `accent` is applied in place.
160
+ - **`ctx`** is `{setConfig(patch), readOnly, values, services}`. `values` is
161
+ what `setNodeValues` shows on this step now (`{badge, tone, ports}`, a copy),
162
+ or `null`. `setConfig` merges
163
+ `patch` into the node's config (an `undefined` value removes the key) as one
164
+ `flow:node:config` edit: one undo entry, and `onChange` is called. It is
165
+ refused (answers `false`) while the editor is read only, from inside
166
+ `render` or `update`, and when a value in `patch` cannot be cloned (a
167
+ function, a DOM node): then nothing is written. `services` is the editor's `services` option.
168
+ - **`size: {w, h}`** is the node's box in pixels; either may be left out. Lines
169
+ leave a node's outputs at its own width, and a node with `h` is exactly that
170
+ tall, its body taking what is left. A body node without `h` is as tall as
171
+ it is drawn. Arrange spaces its columns by the widest node.
172
+ - **`accent`** is a tone (`violet`, `teal`, `amber`, `grey`, `blue`, `indigo`),
173
+ drawn as a stripe over the node's header.
174
+ - **A `render` that throws** leaves that node drawn as a type without a body
175
+ is, and what it threw is one of the editor's own findings: on the node and
176
+ in the strip, beside yours from `setFindings`. An `update` that throws keeps
177
+ the body and says so the same way until it succeeds. No other node is
178
+ affected.
179
+ - A type without these keys is drawn exactly as before, with the same markup,
180
+ and `serialise()` is unchanged.
181
+
182
+ ### Edited on the node (0.17.0)
183
+
184
+ ```js
185
+ const counter = { type_id: 'counter', label: 'Counter', ports, inline: ['count'],
186
+ config_schema: { type: 'object', properties: { count: { type: 'number' } } } };
187
+ const editor = createCanvasEditor(host, { catalogue: [counter], renameInPlace: true });
188
+ ```
189
+
190
+ - **`renameInPlace: true`.** A double-click on a step's title puts a text box
191
+ in its place, the name selected. Enter, or leaving the box, is one
192
+ `flow:node:label` edit, and none when the name is unchanged. Escape puts the
193
+ name back and records nothing. An empty name is refused with a sentence on
194
+ the gesture line and the name stays: on Enter the box stays open
195
+ (`aria-invalid`), on leaving it the box closes. Read only, a double-click
196
+ opens nothing, and going read only closes an open box without writing. The
197
+ settings panel, if it is showing that step, shows the new name.
198
+ - **A type's `inline` settings** are drawn under the node's ports, above its
199
+ own body, by the same widget registry as the panel (`widgets`), with the
200
+ same labels and controls. A change on the node is a `flow:node:config` edit,
201
+ and the panel shows it at once. A change in the panel, or an undo, redraws
202
+ the node's control. The control being typed in is never redrawn under the
203
+ reader; a focused control whose value changes elsewhere (an undo pressed
204
+ while it holds the focus) is redrawn with its focus and caret kept. Typing
205
+ on the node and then in the panel are two undo entries. Read only disables
206
+ the node's controls. A node with inline settings and no
207
+ `size.h` is as tall as it is drawn.
208
+ - Without the option and the key nothing is bound or drawn: the same markup,
209
+ and `serialise()` is unchanged.
210
+
211
+ ### Live values and folded steps (0.17.0)
212
+
213
+ ```js
214
+ const editor = createCanvasEditor(host, { catalogue, collapsible: true });
215
+ editor.setNodeValues({ mix: { badge: '0.42', tone: 'teal', ports: { out: '0.42' } } }); // as often as you like
216
+ editor.setNodeValues(null); // none shown
217
+ ```
218
+
219
+ - **`setNodeValues`** paints what you are showing right now: `badge` is drawn
220
+ on the step's header, before its id tag, in a tone of `TONES` when `tone`
221
+ names one, and each `ports[name]` beside that port. It replaces what the
222
+ last call showed, and a step it leaves out shows nothing. It extends the run
223
+ overlay: nothing is rebuilt or recorded, `onChange` is not called, and the
224
+ settings panel is not touched, so a field being typed into keeps its focus
225
+ and its selection however often you call it. A type with its own body reads
226
+ them as `ctx.values`, and its `update` runs for each step whose values
227
+ changed (a `setConfig` from there is refused, as from any `update`). The
228
+ badge and port-value elements exist only while there is a value to show;
229
+ the editor keeps nothing of them past the next call.
230
+ - **`collapsible: true`** puts a fold button on each header. A folded step
231
+ shows its header only: its port rows, its body and what is drawn under them
232
+ are hidden, and its lines stay, meeting the middle of the header on its left
233
+ (lines in) and its right (lines out). Each fold or unfold is one
234
+ `flow:node:collapse` entry, so Ctrl+Z unfolds a step just folded. The graph
235
+ carries `collapsed: true` on a folded step, written last in its `serialise()`
236
+ text; an unfolded step has no `collapsed` key, so a graph that never folded
237
+ a step is the text it always was. Read only refuses it.
238
+
239
+ **Gestures.** A node's header moves it. A connection is made by clicking an
240
+ output and then an input, and Escape cancels one in progress. Under the
241
+ `connections` option an output can also be dragged to an input, or onto the
242
+ empty canvas to pick a step that arrives connected. A line is picked
243
+ by clicking it, and Delete removes it or the picked step. A palette step is
244
+ added by clicking it (at the centre of the view) or by dragging it onto the
245
+ canvas (where it is dropped). The drag uses pointer events and a ghost on
246
+ `document.body`. It never uses `draggable`, which cancels the pointer after
247
+ about 5px, and never `setPointerCapture`, which would send the release, and so
248
+ a second click, to the palette button. No press rebuilds a node, so a click lands
249
+ on the node it went down on. Arrange lays the run out left to right, a branch's
250
+ arms in their declared order, from the graph's own top-left corner. Fit frames
251
+ the graph at natural size or smaller; pressing Fit frames all of it.
252
+
253
+ **Many picked** (`multiSelect: true`, 0.17.0). A left drag on the empty canvas
254
+ draws a marquee and picks every step it touches; Shift-, Ctrl- or ⌘-click on
255
+ a step adds it to the pick or takes it away; Ctrl/⌘+A picks every step and
256
+ Escape lets go. `select([id, …])` picks several from code. Dragging any picked
257
+ step moves them all (`flow:nodes:move`), Delete removes every one that may be
258
+ removed, with their lines (`flow:nodes:remove`; a start step stays), and the
259
+ panel says how many are picked, with their Remove. On the canvas the arrow
260
+ keys nudge the pick one grid step (`gridSize`), ten with Shift; a held arrow is
261
+ one entry. Eight icon verbs after Fit align the pick's left edges, centres,
262
+ right edges, tops, middles and bottoms, and distribute it horizontally and
263
+ vertically, each refused in place with fewer than two (three) picked. Arrange
264
+ lays out only the pick and the lines between them, and nothing else moves. Tab and
265
+ Shift+Tab move the focus from step to step in graph order, and past either end
266
+ Tab is the browser's again, so the keyboard can leave the canvas. Every one of
267
+ these is a single undo entry. In read-only mode a marquee still picks, and no
268
+ gesture writes.
269
+
270
+ **Groups and notes** (`groups: true`, `notes: true`, 0.17.0). A group is a
271
+ named frame, in one of `TONES` if it has a `tone`, drawn behind its steps and
272
+ round all of them wherever they are. Its title bar is its handle: dragging it
273
+ moves every member (one `flow:nodes:move`; with `multiSelect` the press picks
274
+ them), and a double-click renames it in place (Enter keeps, Escape puts it
275
+ back). Groups are flat and a step is in one at most: Ctrl/⌘+G (or Group on the
276
+ toolbar) groups the picked steps, taking each out of the group it was in, and
277
+ a group left empty goes; Ctrl/⌘+Shift+G (or Ungroup) removes the groups the
278
+ picked steps are in. Removing a step shrinks its group, and removing its last
279
+ member removes the group. Between the steps the frame takes no pointer, so a
280
+ marquee or a pan can start inside it. A note is free text on the canvas: Note
281
+ on the toolbar adds one at the centre of the view and opens it for typing; a
282
+ double-click, or Enter on a focused note, opens it again (Ctrl/⌘+Enter or
283
+ leaving the box keeps the text, Escape puts it back); dragging it moves it,
284
+ and Delete removes the picked one. With `multiSelect` a marquee picks notes
285
+ with the steps, Shift/Ctrl+click adds or takes one away, Ctrl/⌘+A picks them
286
+ too, and dragging or deleting the pick takes its notes along, one entry. A
287
+ graph keeps them as `groups` and `notes` (see `normalise` above) and
288
+ `serialise()` writes them after the connections; a graph without any is the
289
+ same text as before.
290
+
291
+ **Copy and paste** (`clipboard`, 0.17.0). Ctrl/⌘+C copies the picked steps
292
+ and the lines among them; a line to a step left out of the pick is not copied,
293
+ nor is a step whose type the palette does not offer (a start step) or that is
294
+ `unavailable`. What is copied is the graph's own text, `{nodes, connections}`
295
+ as `serialise()` writes it, so a graph from elsewhere pastes too. Ctrl/⌘+V
296
+ gives every pasted step a fresh id (as a new step gets one), keeps the lines
297
+ between them (data lines too, under `connections` with `dataPorts: 'connect'`, from 0.17.1), sets the copy off two grid steps right and down (each further
298
+ paste of the same text one more), picks it, and is one `flow:paste` entry. A
299
+ paste right after Ctrl/⌘+X puts the steps back where they were; a cut is one
300
+ remove entry. A step whose title the flow already has gets a derived label,
301
+ `Title (copy)`, then `Title (copy 2)`, …, as the lanes editor's duplicate does.
302
+ Ctrl/⌘+D does the same beside the pick without touching the clipboard, as one
303
+ `flow:node:duplicate`. With nothing picked Ctrl/⌘+C, X and D are not the
304
+ editor's. What is not steps, or names a type the flow does not have, is refused
305
+ with its sentence on the message line. In read-only mode copying still works,
306
+ and Paste, Cut and Duplicate are refused with the read-only reason and change
307
+ nothing. With `notes` the picked notes are copied, cut and duplicated with the
308
+ steps (a note alone too), and pasted under fresh ids, set off as the steps
309
+ are; with `groups` a group whose every member is copied is in the copy, and a
310
+ paste makes it again round the copies of its members (a fresh id, its name and
311
+ tone kept), while a group with a member left out is not copied. `copy()` and
312
+ `cut()` answer `notes: [id, …]` and `paste()` and `duplicate()` answer `notes`
313
+ and `groups`, each only when there are some.
314
+
315
+ **Keys** (bound on the editor's root): Ctrl/⌘+Z and Ctrl/⌘+Y or Ctrl/⌘+Shift+Z,
316
+ stopped at the root even inside a text field; Delete; F2 renames the picked
317
+ step; Enter on a focused node opens its settings; Escape; with `groups`,
318
+ Ctrl/⌘+G and Ctrl/⌘+Shift+G. Backspace is never the editor's.
319
+
320
+ **What it does not do.** It does not load, save, validate, publish or run
321
+ anything, and it does not keep the stacks across page loads. Those belong to
322
+ the consumer. Nor does it add anything a flow must have: a consumer whose new
323
+ flow opens on a start step adds that step to the graph before `load`, so it is
324
+ part of the baseline and the first Ctrl+Z does not take it away.
325
+
326
+ **Where each claim was checked.** `tests/canvas_adapter.test.mjs`,
327
+ `canvas_edge_router`, `canvas_graph`, `flow_canvas_layout`, `flow_canvas_editor`,
328
+ `flow_canvas_multiselect`, `flow_canvas_clipboard`, `flow_canvas_node_body`, `flow_canvas_live_values`, `flow_canvas_groups_notes` and `flow_canvas_css` check the DOM, the events, the
329
+ geometry and the stylesheet as text. In headless Edge, `demo/flow_canvas_probe.mjs` drives
330
+ `demo/flow_canvas.html` with real input:
331
+ - `elementFromPoint` at the centre of Undo, Arrange, Fit, a consumer verb, a
332
+ palette item, a node's header, a port, and the panel's name box, first field
333
+ and Remove step returned each one, and the panel was not covered.
334
+ - Every line began and ended within 1px of its ports' dots, measured.
335
+ - The loop's line back ran below both of its steps.
336
+ - A real pointer drag from the palette added exactly one step, under the
337
+ pointer, with a `dragstart` counter of 0. A drag ending off the canvas added
338
+ nothing.
339
+ - A real click-then-click connected two steps.
340
+ - A header drag moved its node, and a real Ctrl+Z put it back. Backspace removed
341
+ nothing, Delete removed the step, and the focus stayed on the canvas.
342
+ - On a 300-step graph with `multiSelect`, a real marquee picked three steps, a
343
+ real drag of one moved all three and one real Ctrl+Z put them back; a move
344
+ of 50 picked steps and its undo each finished in under 100 ms.
345
+ - With `groups` and `notes`, a real Ctrl+G on two picked steps drew one frame
346
+ round both and not round the third. Its title bar, and a step inside it, were
347
+ each the element at their own centre, and between the steps the canvas was
348
+ (the frame takes no pointer). A real drag of the title bar moved both by the
349
+ pointer's travel as one entry, and one real Ctrl+Z put them back. A real
350
+ double-click opened a note for typing, real keys and Ctrl+Enter kept the
351
+ text, a real drag moved it, and a real marquee picked the steps and the note
352
+ together.
353
+ - With `collapsible`, ten `setNodeValues` calls 100 ms apart each put their
354
+ value on the step's badge, inside its header, while real keys typed into a
355
+ settings field: the field kept the focus and every key, and the values made
356
+ no undo entry and no `onChange`. A real click on a header's fold button hid
357
+ every port row and left the step as tall as its header, with its lines
358
+ ending on the header's middle at its edges, within 1px; a real Ctrl+Z
359
+ unfolded it and its lines met their port dots again.
360
+ - A type registered with `setCatalogue` after the page opened was in the
361
+ palette. Its node was drawn at its 280×170 size, with its body below its
362
+ ports and its accent. Every line into and out of it began and ended on its
363
+ port dots, within 1px.
364
+ - At 700px the canvas took the editor's whole width.
365
+
366
+ `tests/flow_canvas_edge_style.test.mjs` checks `edgeStyle`, `routing` and
367
+ `hoverHighlight` in the DOM, and `demo/flow_canvas_edges_probe.mjs` in headless
368
+ Edge:
369
+ - A dashed line had a computed dash pattern, and an unstyled line kept its old
370
+ stroke and width.
371
+ - An arrowhead's tip sat on the input port's centre line, and the line ended at
372
+ its base.
373
+ - A label sat on its line's midpoint over a step, and `elementFromPoint` there
374
+ returned the label.
375
+ - With `routing: 'obstacles'`, no point of a line lay inside the drawn box of
376
+ the step between its ends. Above the node limit the strip said so.
377
+ - A real pointer over a step highlighted its lines, and moving off cleared them.
378
+
379
+ ### Building a modeler (0.17.1)
380
+
381
+ A typed node modeler (a dataflow, a signal chain, a diagram of blocks) is the
382
+ canvas editor with its modeler options turned on. Every one of them is a
383
+ `createCanvasEditor` option or a key on a catalogue type, off by default: an
384
+ editor that sets none of them draws, binds and serialises exactly as before.
385
+ None of them stores anything: the graph, the viewport and the values shown are
386
+ the host's, handed in and handed back. Each edit they add is on the closed list
387
+ `CANVAS_ACTIONS`, so one gesture is one undo entry. `demo/flow_modeler.html`
388
+ (linked from `demo/index.html`) uses all of them together on a signal-flow
389
+ diagram of sources, gains, a sum and displays.
390
+
391
+ | Option | What it gives | Details |
392
+ |---|---|---|
393
+ | `render`, `update`, `size`, `accent` (type keys) | A node that draws its own controls and values, at its own size, marked in a tone; `setCatalogue` adds a type while the editor is open | [Node bodies](#node-bodies-0170) |
394
+ | `connections` (`dataPorts`, `policy`) with `accepts` / `provides` (port keys) | Data ports that connect, typed, under the host's rules, by drag or click; an empty drop picks a step that arrives connected | `connections` above |
395
+ | `multiSelect` | Marquee, bulk move and delete, nudge, align and distribute | *Many picked* above |
396
+ | `clipboard`, `prepareCopy` | Copy, cut, paste and duplicate of the picked steps and the lines among them | *Copy and paste* above |
397
+ | `groups`, `notes` | Flat named frames round steps, and free-text notes | *Groups and notes* above |
398
+ | `layout`, `rank` (type key) | Arrange by the kernel's layered layout, sources first and sinks last, or your own | `layout` above |
399
+ | `edgeStyle`, `routing`, `hoverHighlight` | Lines in a tone, dashed, wider, with an arrow or a crow's foot and a label; bent round steps; lit while pointed at | `edgeStyle` above |
400
+ | `viewport`, `onViewportChange`, `zoomControls`, `minimap` | Where the reader looks, kept by you; zoom buttons; an overview | `viewport` above |
401
+ | `renameInPlace`, `inline` (type key) | A step's name and chosen settings edited on the node | [Edited on the node](#edited-on-the-node-0170) |
402
+ | `setNodeValues`, `collapsible` | Values painted on the steps as often as you like; steps folded to their header | [Live values and folded steps](#live-values-and-folded-steps-0170) |
403
+
404
+ A type draws its own body (`render` once, `update` on every repaint), with its
405
+ own `size` and `accent`; `inline` draws chosen settings on the node, and
406
+ `rank` places it when Arrange runs the layered layout:
407
+
408
+ ```js
409
+ const IN = { name: 'in', direction: 'input', port_type: 'DATA', accepts: ['number'], maxConnections: 1 };
410
+ const OUT = { name: 'out', direction: 'output', port_type: 'DATA', provides: ['number'] };
411
+ const types = [
412
+ { type_id: 'source', label: 'Source', rank: 'source', inline: ['amplitude'], ports: [OUT],
413
+ config_schema: { type: 'object', properties: { amplitude: { type: 'number', title: 'Amplitude' } } } },
414
+ { type_id: 'gain', label: 'Gain', size: { w: 224, h: 132 }, accent: 'amber', ports: [IN, OUT],
415
+ render(body, node, ctx) {
416
+ const range = Object.assign(document.createElement('input'), { type: 'range', min: -2, max: 2, step: 0.1 });
417
+ range.addEventListener('change', () => ctx.setConfig({ k: Number(range.value) })); // one undo entry
418
+ body.append(range);
419
+ },
420
+ update(body, node, ctx) { body.querySelector('input').value = node.config.k ?? 1; } },
421
+ { type_id: 'display', label: 'Display', rank: 'sink', ports: [IN],
422
+ render: (body) => body.append(document.createElement('output')),
423
+ update: (body, node, ctx) => { body.querySelector('output').textContent = ctx.values?.badge ?? '—'; } },
424
+ ];
425
+ editor.setCatalogue(createStepCatalogue([...types, laterType], { categories })); // a type added after load
426
+ ```
427
+
428
+ The editor's options, each one independent of the others:
429
+
430
+ ```js
431
+ const editor = createCanvasEditor(host, {
432
+ catalogue: createStepCatalogue(types, { categories }),
433
+ connections: { // data ports connect, typed by accepts/provides
434
+ dataPorts: 'connect',
435
+ policy: ({ connection, graph }) => (closesLoop(graph, connection)
436
+ ? { ok: false, reason: 'That line would feed a block its own output.' } : null),
437
+ },
438
+ multiSelect: true, // marquee, bulk move, align, distribute
439
+ clipboard: true, // or {read(), write(text)} to hold it yourself
440
+ prepareCopy: (copy, { from, via }) => { copy.config.phase = 0; },
441
+ groups: true, notes: true, // Ctrl+G frames, free-text notes
442
+ layout: 'layered', // Arrange: columns by the lines, `rank` hints
443
+ edgeStyle: (c, { source, target }) => ({ tone: 'teal', marker: 'arrow', width: 2,
444
+ dashed: c.targetPort === 'b', label: source.label }),
445
+ routing: 'obstacles', hoverHighlight: true,
446
+ viewport: savedView, // opens there instead of fitting
447
+ onViewportChange: (view) => { savedView = view; }, // once per finished pan or zoom
448
+ zoomControls: true, minimap: true,
449
+ renameInPlace: true, // double-click a title to rename it
450
+ collapsible: true, // a fold button on every header
451
+ });
452
+ editor.load({ graph }, { viewport: savedView });
453
+ setInterval(() => editor.setNodeValues({ mix: { badge: '0.42', tone: 'teal', ports: { out: '0.42' } } }), 250);
454
+ ```
455
+
456
+ `setNodeValues` records nothing and leaves the focus where it is, so a host
457
+ can paint as often as its values change.
458
+
459
+ **Where it was checked.** `tests/flow_modeler_demo.test.mjs` mounts the demo
460
+ under jsdom: what it shows, its live badges, and this section's list against
461
+ the options. `tests/flow_kit_vocabulary.test.mjs` §4 holds every flow and
462
+ canvas source the demo imports, and the demo itself, to the vocabulary gate.
463
+ In headless Edge, `demo/flow_modeler_probe.mjs` opens the page by a real click
464
+ on its link in `demo/index.html` and drives it with real input:
465
+ - The bodies were drawn at their own size below their ports; every line left
466
+ its output dot and its arrowhead ended on its input dot, within 1px; the
467
+ group frame was round both sources and nothing else; a dashed line had a
468
+ dash pattern and a label was the element at its own centre.
469
+ - The badges changed in each of three seconds while a settings field, focused
470
+ by a real click, kept the focus, and nothing was recorded.
471
+ - A real drag from an output to an input added one line, dot to dot, as one
472
+ undo entry. The gesture line appears above the canvas when a drag starts and
473
+ moves the canvas down by its height, so the probe aims at the input where it
474
+ is drawn once the line has started, as a reader would.
475
+ - A real click and Shift+click and a real Ctrl+G drew a second frame round
476
+ exactly the two steps; real Ctrl+C and Ctrl+V put two copies two grid steps
477
+ right of and below the originals, with the line between them; a real click
478
+ on Arrange ran every line left to right, the sources first and the displays
479
+ last, with no two steps overlapping. Each was one undo entry.
480
+ - A real Ctrl+Z after each, last first, put the steps back where they were on
481
+ the canvas, took the copies, the second frame and the dragged line away, and
482
+ left the graph the text it was before each step.