flexdesk 0.5.0 → 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.
- package/README.md +621 -1
- package/css/base.css +2835 -0
- package/css/flexdesk.css +2835 -0
- package/css/tokens.css +72 -0
- package/dist/canvas.js +390 -0
- package/dist/canvas.js.map +7 -0
- 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-SN4LSOAR.js +532 -0
- package/dist/chunk-SN4LSOAR.js.map +7 -0
- package/dist/flexdesk.css +2835 -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 +6 -4
- package/dist/widgets.js.map +1 -1
- 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/dist/chunk-24MS7W5Y.js.map +0 -7
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REFERENCE SYNTAXES — what a chip is recognised as, supplied by the consumer
|
|
3
|
+
* (36 §3.6).
|
|
4
|
+
*
|
|
5
|
+
* A syntax is a RECOGNISER FOR DRAWING, not a grammar. Which references are
|
|
6
|
+
* valid, and what one means, is the consumer's server's to say; a reference
|
|
7
|
+
* it would refuse still draws as a chip (toned `known: false` by the
|
|
8
|
+
* consumer's `describe`) and is kept exactly as text.
|
|
9
|
+
*
|
|
10
|
+
* syntax.find(text) → [{start, end, text, path}]
|
|
11
|
+
* syntax.format(path) → the text that refers to `path`
|
|
12
|
+
* syntax.rename(text, map) → `text` with every reference to an old step
|
|
13
|
+
* id in `map` pointed at its new id
|
|
14
|
+
* syntax.describe(match, ctx) → {label, tone, known} ← the consumer sets this
|
|
15
|
+
*
|
|
16
|
+
* Three ship ready-made: TEMPLATE (`${a.b[0]}`), FORMULA (`[a.b]`) and
|
|
17
|
+
* PARAMETER (`{{name}}`). They are plain objects on purpose: a consumer sets
|
|
18
|
+
* `describe` — and, for a syntax whose paths name steps, `stepSegment` — on
|
|
19
|
+
* the shared object, or makes its own with `createReferenceSyntax`.
|
|
20
|
+
*
|
|
21
|
+
* ══ WHICH SEGMENT NAMES A STEP ═════════════════════════════════════════
|
|
22
|
+
*
|
|
23
|
+
* `rename` rewrites only the segment that names a STEP, and only the consumer
|
|
24
|
+
* knows which one that is — in one grammar it is the segment after a scope
|
|
25
|
+
* word, in another the first. A syntax renames nothing until it is told:
|
|
26
|
+
* `stepScope: 'scope'` says "the segment after `scope`", and `stepSegment:
|
|
27
|
+
* (segments) => index` says anything else. Renaming every segment that happens
|
|
28
|
+
* to equal an old id would rename a column called like a step.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const TEMPLATE_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+|\[\d+\]|\["[^"\\]*"\])*)\}/g;
|
|
32
|
+
const FORMULA_PATTERN = /\[([^[\]\n]+)\]/g;
|
|
33
|
+
const PARAMETER_PATTERN = /\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A path's segments, as `{text, start, end}` over the path: `a.b[0]["c.d"]` →
|
|
37
|
+
* `a`, `b`, `[0]`, `["c.d"]`. A dot inside brackets or quotes does not split.
|
|
38
|
+
*/
|
|
39
|
+
export function pathSegments(path) {
|
|
40
|
+
const out = [];
|
|
41
|
+
let start = 0;
|
|
42
|
+
let depth = 0;
|
|
43
|
+
let quote = false;
|
|
44
|
+
const s = String(path);
|
|
45
|
+
const push = (end) => { if (end > start) out.push({ text: s.slice(start, end), start, end }); };
|
|
46
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
47
|
+
const ch = s[i];
|
|
48
|
+
if (quote) {
|
|
49
|
+
if (ch === '\\') i += 1;
|
|
50
|
+
else if (ch === '"') quote = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === '"' && depth > 0) { quote = true; continue; }
|
|
54
|
+
if (ch === '[') {
|
|
55
|
+
if (depth === 0) { push(i); start = i; }
|
|
56
|
+
depth += 1;
|
|
57
|
+
} else if (ch === ']' && depth > 0) {
|
|
58
|
+
depth -= 1;
|
|
59
|
+
if (depth === 0) { push(i + 1); start = i + 1; }
|
|
60
|
+
} else if (ch === '.' && depth === 0) {
|
|
61
|
+
push(i);
|
|
62
|
+
start = i + 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
push(s.length);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {object} o
|
|
71
|
+
* @param {string} o.name
|
|
72
|
+
* @param {RegExp} o.pattern group 1 is the path; made global if it is not
|
|
73
|
+
* @param {(path: string) => string} o.format
|
|
74
|
+
* @param {(match: object, ctx: object) => {label?: string, tone?: string, known?: boolean}} [o.describe]
|
|
75
|
+
* @param {string|string[]} [o.stepScope] the segment after this word names a step
|
|
76
|
+
* @param {(segments: string[]) => number} [o.stepSegment] which segment names a step, or -1
|
|
77
|
+
* @param {string} [o.tone] its chips' tone when `describe` gives none — two syntaxes in one
|
|
78
|
+
* field are two tones
|
|
79
|
+
*/
|
|
80
|
+
export function createReferenceSyntax({ name, pattern, format, describe = null, stepScope = null,
|
|
81
|
+
stepSegment = null, tone = 'blue' }) {
|
|
82
|
+
if (!(pattern instanceof RegExp)) throw new Error(`Reference syntax "${name}" needs a RegExp pattern.`);
|
|
83
|
+
if (typeof format !== 'function') throw new Error(`Reference syntax "${name}" needs a format(path) function.`);
|
|
84
|
+
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
|
|
85
|
+
const syntax = {
|
|
86
|
+
name: String(name),
|
|
87
|
+
pattern: new RegExp(pattern.source, flags),
|
|
88
|
+
format,
|
|
89
|
+
describe,
|
|
90
|
+
tone,
|
|
91
|
+
stepScope,
|
|
92
|
+
stepSegment,
|
|
93
|
+
|
|
94
|
+
find(text) {
|
|
95
|
+
const out = [];
|
|
96
|
+
if (typeof text !== 'string' || !text) return out;
|
|
97
|
+
const re = new RegExp(syntax.pattern.source, syntax.pattern.flags);
|
|
98
|
+
let m;
|
|
99
|
+
while ((m = re.exec(text)) !== null) {
|
|
100
|
+
if (m[0] === '') { re.lastIndex += 1; continue; }
|
|
101
|
+
out.push({ start: m.index, end: m.index + m[0].length, text: m[0], path: m[1] ?? m[0] });
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/** The index of the segment of `path` that names a step, or -1. */
|
|
107
|
+
stepIndex(path) {
|
|
108
|
+
const segs = pathSegments(path).map((s) => s.text);
|
|
109
|
+
if (typeof syntax.stepSegment === 'function') {
|
|
110
|
+
const i = syntax.stepSegment(segs);
|
|
111
|
+
return Number.isInteger(i) && i >= 0 && i < segs.length ? i : -1;
|
|
112
|
+
}
|
|
113
|
+
const scopes = [].concat(syntax.stepScope ?? []);
|
|
114
|
+
if (!scopes.length) return -1;
|
|
115
|
+
return scopes.includes(segs[0]) && segs.length > 1 ? 1 : -1;
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
rename(text, map) {
|
|
119
|
+
if (typeof text !== 'string' || !map) return text;
|
|
120
|
+
const lookup = map instanceof Map ? map : new Map(Object.entries(map));
|
|
121
|
+
if (!lookup.size) return text;
|
|
122
|
+
const matches = syntax.find(text);
|
|
123
|
+
if (!matches.length) return text;
|
|
124
|
+
let out = '';
|
|
125
|
+
let at = 0;
|
|
126
|
+
for (const m of matches) {
|
|
127
|
+
const i = syntax.stepIndex(m.path);
|
|
128
|
+
if (i < 0) continue;
|
|
129
|
+
const seg = pathSegments(m.path)[i];
|
|
130
|
+
if (!lookup.has(seg.text)) continue;
|
|
131
|
+
const path = m.path.slice(0, seg.start) + lookup.get(seg.text) + m.path.slice(seg.end);
|
|
132
|
+
out += text.slice(at, m.start) + syntax.format(path);
|
|
133
|
+
at = m.end;
|
|
134
|
+
}
|
|
135
|
+
return out + text.slice(at);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
return syntax;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const TEMPLATE_REFERENCES = createReferenceSyntax({
|
|
142
|
+
name: 'template', pattern: TEMPLATE_PATTERN, format: (path) => `\${${path}}`,
|
|
143
|
+
});
|
|
144
|
+
export const FORMULA_REFERENCES = createReferenceSyntax({
|
|
145
|
+
name: 'formula', pattern: FORMULA_PATTERN, format: (path) => `[${path}]`,
|
|
146
|
+
});
|
|
147
|
+
export const PARAMETER_REFERENCES = createReferenceSyntax({
|
|
148
|
+
name: 'parameter', pattern: PARAMETER_PATTERN, format: (name) => `{{${name}}}`, tone: 'violet',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every reference of every syntax in `text`, as non-overlapping spans in text
|
|
153
|
+
* order, each carrying its `syntax`. Where two overlap, the one that starts
|
|
154
|
+
* first wins, then the longer, then the syntax listed first.
|
|
155
|
+
*/
|
|
156
|
+
export function findReferences(text, syntaxes = []) {
|
|
157
|
+
const all = [];
|
|
158
|
+
syntaxes.forEach((syntax, rank) => {
|
|
159
|
+
for (const m of syntax?.find?.(text) || []) all.push({ ...m, syntax, rank });
|
|
160
|
+
});
|
|
161
|
+
all.sort((a, b) => (a.start - b.start) || ((b.end - b.start) - (a.end - a.start)) || (a.rank - b.rank));
|
|
162
|
+
const out = [];
|
|
163
|
+
let end = -1;
|
|
164
|
+
for (const m of all) {
|
|
165
|
+
if (m.start < end) continue;
|
|
166
|
+
out.push({ start: m.start, end: m.end, text: m.text, path: m.path, syntax: m.syntax });
|
|
167
|
+
end = m.end;
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `text` cut into `{text}` and `{ref}` parts, in order — what a chip input draws. */
|
|
173
|
+
export function tokenize(text, syntaxes = []) {
|
|
174
|
+
const parts = [];
|
|
175
|
+
let at = 0;
|
|
176
|
+
for (const m of findReferences(text, syntaxes)) {
|
|
177
|
+
if (m.start > at) parts.push({ kind: 'text', text: text.slice(at, m.start) });
|
|
178
|
+
parts.push({ kind: 'ref', text: m.text, match: m });
|
|
179
|
+
at = m.end;
|
|
180
|
+
}
|
|
181
|
+
if (at < String(text ?? '').length) parts.push({ kind: 'text', text: text.slice(at) });
|
|
182
|
+
return parts;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** How a chip reads: the syntax's `describe`, else the path, in the syntax's tone, known. */
|
|
186
|
+
export function describeReference(match, ctx = {}) {
|
|
187
|
+
const d = typeof match?.syntax?.describe === 'function' ? match.syntax.describe(match, ctx) : null;
|
|
188
|
+
return {
|
|
189
|
+
label: d?.label ?? match?.path ?? match?.text ?? '',
|
|
190
|
+
tone: d?.tone ?? match?.syntax?.tone ?? null,
|
|
191
|
+
known: d?.known !== false,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Every syntax name in `names` that `references` (name → syntax) holds, as syntax objects. */
|
|
196
|
+
export function resolveSyntaxes(names, references) {
|
|
197
|
+
const map = references instanceof Map ? references : new Map(Object.entries(references || {}));
|
|
198
|
+
return [].concat(names ?? []).map((n) => map.get(n)).filter(Boolean);
|
|
199
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SETTINGS PANEL — one step's settings, or the flow's own (36 §3.4).
|
|
3
|
+
*
|
|
4
|
+
* The header is the type, the step's name, WHERE it is ("Inside Anything new?
|
|
5
|
+
* › Then"), its description and the line saying how later steps read it; under
|
|
6
|
+
* it, the step's fields, drawn from its schema by the widget registry, with
|
|
7
|
+
* the editor's synthetic fields (`extra`) among them and the consumer's own
|
|
8
|
+
* areas (`slots`) before and after.
|
|
9
|
+
*
|
|
10
|
+
* ══ TWO RULES IT KEEPS ══════════════════════════════════════════════════
|
|
11
|
+
*
|
|
12
|
+
* **A repaint puts focus back** on the rebuilt control of the same field —
|
|
13
|
+
* caret and selection where they were — or, when that field is gone, hands it
|
|
14
|
+
* to the editor (`onFocusLost`) to put on its surface. Never on `<body>`, where
|
|
15
|
+
* an editor's keys never arrive.
|
|
16
|
+
*
|
|
17
|
+
* **A repaint the reader did not ask for rebuilds no field.** Findings are
|
|
18
|
+
* applied to the fields in place (`setFindings`), and the consumer's slots are
|
|
19
|
+
* redrawn on their own (`repaintSlots`) — so a run overlay refreshed every
|
|
20
|
+
* second leaves a name being typed with its control, its caret and its own
|
|
21
|
+
* undo. Only `show()`, and a setting whose change reshapes the form (a reshape,
|
|
22
|
+
* or a key some field's `x-ui-when` reads), rebuild the fields.
|
|
23
|
+
*
|
|
24
|
+
* A refused control is disabled AND SAYS WHY beside it, on the screen — never
|
|
25
|
+
* only on a `title` nobody hovers.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { button, el, icon, toneClass } from './dom.js';
|
|
29
|
+
import { createStrings, say } from './strings.js';
|
|
30
|
+
import { fieldsFromSchema, visibleFields, whenKeys } from './settings_schema.js';
|
|
31
|
+
import { renderField } from './widgets.js';
|
|
32
|
+
import { matchFindingField } from './findings.js';
|
|
33
|
+
import { openValuePicker } from './value_picker.js';
|
|
34
|
+
import { ownsUndo } from './keys.js';
|
|
35
|
+
|
|
36
|
+
const TITLE = 'data-twm-flow-title';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} o
|
|
40
|
+
* @param {object} o.widgets `createWidgetRegistry()`'s
|
|
41
|
+
* @param {object} [o.references] syntax name → syntax
|
|
42
|
+
* @param {object} [o.strings]
|
|
43
|
+
* @param {object} [o.services] the consumer's bag, handed to every widget untouched
|
|
44
|
+
* @param {false|true|{reason: string}} [o.readOnly]
|
|
45
|
+
* @param {(stepId: string|null, key: string, value: any) => void} [o.onChange]
|
|
46
|
+
* @param {(stepId: string|null, label: string) => void} [o.onRename] the title, typed
|
|
47
|
+
* @param {() => void} [o.onFocusLost] a repaint took the focused control away
|
|
48
|
+
* @param {(q: {stepId, step, field, key}) => Promise<object[]|{groups, note}>} [o.values]
|
|
49
|
+
* *Insert a value*'s groups for one field; absent, no field offers the picker
|
|
50
|
+
* @param {string} [o.valuesNote] the picker's footer sentence
|
|
51
|
+
* @param {(step: object) => object[]|null} [o.columns] a step's input columns (a data flow)
|
|
52
|
+
* @param {HTMLElement} [o.host] appended here when given
|
|
53
|
+
*/
|
|
54
|
+
export function createSettingsPanel({ widgets, references = {}, strings = null, services = null, readOnly = false,
|
|
55
|
+
onChange = null, onRename = null, onFocusLost = null, values = null,
|
|
56
|
+
valuesNote = null, columns = null, host = null } = {}) {
|
|
57
|
+
if (!widgets?.get) throw new Error('createSettingsPanel needs a widget registry (createWidgetRegistry()).');
|
|
58
|
+
const S = createStrings(strings);
|
|
59
|
+
const root = el('section', 'twm-flow-panel');
|
|
60
|
+
root.setAttribute('role', 'region');
|
|
61
|
+
root.setAttribute('aria-label', say(S, 'settings'));
|
|
62
|
+
if (host) host.appendChild(root);
|
|
63
|
+
|
|
64
|
+
let ro = readOnly;
|
|
65
|
+
let spec = null;
|
|
66
|
+
let current = {};
|
|
67
|
+
let fields = []; // the rendered fields, in order
|
|
68
|
+
let allFields = []; // the schema's fields, before x-ui-when
|
|
69
|
+
let findings = [];
|
|
70
|
+
let findingsBox = null;
|
|
71
|
+
let slotBefore = null;
|
|
72
|
+
let slotAfter = null;
|
|
73
|
+
let destroyed = false;
|
|
74
|
+
|
|
75
|
+
const stepId = () => spec?.step?.id ?? null;
|
|
76
|
+
const isReadOnly = () => Boolean(ro);
|
|
77
|
+
const readOnlyReason = () => (ro && typeof ro === 'object' && ro.reason ? String(ro.reason) : '');
|
|
78
|
+
|
|
79
|
+
function pickerFor() {
|
|
80
|
+
if (!values) return null;
|
|
81
|
+
return async ({ anchor, insert, path, key }) => {
|
|
82
|
+
const answer = await values({ stepId: stepId(), step: spec?.step ?? null, field: path, key });
|
|
83
|
+
const groups = Array.isArray(answer) ? answer : (answer?.groups || []);
|
|
84
|
+
const note = Array.isArray(answer) ? valuesNote : (answer?.note ?? valuesNote);
|
|
85
|
+
if (destroyed || !anchor.isConnected) return;
|
|
86
|
+
openValuePicker({ anchor, groups, note, strings: S, onPick: (item) => insert(item.ref) });
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function setValue(key, v, opts = {}) {
|
|
91
|
+
if (v === undefined) delete current[key]; else current[key] = v;
|
|
92
|
+
onChange?.(stepId(), key, v);
|
|
93
|
+
if (opts?.reshape || whenKeys(allFields).has(key)) repaint();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fieldCtx() {
|
|
97
|
+
return {
|
|
98
|
+
widgets, referenceMap: references, strings: S, services, readOnly: isReadOnly(),
|
|
99
|
+
step: spec?.step ?? null, type: spec?.type ?? null, path: '',
|
|
100
|
+
openValuePicker: pickerFor(),
|
|
101
|
+
columns: columns ? () => columns(spec?.step ?? null) : () => null,
|
|
102
|
+
describeContext: { step: spec?.step ?? null, type: spec?.type ?? null, services },
|
|
103
|
+
set: setValue,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function paintHeader() {
|
|
108
|
+
const head = el('header', 'twm-flow-panel__head');
|
|
109
|
+
const chip = el('span', `twm-flow-panel__icon ${toneClass('twm-flow-panel__icon', spec.tone)}`);
|
|
110
|
+
chip.appendChild(icon(spec.icon || ''));
|
|
111
|
+
chip.setAttribute('aria-hidden', 'true');
|
|
112
|
+
const titles = el('div', 'twm-flow-panel__titles');
|
|
113
|
+
if (spec.typeLabel) titles.appendChild(el('span', 'twm-flow-panel__type', spec.typeLabel));
|
|
114
|
+
if (spec.rename && spec.step) {
|
|
115
|
+
const input = el('input', 'twm-flow-panel__title-input');
|
|
116
|
+
input.type = 'text';
|
|
117
|
+
input.value = spec.title ?? '';
|
|
118
|
+
input.placeholder = spec.placeholderTitle ?? spec.typeLabel ?? '';
|
|
119
|
+
input.setAttribute('aria-label', say(S, 'rename'));
|
|
120
|
+
input.setAttribute(TITLE, '');
|
|
121
|
+
input.readOnly = isReadOnly();
|
|
122
|
+
input.addEventListener('input', () => {
|
|
123
|
+
// What was typed is the title now: a repaint the reader asks for
|
|
124
|
+
// next (a reshape) must not draw the old one back.
|
|
125
|
+
spec.title = input.value;
|
|
126
|
+
onRename?.(stepId(), input.value);
|
|
127
|
+
});
|
|
128
|
+
titles.appendChild(input);
|
|
129
|
+
} else {
|
|
130
|
+
titles.appendChild(el('h3', 'twm-flow-panel__title', spec.title ?? ''));
|
|
131
|
+
}
|
|
132
|
+
if (spec.where) titles.appendChild(el('span', 'twm-flow-panel__where', spec.where));
|
|
133
|
+
head.append(chip, titles);
|
|
134
|
+
const acts = Array.isArray(spec.actions) ? spec.actions : [];
|
|
135
|
+
if (acts.length) {
|
|
136
|
+
const box = el('div', 'twm-flow-panel__actions');
|
|
137
|
+
for (const a of acts) {
|
|
138
|
+
const refusal = isReadOnly() ? (readOnlyReason() || say(S, 'readOnly')) : (a.disabled ? a.reason || '' : '');
|
|
139
|
+
const b = button({ label: a.label, icon: a.icon || null, danger: Boolean(a.danger),
|
|
140
|
+
primary: Boolean(a.primary), className: 'twm-flow-panel__action',
|
|
141
|
+
onClick: () => { if (!b.disabled) a.run?.(); } });
|
|
142
|
+
b.dataset.action = a.id ?? '';
|
|
143
|
+
b.disabled = Boolean(isReadOnly() || a.disabled);
|
|
144
|
+
const wrap = el('span', 'twm-flow-panel__action-wrap');
|
|
145
|
+
wrap.appendChild(b);
|
|
146
|
+
if (b.disabled && refusal && !isReadOnly()) wrap.appendChild(el('span', 'twm-flow-panel__refusal', refusal));
|
|
147
|
+
box.appendChild(wrap);
|
|
148
|
+
}
|
|
149
|
+
head.appendChild(box);
|
|
150
|
+
}
|
|
151
|
+
return head;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function paintSlots() {
|
|
155
|
+
for (const [box, name] of [[slotBefore, 'before'], [slotAfter, 'after']]) {
|
|
156
|
+
if (!box) continue;
|
|
157
|
+
box.replaceChildren();
|
|
158
|
+
const fn = spec?.slots?.[name];
|
|
159
|
+
if (typeof fn === 'function') fn(box);
|
|
160
|
+
box.hidden = box.childNodes.length === 0;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function paint() {
|
|
165
|
+
for (const f of fields) f.destroy?.();
|
|
166
|
+
fields = [];
|
|
167
|
+
root.replaceChildren();
|
|
168
|
+
if (!spec) return;
|
|
169
|
+
if (isReadOnly() && readOnlyReason()) root.appendChild(el('p', 'twm-flow-panel__readonly', readOnlyReason()));
|
|
170
|
+
root.appendChild(paintHeader());
|
|
171
|
+
if (spec.description) root.appendChild(el('p', 'twm-flow-panel__description', spec.description));
|
|
172
|
+
if (spec.idLine) root.appendChild(el('div', 'twm-flow-panel__id', spec.idLine));
|
|
173
|
+
findingsBox = el('ul', 'twm-flow-panel__findings');
|
|
174
|
+
findingsBox.hidden = true;
|
|
175
|
+
root.appendChild(findingsBox);
|
|
176
|
+
slotBefore = el('div', 'twm-flow-panel__slot');
|
|
177
|
+
slotBefore.dataset.slot = 'before';
|
|
178
|
+
root.appendChild(slotBefore);
|
|
179
|
+
|
|
180
|
+
const list = el('div', 'twm-flow-panel__fields');
|
|
181
|
+
allFields = Array.isArray(spec.fields) ? spec.fields : fieldsFromSchema(spec.type?.config_schema);
|
|
182
|
+
const drawn = visibleFields(allFields, current);
|
|
183
|
+
const extra = Array.isArray(spec.extra) ? spec.extra : [];
|
|
184
|
+
const placed = (e) => e.after ?? e.before ?? null;
|
|
185
|
+
const extrasAt = (key, where) => extra.filter((e) => (where === 'after' ? e.after === key : e.before === key));
|
|
186
|
+
const drawExtra = (e) => {
|
|
187
|
+
if (e.note !== undefined) {
|
|
188
|
+
list.appendChild(el('p', `twm-flow-panel__note${e.tone ? ` twm-flow-panel__note--${e.tone === 'warning' ? 'warning' : 'info'}` : ''}`, e.note));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const f = renderField({ key: e.key, spec: e.spec || {}, required: Boolean(e.required) }, e.value, {
|
|
192
|
+
...fieldCtx(),
|
|
193
|
+
set: (_k, v, opts) => {
|
|
194
|
+
e.set?.(v, opts);
|
|
195
|
+
if (opts?.reshape) repaint();
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
f.el.dataset.extra = '';
|
|
199
|
+
fields.push(f);
|
|
200
|
+
list.appendChild(f.el);
|
|
201
|
+
};
|
|
202
|
+
for (const e of extra.filter((x) => x.at === 'start')) drawExtra(e);
|
|
203
|
+
for (const f of drawn) {
|
|
204
|
+
for (const e of extrasAt(f.key, 'before')) drawExtra(e);
|
|
205
|
+
const field = renderField(f, current[f.key], fieldCtx());
|
|
206
|
+
fields.push(field);
|
|
207
|
+
list.appendChild(field.el);
|
|
208
|
+
for (const e of extrasAt(f.key, 'after')) drawExtra(e);
|
|
209
|
+
}
|
|
210
|
+
const keys = new Set(drawn.map((f) => f.key));
|
|
211
|
+
for (const e of extra) {
|
|
212
|
+
if (e.at === 'start') continue;
|
|
213
|
+
const anchor = placed(e);
|
|
214
|
+
if (anchor === null || !keys.has(anchor)) drawExtra(e);
|
|
215
|
+
}
|
|
216
|
+
root.appendChild(list);
|
|
217
|
+
slotAfter = el('div', 'twm-flow-panel__slot');
|
|
218
|
+
slotAfter.dataset.slot = 'after';
|
|
219
|
+
root.appendChild(slotAfter);
|
|
220
|
+
paintSlots();
|
|
221
|
+
applyFindings();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Where focus is in the panel, so a repaint can put it back. */
|
|
225
|
+
function heldFocus() {
|
|
226
|
+
const active = root.ownerDocument.activeElement;
|
|
227
|
+
if (!active || active === root || !root.contains(active)) return null;
|
|
228
|
+
if (active.hasAttribute?.(TITLE)) {
|
|
229
|
+
return { title: true, caret: { start: active.selectionStart, end: active.selectionEnd } };
|
|
230
|
+
}
|
|
231
|
+
const path = active.closest('[data-field]')?.dataset.field ?? null;
|
|
232
|
+
const field = path ? fields.map((f) => f.fieldAt(path)).find(Boolean) : null;
|
|
233
|
+
let caret = field?.caret?.() ?? null;
|
|
234
|
+
if (!caret && ownsUndo(active) && active.matches?.('input, textarea')) {
|
|
235
|
+
try {
|
|
236
|
+
if (Number.isInteger(active.selectionStart)) {
|
|
237
|
+
caret = { start: active.selectionStart, end: active.selectionEnd, direction: active.selectionDirection };
|
|
238
|
+
}
|
|
239
|
+
} catch { /* a type without a caret */ }
|
|
240
|
+
}
|
|
241
|
+
return { path, caret, tag: active.tagName };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function restoreFocus(held) {
|
|
245
|
+
if (!held || destroyed || !root.isConnected) return;
|
|
246
|
+
if (held.title) {
|
|
247
|
+
const t = root.querySelector(`[${TITLE}]`);
|
|
248
|
+
if (t) {
|
|
249
|
+
t.focus({ preventScroll: true });
|
|
250
|
+
try { t.setSelectionRange(held.caret.start, held.caret.end); } catch { /* no caret */ }
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const field = held.path ? fields.map((f) => f.fieldAt(held.path)).find(Boolean) : null;
|
|
255
|
+
if (field && field.focus()) {
|
|
256
|
+
if (held.caret) {
|
|
257
|
+
if (field.control?.restoreCaret) field.restoreCaret(held.caret);
|
|
258
|
+
else {
|
|
259
|
+
const box = root.ownerDocument.activeElement;
|
|
260
|
+
try {
|
|
261
|
+
const end = box.value.length;
|
|
262
|
+
box.setSelectionRange(Math.min(held.caret.start, end), Math.min(held.caret.end, end),
|
|
263
|
+
held.caret.direction || 'none');
|
|
264
|
+
} catch { /* no caret */ }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
onFocusLost?.();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function repaint() {
|
|
273
|
+
const held = heldFocus();
|
|
274
|
+
paint();
|
|
275
|
+
restoreFocus(held);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function applyFindings() {
|
|
279
|
+
if (!spec || !findingsBox) return;
|
|
280
|
+
const mine = findings.filter((f) => (f.node_id ?? null) === stepId());
|
|
281
|
+
const paths = fields.flatMap((f) => f.paths());
|
|
282
|
+
const byPath = new Map();
|
|
283
|
+
const loose = [];
|
|
284
|
+
for (const f of mine) {
|
|
285
|
+
const hit = matchFindingField(paths, f.field);
|
|
286
|
+
if (hit) {
|
|
287
|
+
if (!byPath.has(hit)) byPath.set(hit, []);
|
|
288
|
+
byPath.get(hit).push(f);
|
|
289
|
+
} else loose.push(f);
|
|
290
|
+
}
|
|
291
|
+
for (const field of fields) {
|
|
292
|
+
for (const p of field.paths()) {
|
|
293
|
+
const target = field.fieldAt(p);
|
|
294
|
+
const list = byPath.get(p) || [];
|
|
295
|
+
const errors = list.filter((f) => f.severity !== 'warning');
|
|
296
|
+
target.setError(list.map((f) => f.message).join(' '), errors.length ? 'error' : 'warning');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
findingsBox.replaceChildren();
|
|
300
|
+
for (const f of loose) {
|
|
301
|
+
const li = el('li', `twm-flow-panel__finding${f.severity === 'warning' ? ' twm-flow-panel__finding--warning' : ''}`);
|
|
302
|
+
li.append(icon(f.severity === 'warning' ? 'warning' : 'error', 'twm-flow-panel__finding-icon'),
|
|
303
|
+
el('span', 'twm-flow-panel__finding-text', String(f.message ?? '')));
|
|
304
|
+
findingsBox.appendChild(li);
|
|
305
|
+
}
|
|
306
|
+
findingsBox.hidden = loose.length === 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
el: root,
|
|
311
|
+
/** Draw a step's (or, with no `step`, the flow's) settings. */
|
|
312
|
+
show(next) {
|
|
313
|
+
const sameStep = spec && next && (spec.step?.id ?? null) === (next.step?.id ?? null);
|
|
314
|
+
const held = heldFocus();
|
|
315
|
+
spec = { ...next };
|
|
316
|
+
current = { ...(next.value ?? next.step?.config ?? {}) };
|
|
317
|
+
paint();
|
|
318
|
+
if (held && sameStep) restoreFocus(held);
|
|
319
|
+
else if (held) onFocusLost?.();
|
|
320
|
+
},
|
|
321
|
+
/** Empty the panel. */
|
|
322
|
+
clear() {
|
|
323
|
+
const held = heldFocus();
|
|
324
|
+
spec = null;
|
|
325
|
+
current = {};
|
|
326
|
+
paint();
|
|
327
|
+
if (held) onFocusLost?.();
|
|
328
|
+
},
|
|
329
|
+
get stepId() { return stepId(); },
|
|
330
|
+
get value() { return { ...current }; },
|
|
331
|
+
setFindings(list) {
|
|
332
|
+
findings = Array.isArray(list) ? list.filter((f) => f && typeof f === 'object')
|
|
333
|
+
: [...(list?.errors || []), ...(list?.warnings || [])];
|
|
334
|
+
applyFindings();
|
|
335
|
+
},
|
|
336
|
+
/** Focus the field a finding's `field` names (the longest prefix); false when none. */
|
|
337
|
+
focusField(path) {
|
|
338
|
+
const hit = matchFindingField(fields.flatMap((f) => f.paths()), path);
|
|
339
|
+
const field = hit ? fields.map((f) => f.fieldAt(hit)).find(Boolean) : null;
|
|
340
|
+
if (!field) return false;
|
|
341
|
+
field.el.scrollIntoView?.({ block: 'nearest' });
|
|
342
|
+
return field.focus();
|
|
343
|
+
},
|
|
344
|
+
/** Redraw the consumer's slots only — no field is rebuilt. */
|
|
345
|
+
repaintSlots() { paintSlots(); },
|
|
346
|
+
setReadOnly(next) {
|
|
347
|
+
ro = next;
|
|
348
|
+
repaint();
|
|
349
|
+
},
|
|
350
|
+
destroy() {
|
|
351
|
+
destroyed = true;
|
|
352
|
+
for (const f of fields) f.destroy?.();
|
|
353
|
+
fields = [];
|
|
354
|
+
root.remove();
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|