flexdesk 0.4.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +1036 -301
  2. package/css/base.css +2958 -2
  3. package/css/flexdesk.css +2962 -4
  4. package/css/overrides.css +4 -2
  5. package/css/tokens.css +86 -0
  6. package/dist/canvas.js +390 -0
  7. package/dist/canvas.js.map +7 -0
  8. package/dist/charts.js +782 -782
  9. package/dist/{chunk-LH5TSOZW.js → chunk-6JSOVNID.js} +42 -2
  10. package/dist/{chunk-LH5TSOZW.js.map → chunk-6JSOVNID.js.map} +3 -3
  11. package/dist/{chunk-ELXVW542.js → chunk-6NG7YVBG.js} +3 -471
  12. package/dist/chunk-6NG7YVBG.js.map +7 -0
  13. package/dist/{chunk-F5FYDCOA.js → chunk-CNXP3SIN.js} +2373 -2373
  14. package/dist/{chunk-F5FYDCOA.js.map → chunk-CNXP3SIN.js.map} +7 -7
  15. package/dist/chunk-H4LT43RS.js +475 -0
  16. package/dist/chunk-H4LT43RS.js.map +7 -0
  17. package/dist/{chunk-WMGOJ3I6.js → chunk-KH2GJTKR.js} +3702 -2628
  18. package/dist/chunk-KH2GJTKR.js.map +7 -0
  19. package/dist/chunk-SN4LSOAR.js +532 -0
  20. package/dist/chunk-SN4LSOAR.js.map +7 -0
  21. package/dist/flexdesk.css +2962 -4
  22. package/dist/flow.js +10030 -0
  23. package/dist/flow.js.map +7 -0
  24. package/dist/tiles.js +2195 -2195
  25. package/dist/tokens.css +86 -0
  26. package/dist/widgets.js +5824 -5796
  27. package/dist/widgets.js.map +1 -1
  28. package/dist/wm.js +8077 -8074
  29. package/dist/wm.js.map +7 -7
  30. package/package.json +89 -81
  31. package/src/canvas/canvas_adapter.js +523 -0
  32. package/src/canvas/edge_router.js +392 -0
  33. package/src/canvas/graph/connector_router.js +189 -0
  34. package/src/canvas/graph/history_service.js +170 -0
  35. package/src/canvas/graph/node_platform.js +116 -0
  36. package/src/canvas/index.js +16 -0
  37. package/src/flow/canvas_editor/editor.js +1081 -0
  38. package/src/flow/canvas_editor/index.js +16 -0
  39. package/src/flow/canvas_editor/layout.js +118 -0
  40. package/src/flow/canvas_editor/strings.js +37 -0
  41. package/src/flow/kit/always_before.js +325 -0
  42. package/src/flow/kit/catalogue.js +170 -0
  43. package/src/flow/kit/chip_input.js +461 -0
  44. package/src/flow/kit/dom.js +205 -0
  45. package/src/flow/kit/findings.js +129 -0
  46. package/src/flow/kit/history.js +173 -0
  47. package/src/flow/kit/index.js +38 -0
  48. package/src/flow/kit/keys.js +127 -0
  49. package/src/flow/kit/logic_graph.js +210 -0
  50. package/src/flow/kit/references.js +199 -0
  51. package/src/flow/kit/settings_panel.js +357 -0
  52. package/src/flow/kit/settings_schema.js +175 -0
  53. package/src/flow/kit/step_picker.js +209 -0
  54. package/src/flow/kit/strings.js +102 -0
  55. package/src/flow/kit/value_picker.js +238 -0
  56. package/src/flow/kit/widgets.js +763 -0
  57. package/src/flow/lanes/editor.js +1469 -0
  58. package/src/flow/lanes/index.js +26 -0
  59. package/src/flow/lanes/layout.js +525 -0
  60. package/src/flow/lanes/pipeline.js +327 -0
  61. package/src/flow/lanes/preview.js +109 -0
  62. package/src/flow/lanes/strings.js +98 -0
  63. package/src/flow/outline/editor.js +1511 -0
  64. package/src/flow/outline/index.js +20 -0
  65. package/src/flow/outline/mapping.js +286 -0
  66. package/src/flow/outline/menu.js +154 -0
  67. package/src/flow/outline/operations.js +738 -0
  68. package/src/flow/outline/recognise.js +755 -0
  69. package/src/flow/outline/strings.js +135 -0
  70. package/src/tiling/landing_table.js +13 -5
  71. package/src/tiling/wm.js +3916 -3916
  72. package/src/ui/components/data_table.js +4205 -3294
  73. package/src/ui/components/row_activation.js +70 -0
  74. package/src/ui/components/table_values.js +423 -0
  75. package/dist/chunk-ELXVW542.js.map +0 -7
  76. package/dist/chunk-WMGOJ3I6.js.map +0 -7
