flexdesk 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -1
- package/css/base.css +2868 -0
- package/css/flexdesk.css +2868 -0
- package/css/tokens.css +72 -0
- package/dist/canvas.js +390 -0
- package/dist/canvas.js.map +7 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-24MS7W5Y.js → chunk-6NG7YVBG.js} +2 -470
- package/dist/chunk-6NG7YVBG.js.map +7 -0
- package/dist/chunk-H4LT43RS.js +475 -0
- package/dist/chunk-H4LT43RS.js.map +7 -0
- package/dist/{chunk-KH2GJTKR.js → chunk-IYV5JVYB.js} +29 -1
- package/dist/chunk-IYV5JVYB.js.map +7 -0
- package/dist/chunk-SN4LSOAR.js +532 -0
- package/dist/chunk-SN4LSOAR.js.map +7 -0
- package/dist/{chunk-CNXP3SIN.js → chunk-ZMCRDOCF.js} +2 -2
- package/dist/flexdesk.css +2868 -0
- package/dist/flow.js +10030 -0
- package/dist/flow.js.map +7 -0
- package/dist/tiles.js +9 -9
- package/dist/tokens.css +72 -0
- package/dist/widgets.js +87 -5
- package/dist/widgets.js.map +3 -3
- package/dist/wm.js +4 -2
- package/dist/wm.js.map +1 -1
- package/package.json +9 -1
- package/src/canvas/canvas_adapter.js +523 -0
- package/src/canvas/edge_router.js +392 -0
- package/src/canvas/graph/connector_router.js +189 -0
- package/src/canvas/graph/history_service.js +170 -0
- package/src/canvas/graph/node_platform.js +116 -0
- package/src/canvas/index.js +16 -0
- package/src/flow/canvas_editor/editor.js +1081 -0
- package/src/flow/canvas_editor/index.js +16 -0
- package/src/flow/canvas_editor/layout.js +118 -0
- package/src/flow/canvas_editor/strings.js +37 -0
- package/src/flow/kit/always_before.js +325 -0
- package/src/flow/kit/catalogue.js +170 -0
- package/src/flow/kit/chip_input.js +461 -0
- package/src/flow/kit/dom.js +205 -0
- package/src/flow/kit/findings.js +129 -0
- package/src/flow/kit/history.js +173 -0
- package/src/flow/kit/index.js +38 -0
- package/src/flow/kit/keys.js +127 -0
- package/src/flow/kit/logic_graph.js +210 -0
- package/src/flow/kit/references.js +199 -0
- package/src/flow/kit/settings_panel.js +357 -0
- package/src/flow/kit/settings_schema.js +175 -0
- package/src/flow/kit/step_picker.js +209 -0
- package/src/flow/kit/strings.js +102 -0
- package/src/flow/kit/value_picker.js +238 -0
- package/src/flow/kit/widgets.js +763 -0
- package/src/flow/lanes/editor.js +1469 -0
- package/src/flow/lanes/index.js +26 -0
- package/src/flow/lanes/layout.js +525 -0
- package/src/flow/lanes/pipeline.js +327 -0
- package/src/flow/lanes/preview.js +109 -0
- package/src/flow/lanes/strings.js +98 -0
- package/src/flow/outline/editor.js +1511 -0
- package/src/flow/outline/index.js +20 -0
- package/src/flow/outline/mapping.js +286 -0
- package/src/flow/outline/menu.js +154 -0
- package/src/flow/outline/operations.js +738 -0
- package/src/flow/outline/recognise.js +755 -0
- package/src/flow/outline/strings.js +135 -0
- package/src/ui/components/data_table.js +40 -0
- package/src/ui/utils/scroll_lock.js +111 -0
- package/dist/chunk-24MS7W5Y.js.map +0 -7
- package/dist/chunk-KH2GJTKR.js.map +0 -7
- /package/dist/{chunk-CNXP3SIN.js.map → chunk-ZMCRDOCF.js.map} +0 -0
|
@@ -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
|
+
}
|