@@ -0,0 +1,129 @@
1
+ /**
2
+ * FINDINGS — what the consumer's validator said, drawn where it applies
3
+ * (36 §3.8). The kit decides none of them.
4
+ *
5
+ * A finding is the server's shape: `{code, message, severity: 'error' |
6
+ * 'warning', node_id?, field?}`.
7
+ *
8
+ * - one with a `node_id` is drawn ON THAT STEP (each editor draws its own
9
+ * mark) and, while that step's settings are open, under its field;
10
+ * - one without a `node_id` is drawn in the STRIP;
11
+ * - the strip lists every one of them, with *Go to it*;
12
+ * - one naming a step the graph no longer has goes to the strip as well —
13
+ * never nowhere.
14
+ */
15
+
16
+ import { button, el } from './dom.js';
17
+ import { createStrings, say } from './strings.js';
18
+
19
+ /** A list, or a validator result `{errors, warnings}`, as one list. */
20
+ export function findingsList(input) {
21
+ if (Array.isArray(input)) return input.filter((f) => f && typeof f === 'object');
22
+ if (input && typeof input === 'object') {
23
+ return [...(input.errors || []), ...(input.warnings || [])].filter((f) => f && typeof f === 'object');
24
+ }
25
+ return [];
26
+ }
27
+
28
+ /**
29
+ * The findings grouped by the step they are drawn on: a Map keyed by
30
+ * `node_id`, `null` for the flow. With `graph`, a finding naming a step the
31
+ * graph no longer has is filed under `null`.
32
+ */
33
+ export function groupFindings(input, { graph = null } = {}) {
34
+ const ids = graph ? new Set((graph.nodes || []).map((n) => n.id)) : null;
35
+ const out = new Map();
36
+ for (const f of findingsList(input)) {
37
+ let key = f.node_id ?? null;
38
+ if (key !== null && ids && !ids.has(key)) key = null;
39
+ if (!out.has(key)) out.set(key, []);
40
+ out.get(key).push(f);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** Is `prefix` a path prefix of `path` at a segment boundary? */
46
+ export function isPathPrefix(prefix, path) {
47
+ if (!prefix || !path) return false;
48
+ if (path === prefix) return true;
49
+ if (!path.startsWith(prefix)) return false;
50
+ const next = path[prefix.length];
51
+ return next === '.' || next === '[';
52
+ }
53
+
54
+ /**
55
+ * The field a finding is drawn under: the LONGEST of `paths` that is a
56
+ * segment-boundary prefix of the finding's `field`, or null.
57
+ */
58
+ export function matchFindingField(paths, field) {
59
+ if (!field) return null;
60
+ let best = null;
61
+ for (const p of paths) {
62
+ if (isPathPrefix(p, field) && (best === null || p.length > best.length)) best = p;
63
+ }
64
+ return best;
65
+ }
66
+
67
+ /** `error` before `warning`, otherwise as given. */
68
+ function bySeverity(list) {
69
+ return list.map((f, i) => ({ f, i }))
70
+ .sort((a, b) => ((a.f.severity === 'warning') - (b.f.severity === 'warning')) || (a.i - b.i))
71
+ .map((x) => x.f);
72
+ }
73
+
74
+ /**
75
+ * The strip: "N things to fix before this can be published." and one line per
76
+ * finding with *Go to it*. `aria-live="polite"`, hidden while empty.
77
+ *
78
+ * @param {object} [o]
79
+ * @param {(finding: object) => void} [o.onGoTo] select the step and focus the field
80
+ * @param {(nodeId: string|null) => string} [o.nameOf] a step's name for its line
81
+ * @param {object} [o.strings]
82
+ */
83
+ export function createFindingsStrip({ onGoTo = null, nameOf = null, strings = null } = {}) {
84
+ const S = createStrings(strings);
85
+ const root = el('div', 'twm-flow-strip');
86
+ root.setAttribute('role', 'status');
87
+ root.setAttribute('aria-live', 'polite');
88
+ root.hidden = true;
89
+ let list = [];
90
+
91
+ const paint = () => {
92
+ root.replaceChildren();
93
+ root.hidden = list.length === 0;
94
+ if (!list.length) return;
95
+ const errors = list.filter((f) => f.severity !== 'warning').length;
96
+ root.classList.toggle('twm-flow-strip--warning', errors === 0);
97
+ const head = el('div', 'twm-flow-strip__head');
98
+ head.append(el('span', 'twm-flow-strip__icon material-symbols-outlined', errors ? 'error' : 'warning'),
99
+ el('span', 'twm-flow-strip__title', errors
100
+ ? say(S, 'toFix', errors) : say(S, 'toLookAt', list.length)));
101
+ head.firstChild.setAttribute('aria-hidden', 'true');
102
+ root.appendChild(head);
103
+ const lines = el('ul', 'twm-flow-strip__list');
104
+ for (const f of bySeverity(list)) {
105
+ const li = el('li', `twm-flow-strip__line${f.severity === 'warning' ? ' twm-flow-strip__line--warning' : ''}`);
106
+ li.dataset.code = String(f.code ?? '');
107
+ if (f.node_id !== undefined && f.node_id !== null) li.dataset.node = String(f.node_id);
108
+ const where = f.node_id ? (nameOf?.(f.node_id) || String(f.node_id)) : (nameOf?.(null) || '');
109
+ li.appendChild(el('span', 'twm-flow-strip__text', say(S, 'findingLine', where, String(f.message ?? ''))));
110
+ if (onGoTo) {
111
+ li.appendChild(button({ label: say(S, 'goToIt'), className: 'twm-flow-strip__go',
112
+ onClick: () => onGoTo(f) }));
113
+ }
114
+ lines.appendChild(li);
115
+ }
116
+ root.appendChild(lines);
117
+ };
118
+
119
+ return {
120
+ el: root,
121
+ /** Draw `input` (a list or `{errors, warnings}`). */
122
+ set(input) {
123
+ list = findingsList(input);
124
+ paint();
125
+ },
126
+ get findings() { return [...list]; },
127
+ destroy() { root.remove(); },
128
+ };
129
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * UNDO AND REDO OVER WHOLE SNAPSHOTS, BEHIND A CLOSED ACTION LIST (36 §3.9).
3
+ * PURE: no DOM, runs under plain node.
4
+ *
5
+ * Generalised from Tables' designer history in exactly one respect: the
6
+ * closed list of actions is the EDITOR's, passed in, and `commit` still throws
7
+ * on any other. Everything else is kept, behaviour and reason.
8
+ *
9
+ * ══ WHY SNAPSHOTS ══════════════════════════════════════════════════════
10
+ *
11
+ * A flow is small — a few dozen steps and their settings — and one serialised
12
+ * snapshot IS the whole flow (its graph and its own settings), so "undo" is
13
+ * "put the text back" and there is no inverse to get wrong.
14
+ *
15
+ * ══ THE CLOSED LIST ════════════════════════════════════════════════════
16
+ *
17
+ * A future mutation path that forgets to name its action cannot silently fold
18
+ * itself into the next entry; it fails the first test that drives it. Loading
19
+ * a flow and a consumer's publish are absent from every list BY DESIGN: they
20
+ * are BASELINES (both stacks cleared), never entries — the first Ctrl+Z after
21
+ * opening must not "undo" the opening, and a publish is the boundary.
22
+ *
23
+ * ══ MERGING ════════════════════════════════════════════════════════════
24
+ *
25
+ * Typing a label is one edit to a reader, not one per keystroke. A commit with
26
+ * a `key` equal to the top entry's, within `mergeMs` of that entry's last
27
+ * commit, and with no undo or redo since, updates the top entry instead of
28
+ * pushing a new one — so the entry still restores the text from BEFORE the
29
+ * typing began. A merge that lands back on that text drops the entry: a label
30
+ * typed and deleted again is no edit. The first commit after an undo or a redo
31
+ * never merges, even inside the window: the entry it would merge into is an
32
+ * older one the undo exposed, and the next undo would skip a state.
33
+ *
34
+ * ══ SURVIVING A DESTROY ════════════════════════════════════════════════
35
+ *
36
+ * A host may destroy an editor on a tab switch. `keep()` hands the stacks to
37
+ * the caller; `adopt(kept, state)` takes them back only when the state the
38
+ * successor loaded is BYTE-IDENTICAL to the state the stacks were settled on.
39
+ * Bytes are this class's half of the rule: a consumer whose publish turns a
40
+ * draft into a published version byte-identical to it must also check that
41
+ * the successor holds the SAME DRAFT — a fact only the consumer can see — and
42
+ * check it before calling `adopt`.
43
+ */
44
+
45
+ export class FlowHistory {
46
+ /**
47
+ * @param {object} options
48
+ * @param {readonly string[]} options.actions the edits this editor records — anything else throws
49
+ * @param {(state: string) => void} options.restore puts a snapshot back
50
+ * @param {() => void} [options.onState] after every change to the stacks
51
+ * @param {number} [options.limit] entries kept; the oldest goes first
52
+ * @param {number} [options.mergeMs] how close two keyed commits must be
53
+ * @param {() => number} [options.now] the clock, for a test
54
+ */
55
+ constructor({ actions, restore, onState = null, limit = 100, mergeMs = 1000, now = () => Date.now() } = {}) {
56
+ if (!Array.isArray(actions) || actions.length === 0 || !actions.every((a) => typeof a === 'string')) {
57
+ throw new Error('FlowHistory needs its editor\'s closed list of actions: an edit nobody named '
58
+ + 'must fail rather than fold itself into the next entry.');
59
+ }
60
+ if (typeof restore !== 'function') {
61
+ throw new Error('FlowHistory needs a restore(state) function: undo has nothing to put back without one.');
62
+ }
63
+ this.actions = Object.freeze([...actions]);
64
+ this._restore = restore;
65
+ this._onState = onState;
66
+ this._limit = limit;
67
+ this._mergeMs = mergeMs;
68
+ this._now = now;
69
+ this._undo = [];
70
+ this._redo = [];
71
+ this._settled = null;
72
+ this._travelled = true;
73
+ this.applying = false;
74
+ }
75
+
76
+ get canUndo() { return this._undo.length > 0; }
77
+ get canRedo() { return this._redo.length > 0; }
78
+ get depth() { return this._undo.length; }
79
+ /** The state the stacks describe the present as. */
80
+ get settled() { return this._settled; }
81
+ /** The action the next undo would undo, or null. */
82
+ get undoAction() { return this._undo[this._undo.length - 1]?.action ?? null; }
83
+ /** The action the next redo would redo, or null. */
84
+ get redoAction() { return this._redo[this._redo.length - 1]?.action ?? null; }
85
+
86
+ /** The hydration gate: `state` is where history starts, and nothing before it is undoable. */
87
+ baseline(state) {
88
+ this._undo = [];
89
+ this._redo = [];
90
+ this._settled = state;
91
+ this._travelled = true;
92
+ this._changed();
93
+ }
94
+
95
+ /**
96
+ * Record that the flow is now `state`, by `action`. A no-op when nothing
97
+ * changed or while a restore is being applied. Returns whether the stacks moved.
98
+ */
99
+ commit(state, action, key = null) {
100
+ if (!this.actions.includes(action)) {
101
+ throw new Error(`"${action}" is not an edit this editor records (its actions are `
102
+ + `${this.actions.join(', ')}).`);
103
+ }
104
+ if (this.applying || state === this._settled) return false;
105
+ const at = this._now();
106
+ const top = this._undo[this._undo.length - 1];
107
+ if (key !== null && top && top.key === key && !this._travelled && at - top.at < this._mergeMs) {
108
+ top.at = at;
109
+ // Typed, then typed away again: the entry changes nothing.
110
+ if (top.before === state) this._undo.pop();
111
+ } else {
112
+ this._undo.push({ action, key, at, before: this._settled });
113
+ if (this._undo.length > this._limit) this._undo.shift();
114
+ }
115
+ this._redo = [];
116
+ this._settled = state;
117
+ this._travelled = false;
118
+ this._changed();
119
+ return true;
120
+ }
121
+
122
+ undo() {
123
+ const entry = this._undo.pop();
124
+ if (!entry) return false;
125
+ this._redo.push({ action: entry.action, after: this._settled });
126
+ this._travel(entry.before);
127
+ return true;
128
+ }
129
+
130
+ redo() {
131
+ const entry = this._redo.pop();
132
+ if (!entry) return false;
133
+ this._undo.push({ action: entry.action, key: null, at: 0, before: this._settled });
134
+ this._travel(entry.after);
135
+ return true;
136
+ }
137
+
138
+ /** The stacks, to be handed back to a successor through `adopt`. */
139
+ keep() {
140
+ return {
141
+ undo: this._undo.map((e) => ({ ...e })),
142
+ redo: this._redo.map((e) => ({ ...e })),
143
+ settled: this._settled,
144
+ };
145
+ }
146
+
147
+ /** Take `kept`'s stacks when they were settled on exactly `state`. */
148
+ adopt(kept, state) {
149
+ if (!kept || typeof kept.settled !== 'string' || kept.settled !== state) return false;
150
+ this._undo = (kept.undo || []).map((e) => ({ ...e }));
151
+ this._redo = (kept.redo || []).map((e) => ({ ...e }));
152
+ this._settled = state;
153
+ this._travelled = true;
154
+ this._changed();
155
+ return true;
156
+ }
157
+
158
+ _travel(state) {
159
+ this._settled = state;
160
+ this._travelled = true;
161
+ this.applying = true;
162
+ try {
163
+ this._restore(state);
164
+ } finally {
165
+ this.applying = false;
166
+ }
167
+ this._changed();
168
+ }
169
+
170
+ _changed() {
171
+ this._onState?.();
172
+ }
173
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The shared flow-editor kit (36 §3) — what every one of the three editors
3
+ * stands on, and what `@flexdesk/flow` exports for a consumer that builds its
4
+ * own. OWNED BY the kit agent (A); frozen once it lands.
5
+ */
6
+
7
+ export { FLOW_STRINGS, createStrings, say } from './strings.js';
8
+ export {
9
+ TONES, el, icon, button, toneClass, toneOf, uid, openPopover, closeFlowPopovers, openFlowPopover, nextOption,
10
+ } from './dom.js';
11
+ export {
12
+ SETTINGS_SCHEMA_ANNOTATIONS, SETTINGS_SCHEMA_KEYWORDS, SETTINGS_SCHEMA_TYPES, UI_HINTS,
13
+ checkSettingsSchema, fieldsFromSchema, fieldVisible, visibleFields, whenKeys, titleOf, enumLabel,
14
+ primaryType, admittedTypes, pyRepr,
15
+ } from './settings_schema.js';
16
+ export { createStepCatalogue, defaultIdBase } from './catalogue.js';
17
+ export {
18
+ createReferenceSyntax, TEMPLATE_REFERENCES, FORMULA_REFERENCES, PARAMETER_REFERENCES,
19
+ findReferences, tokenize, describeReference, resolveSyntaxes, pathSegments,
20
+ } from './references.js';
21
+ export { createChipInput } from './chip_input.js';
22
+ export {
23
+ createWidgetRegistry, GENERIC_WIDGETS, DEFAULT_WIDGET_REFERENCES, renderField, shownValue, typedValue,
24
+ fitToText,
25
+ } from './widgets.js';
26
+ export { createSettingsPanel } from './settings_panel.js';
27
+ export { openStepPicker } from './step_picker.js';
28
+ export { openValuePicker } from './value_picker.js';
29
+ export { alwaysBefore, enclosingLoops, enclosingArms, flowStructure, DEFAULT_LOOP_PORTS } from './always_before.js';
30
+ export {
31
+ groupFindings, findingsList, createFindingsStrip, matchFindingField, isPathPrefix,
32
+ } from './findings.js';
33
+ export { FlowHistory } from './history.js';
34
+ export { bindFlowKeys, ownsUndo, isTextField, isOwnControl } from './keys.js';
35
+ export {
36
+ ID_OK, LOGIC_GRAPH_STRINGS, emptyGraph, normalise, serialise, nextId, addNode, removeNode, moveNode,
37
+ canConnect, connect, disconnect, sameEdge, inputNamesOf,
38
+ } from './logic_graph.js';
@@ -0,0 +1,127 @@
1
+ /**
2
+ * AN EDITOR'S KEYS, BOUND ON ITS ROOT AND NEVER ON `window` (36 §3.10).
3
+ *
4
+ * Ctrl/⌘+Z · Ctrl/⌘+Y, Ctrl/⌘+Shift+Z undo · redo
5
+ * Delete remove the selection
6
+ * Ctrl/⌘+D · C · X · V duplicate · copy · cut · paste
7
+ * F2 rename
8
+ * Alt+↑ · Alt+↓ · Alt+← move up · down · out of its block
9
+ * ↑ ↓ ← → move the selection
10
+ * Enter open the selection's settings
11
+ * Escape close a picker, cancel a gesture
12
+ *
13
+ * ══ UNDO STOPS AT THE ROOT, EVEN IN A TEXT FIELD ═══════════════════════
14
+ *
15
+ * Ctrl+Z inside a text field is the field's own undo — the browser's default
16
+ * action on its text, which stopping propagation leaves alone. It is STOPPED
17
+ * all the same: another tile on the page may listen for Ctrl+Z on `window`
18
+ * (a diagram does), and an editor split beside it would otherwise undo both.
19
+ * A `<select>`, a checkbox or a button has no undo of its own, so Ctrl+Z there
20
+ * is the editor's. `ownsUndo` is the one answer to "does this control undo its
21
+ * own text": a textarea, an editable `contenteditable`, Monaco, an `<input>` of
22
+ * a text-like type.
23
+ *
24
+ * Every other key inside ANY field — a select included — is the field's, and
25
+ * Enter on a button, a link or a summary is that control's own activation.
26
+ *
27
+ * ══ BACKSPACE IS NEVER AN EDITOR'S KEY ═════════════════════════════════
28
+ *
29
+ * It belongs to the host's navigation (a page trail may climb one level on it),
30
+ * so outside a text field an editor neither acts on Backspace nor stops it.
31
+ * Delete removes a step.
32
+ */
33
+
34
+ /** The `<input>` types whose text the browser can undo. */
35
+ const TEXT_INPUTS = new Set(['', 'text', 'search', 'url', 'tel', 'email', 'password', 'number']);
36
+ const EDITABLE = '[contenteditable]:not([contenteditable="false"])';
37
+
38
+ /** Does Ctrl+Z here belong to the control rather than to the editor? */
39
+ export function ownsUndo(target) {
40
+ if (!target?.closest) return false;
41
+ if (target.closest(`textarea, ${EDITABLE}, .monaco-editor`)) return true;
42
+ const input = target.closest('input');
43
+ return Boolean(input) && TEXT_INPUTS.has(String(input.getAttribute('type') || '').toLowerCase());
44
+ }
45
+
46
+ /**
47
+ * Is `target` a control whose Enter is its own — a button, a link, a summary?
48
+ * An editor's own item (a row, a card, a node) marks itself
49
+ * `data-twm-flow-item`, and Enter there opens its settings.
50
+ */
51
+ export function isOwnControl(target) {
52
+ const c = target?.closest?.('button, a[href], summary, [role="button"], [role="menuitem"], '
53
+ + '[role="option"], [role="tab"], [role="radio"], [role="checkbox"]');
54
+ return Boolean(c) && !c.closest('[data-twm-flow-item]');
55
+ }
56
+
57
+ /** Is `target` inside a field, whose keys are its own? */
58
+ export function isTextField(target) {
59
+ return Boolean(target?.closest?.(`input, textarea, select, ${EDITABLE}, .monaco-editor`));
60
+ }
61
+
62
+ /**
63
+ * Bind the editor keys on `root`. Each handler is optional; a key whose
64
+ * handler is absent is not handled (nothing is prevented). A handler that
65
+ * returns `false` declines the key and it goes on as if unbound.
66
+ *
67
+ * @param {HTMLElement} root
68
+ * @param {object} handlers undo, redo, remove, duplicate, copy, cut, paste,
69
+ * rename, moveUp, moveDown, moveOut, up, down, left, right, open, escape
70
+ * @returns {() => void} unbind
71
+ */
72
+ export function bindFlowKeys(root, handlers = {}) {
73
+ const run = (name, ev) => {
74
+ const h = handlers[name];
75
+ if (typeof h !== 'function') return false;
76
+ if (h(ev) === false) return false;
77
+ ev.preventDefault();
78
+ return true;
79
+ };
80
+
81
+ const onKey = (ev) => {
82
+ if (ev.isComposing || ev.keyCode === 229) return;
83
+ const mod = (ev.ctrlKey || ev.metaKey) && !ev.altKey;
84
+ const k = String(ev.key || '');
85
+ const lower = k.toLowerCase();
86
+
87
+ if (mod && (lower === 'z' || lower === 'y')) {
88
+ // HERE AND NO FURTHER, wherever in the editor it was pressed.
89
+ ev.stopPropagation();
90
+ if (ownsUndo(ev.target)) return;
91
+ run(lower === 'y' || ev.shiftKey ? 'redo' : 'undo', ev);
92
+ return;
93
+ }
94
+ if (k === 'Backspace') return;
95
+ if (isTextField(ev.target)) return;
96
+ if (ev.defaultPrevented) return;
97
+
98
+ if (mod && !ev.shiftKey) {
99
+ if (lower === 'd') { run('duplicate', ev); return; }
100
+ if (lower === 'c') { run('copy', ev); return; }
101
+ if (lower === 'x') { run('cut', ev); return; }
102
+ if (lower === 'v') { run('paste', ev); return; }
103
+ return;
104
+ }
105
+ if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
106
+ if (k === 'ArrowUp') run('moveUp', ev);
107
+ else if (k === 'ArrowDown') run('moveDown', ev);
108
+ else if (k === 'ArrowLeft') run('moveOut', ev);
109
+ return;
110
+ }
111
+ if (ev.ctrlKey || ev.metaKey) return;
112
+ switch (k) {
113
+ case 'Delete': run('remove', ev); break;
114
+ case 'F2': run('rename', ev); break;
115
+ case 'ArrowUp': run('up', ev); break;
116
+ case 'ArrowDown': run('down', ev); break;
117
+ case 'ArrowLeft': run('left', ev); break;
118
+ case 'ArrowRight': run('right', ev); break;
119
+ case 'Enter': if (!ev.shiftKey && !isOwnControl(ev.target)) run('open', ev); break;
120
+ case 'Escape': run('escape', ev); break;
121
+ default: break;
122
+ }
123
+ };
124
+
125
+ root.addEventListener('keydown', onKey);
126
+ return () => root.removeEventListener('keydown', onKey);
127
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * A LOGIC FLOW'S GRAPH, as the canvas and the outline both edit it — PURE: no
3
+ * DOM, runs under plain node (36 §3.14). Moved from Tables' designer graph
4
+ * model, minus everything that was Tables'.
5
+ *
6
+ * The shape is the consumer server's:
7
+ *
8
+ * { nodes: [{ id, type, label?, config, position: {x, y} }],
9
+ * connections: [{ source, target, sourcePort, targetPort }] }
10
+ *
11
+ * **The editors decide nothing a publish depends on.** Whether a graph is a
12
+ * valid flow is the consumer's validator, drawn as findings. What `canConnect`
13
+ * refuses is only what an editor cannot DRAW honestly — a port that does not
14
+ * exist, a flow line into a data port, a second line out of a port that
15
+ * carries one, a step connected to itself — and every refusal is a sentence,
16
+ * returned, never thrown into a console.
17
+ *
18
+ * `serialise` is byte-stable: node keys in the order `id, type, label?, config,
19
+ * position`, config keys sorted, nodes and connections in the order they were
20
+ * drawn — so a graph opened and saved with no edit is the identical text.
21
+ */
22
+
23
+ import { createStrings } from './strings.js';
24
+
25
+ /** The node ids a server accepts: letters, digits, `-` and `_`, at most 64. */
26
+ export const ID_OK = /^[A-Za-z0-9_-]{1,64}$/;
27
+
28
+ export const LOGIC_GRAPH_STRINGS = Object.freeze({
29
+ connectSelf: 'A step cannot connect to itself.',
30
+ connectMissing: 'Both ends must be steps in this flow.',
31
+ connectNoOutput: (port) => `This step has no output called ${port}.`,
32
+ connectNoInput: (port) => `That step has no input called ${port}.`,
33
+ connectNotFlow: (how) => (how
34
+ ? `Only flow ports connect; a data port is read as ${how}.`
35
+ : 'Only flow ports connect; a data port is read, not connected.'),
36
+ connectTwice: 'These two are already connected.',
37
+ connectOneLine: (port) => `The ${port} port takes one connection; `
38
+ + 'use a step that runs several branches.',
39
+ });
40
+
41
+ export function emptyGraph() {
42
+ return { nodes: [], connections: [] };
43
+ }
44
+
45
+ /** A deep copy with every field the graph carries, and nothing else. */
46
+ export function normalise(graph) {
47
+ const g = graph && typeof graph === 'object' ? graph : emptyGraph();
48
+ return {
49
+ nodes: (Array.isArray(g.nodes) ? g.nodes : []).map((n) => ({
50
+ id: String(n.id),
51
+ type: String(n.type),
52
+ ...(n.label ? { label: String(n.label) } : {}),
53
+ config: n.config && typeof n.config === 'object' ? structuredClone(n.config) : {},
54
+ position: { x: Math.round(Number(n.position?.x) || 0), y: Math.round(Number(n.position?.y) || 0) },
55
+ })),
56
+ connections: (Array.isArray(g.connections) ? g.connections : []).map((c) => ({
57
+ source: String(c.source),
58
+ target: String(c.target),
59
+ sourcePort: String(c.sourcePort || 'out'),
60
+ targetPort: String(c.targetPort || 'in'),
61
+ })),
62
+ };
63
+ }
64
+
65
+ function sortedKeys(value) {
66
+ if (Array.isArray(value)) return value.map(sortedKeys);
67
+ if (value && typeof value === 'object') {
68
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortedKeys(value[k])]));
69
+ }
70
+ return value;
71
+ }
72
+
73
+ /** The text a save sends and a reload must reproduce exactly. */
74
+ export function serialise(graph) {
75
+ const g = normalise(graph);
76
+ return JSON.stringify({
77
+ nodes: g.nodes.map((n) => ({
78
+ id: n.id, type: n.type, ...(n.label ? { label: n.label } : {}),
79
+ config: sortedKeys(n.config), position: n.position,
80
+ })),
81
+ connections: g.connections.map((c) => ({
82
+ source: c.source, target: c.target, sourcePort: c.sourcePort, targetPort: c.targetPort,
83
+ })),
84
+ });
85
+ }
86
+
87
+ /**
88
+ * A new id for a step of `typeId`: `stem`, `stem-2`, … — readable inside a
89
+ * reference. The stem is the catalogue's `idBase` when a catalogue is given,
90
+ * else the type id, made safe for an id.
91
+ */
92
+ export function nextId(graph, typeId, { catalogue = null } = {}) {
93
+ const base = catalogue?.idBase
94
+ ? catalogue.idBase(typeId)
95
+ : (String(typeId || 'step').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 40) || 'step');
96
+ const taken = new Set((graph?.nodes || []).map((n) => n.id));
97
+ if (!taken.has(base)) return base;
98
+ for (let i = 2; ; i += 1) {
99
+ if (!taken.has(`${base}-${i}`)) return `${base}-${i}`;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Append a step of `typeId`. A wanted `id` is used when it is a valid id nobody
105
+ * holds; otherwise `nextId`. A step with no position is at (0, 0), the
106
+ * position `normalise` gives one.
107
+ */
108
+ export function addNode(graph, typeId, position = null, { id = null, label = null, config = null,
109
+ catalogue = null } = {}) {
110
+ const nid = id && ID_OK.test(id) && !graph.nodes.some((n) => n.id === id)
111
+ ? id : nextId(graph, typeId, { catalogue });
112
+ const node = {
113
+ id: nid, type: typeId, ...(label ? { label } : {}),
114
+ config: config && typeof config === 'object' ? structuredClone(config) : {},
115
+ position: { x: Math.round(position?.x || 0), y: Math.round(position?.y || 0) },
116
+ };
117
+ graph.nodes.push(node);
118
+ return node;
119
+ }
120
+
121
+ export function removeNode(graph, nodeId) {
122
+ graph.nodes = graph.nodes.filter((n) => n.id !== nodeId);
123
+ graph.connections = graph.connections.filter((c) => c.source !== nodeId && c.target !== nodeId);
124
+ }
125
+
126
+ export function moveNode(graph, nodeId, position) {
127
+ const node = graph.nodes.find((n) => n.id === nodeId);
128
+ if (node) node.position = { x: Math.round(position.x), y: Math.round(position.y) };
129
+ }
130
+
131
+ export const sameEdge = (a, b) => a.source === b.source && a.target === b.target
132
+ && a.sourcePort === b.sourcePort && a.targetPort === b.targetPort;
133
+
134
+ function portOf(catalogue, node, name, direction) {
135
+ if (!node) return null;
136
+ if (catalogue?.port) return catalogue.port(node.type, name, direction);
137
+ const def = catalogue?.get?.(node.type);
138
+ return def?.ports?.find((p) => p.name === name && p.direction === direction) || null;
139
+ }
140
+
141
+ /**
142
+ * May `source.sourcePort` connect to `target.targetPort`? `{ok: true}` or
143
+ * `{ok: false, reason}` — the editor shows the reason where the gesture failed.
144
+ * `dataRead` is the consumer's words for how a data port is read instead
145
+ * (`syntax.format(...)`), put in the refusal.
146
+ */
147
+ export function canConnect(graph, catalogue, { source, sourcePort, target, targetPort },
148
+ { strings = null, dataRead = null } = {}) {
149
+ const S = createStrings(LOGIC_GRAPH_STRINGS, strings);
150
+ if (source === target) return { ok: false, reason: S.connectSelf };
151
+ const from = graph.nodes.find((n) => n.id === source);
152
+ const to = graph.nodes.find((n) => n.id === target);
153
+ if (!from || !to) return { ok: false, reason: S.connectMissing };
154
+ const out = portOf(catalogue, from, sourcePort, 'output');
155
+ const inp = portOf(catalogue, to, targetPort, 'input');
156
+ if (!out) return { ok: false, reason: S.connectNoOutput(sourcePort) };
157
+ if (!inp) return { ok: false, reason: S.connectNoInput(targetPort) };
158
+ if ((out.port_type || 'FLOW') !== 'FLOW' || (inp.port_type || 'FLOW') !== 'FLOW') {
159
+ return { ok: false, reason: S.connectNotFlow(dataRead) };
160
+ }
161
+ if (graph.connections.some((c) => c.source === source && c.sourcePort === sourcePort
162
+ && c.target === target && c.targetPort === targetPort)) {
163
+ return { ok: false, reason: S.connectTwice };
164
+ }
165
+ if (!out.multiple && graph.connections.some((c) => c.source === source && c.sourcePort === sourcePort)) {
166
+ return { ok: false, reason: S.connectOneLine(out.label || sourcePort) };
167
+ }
168
+ return { ok: true };
169
+ }
170
+
171
+ /** Connect when `canConnect` allows it; returns its verdict either way. */
172
+ export function connect(graph, catalogue, edge, options = {}) {
173
+ const verdict = canConnect(graph, catalogue, edge, options);
174
+ if (verdict.ok) {
175
+ graph.connections.push({ source: edge.source, target: edge.target,
176
+ sourcePort: edge.sourcePort, targetPort: edge.targetPort });
177
+ }
178
+ return verdict;
179
+ }
180
+
181
+ export function disconnect(graph, edge) {
182
+ graph.connections = graph.connections.filter((c) => !sameEdge(c, edge));
183
+ }
184
+
185
+ /**
186
+ * The names a graph reads from one SCOPE of a reference syntax — every
187
+ * reference whose path starts `scope.` in any step's config, the segment after
188
+ * it, in first-seen order. (`inputNamesOf(graph, TEMPLATE_REFERENCES, 'run')`
189
+ * lists what a run must be given.)
190
+ */
191
+ export function inputNamesOf(graph, syntax, scope) {
192
+ const names = new Set();
193
+ const prefix = `${scope}.`;
194
+ const walk = (value) => {
195
+ if (typeof value === 'string') {
196
+ for (const m of syntax.find(value)) {
197
+ if (!m.path.startsWith(prefix)) continue;
198
+ const rest = m.path.slice(prefix.length);
199
+ const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest)?.[0];
200
+ if (name) names.add(name);
201
+ }
202
+ } else if (Array.isArray(value)) {
203
+ value.forEach(walk);
204
+ } else if (value && typeof value === 'object') {
205
+ Object.values(value).forEach(walk);
206
+ }
207
+ };
208
+ for (const n of graph?.nodes || []) walk(n.config);
209
+ return [...names];
210
+ }