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.
- package/README.md +1036 -301
- package/css/base.css +2958 -2
- package/css/flexdesk.css +2962 -4
- package/css/overrides.css +4 -2
- package/css/tokens.css +86 -0
- package/dist/canvas.js +390 -0
- package/dist/canvas.js.map +7 -0
- package/dist/charts.js +782 -782
- package/dist/{chunk-LH5TSOZW.js → chunk-6JSOVNID.js} +42 -2
- package/dist/{chunk-LH5TSOZW.js.map → chunk-6JSOVNID.js.map} +3 -3
- package/dist/{chunk-ELXVW542.js → chunk-6NG7YVBG.js} +3 -471
- package/dist/chunk-6NG7YVBG.js.map +7 -0
- package/dist/{chunk-F5FYDCOA.js → chunk-CNXP3SIN.js} +2373 -2373
- package/dist/{chunk-F5FYDCOA.js.map → chunk-CNXP3SIN.js.map} +7 -7
- package/dist/chunk-H4LT43RS.js +475 -0
- package/dist/chunk-H4LT43RS.js.map +7 -0
- package/dist/{chunk-WMGOJ3I6.js → chunk-KH2GJTKR.js} +3702 -2628
- package/dist/chunk-KH2GJTKR.js.map +7 -0
- package/dist/chunk-SN4LSOAR.js +532 -0
- package/dist/chunk-SN4LSOAR.js.map +7 -0
- package/dist/flexdesk.css +2962 -4
- package/dist/flow.js +10030 -0
- package/dist/flow.js.map +7 -0
- package/dist/tiles.js +2195 -2195
- package/dist/tokens.css +86 -0
- package/dist/widgets.js +5824 -5796
- package/dist/widgets.js.map +1 -1
- package/dist/wm.js +8077 -8074
- package/dist/wm.js.map +7 -7
- package/package.json +89 -81
- 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/tiling/landing_table.js +13 -5
- package/src/tiling/wm.js +3916 -3916
- package/src/ui/components/data_table.js +4205 -3294
- package/src/ui/components/row_activation.js +70 -0
- package/src/ui/components/table_values.js +423 -0
- package/dist/chunk-ELXVW542.js.map +0 -7
- package/dist/chunk-WMGOJ3I6.js.map +0 -7
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CHIP INPUT — text in which references are drawn as chips (36 §3.6).
|
|
3
|
+
* Every chip-bearing widget is built on this one control.
|
|
4
|
+
*
|
|
5
|
+
* ══ CHIPS ARE A VIEW OF TEXT ═══════════════════════════════════════════
|
|
6
|
+
*
|
|
7
|
+
* The stored value is ALWAYS the plain text: a chip is drawn for each
|
|
8
|
+
* reference the field's syntaxes recognise, and it carries the reference's
|
|
9
|
+
* exact text in `data-ref`; reading the field serialises the text nodes plus
|
|
10
|
+
* each chip's `data-ref`. Nothing about a chip — its label, its tone — is ever
|
|
11
|
+
* written back. A value nobody edits is never re-serialised at all, so a field
|
|
12
|
+
* opened and closed hands back exactly what it was given.
|
|
13
|
+
*
|
|
14
|
+
* ══ HOW IT EDITS ═══════════════════════════════════════════════════════
|
|
15
|
+
*
|
|
16
|
+
* The host is a `contenteditable` and each chip a `contenteditable="false"`
|
|
17
|
+
* span, which the browser treats as one unit. Typing is the browser's (so an
|
|
18
|
+
* IME and dead keys work), and after every input the text is read back and
|
|
19
|
+
* RE-TOKENISED: when the text now holds a reference the DOM does not draw as a
|
|
20
|
+
* chip (one was just typed or pasted), or a chip the text no longer holds, the
|
|
21
|
+
* field is redrawn and the caret put back at the same TEXT offset. The rest is
|
|
22
|
+
* done on the text and redrawn, never left to the browser:
|
|
23
|
+
*
|
|
24
|
+
* - ← and → step over a chip as one unit;
|
|
25
|
+
* - one Backspace (or Delete) removes a whole chip;
|
|
26
|
+
* - a paste is plain text — never markup — and a reference in it becomes a chip;
|
|
27
|
+
* - a copy or a cut puts the exact TEXT on the clipboard, references included;
|
|
28
|
+
* - Enter adds a line break in a multi-line field and nothing in a single-line one;
|
|
29
|
+
* - formatting (Ctrl+B and the like) and a drop are refused;
|
|
30
|
+
* - nothing is touched during an IME composition, until `compositionend`.
|
|
31
|
+
*
|
|
32
|
+
* ══ ITS OWN UNDO ═══════════════════════════════════════════════════════
|
|
33
|
+
*
|
|
34
|
+
* A script that rebuilds a `contenteditable` breaks the browser's undo for it,
|
|
35
|
+
* so the field keeps its own: Ctrl+Z / Ctrl+Y (and the browser's Edit menu,
|
|
36
|
+
* through `beforeinput`) walk the field's own texts, typing merged into one
|
|
37
|
+
* step. An editor's key binding leaves Ctrl+Z to a `contenteditable` (`ownsUndo`),
|
|
38
|
+
* so an undo in the field is the field's and an undo outside it the editor's.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { el, toneClass } from './dom.js';
|
|
42
|
+
import { describeReference, findReferences, tokenize } from './references.js';
|
|
43
|
+
|
|
44
|
+
const MERGE_MS = 1000;
|
|
45
|
+
const TAIL = 'data-twm-tail';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} o
|
|
49
|
+
* @param {string} [o.value] the text
|
|
50
|
+
* @param {object[]} [o.syntaxes] reference syntaxes (references.js)
|
|
51
|
+
* @param {boolean} [o.readOnly]
|
|
52
|
+
* @param {(text: string) => void} [o.onInput] after every change the READER made
|
|
53
|
+
* @param {boolean} [o.multiline] Enter adds a line break
|
|
54
|
+
* @param {string} [o.placeholder]
|
|
55
|
+
* @param {string} [o.label] the accessible name
|
|
56
|
+
* @param {boolean} [o.mono] a monospace face (formulas, JSON)
|
|
57
|
+
* @param {object} [o.describeContext] handed to each syntax's `describe`
|
|
58
|
+
*/
|
|
59
|
+
export function createChipInput({ value = '', syntaxes = [], readOnly = false, onInput = null, multiline = false,
|
|
60
|
+
placeholder = '', label = '', mono = false, describeContext = null } = {}) {
|
|
61
|
+
const host = el('div', `twm-flow-chipinput${mono ? ' twm-flow-chipinput--mono' : ''}`
|
|
62
|
+
+ `${multiline ? ' twm-flow-chipinput--multiline' : ''}`);
|
|
63
|
+
host.setAttribute('role', 'textbox');
|
|
64
|
+
host.setAttribute('aria-multiline', multiline ? 'true' : 'false');
|
|
65
|
+
if (label) host.setAttribute('aria-label', label);
|
|
66
|
+
if (placeholder) {
|
|
67
|
+
host.dataset.placeholder = placeholder;
|
|
68
|
+
host.setAttribute('aria-placeholder', placeholder);
|
|
69
|
+
}
|
|
70
|
+
host.spellcheck = false;
|
|
71
|
+
|
|
72
|
+
let text = String(value ?? '');
|
|
73
|
+
let ro = Boolean(readOnly);
|
|
74
|
+
let composing = false;
|
|
75
|
+
let lastCaret = null;
|
|
76
|
+
let destroyed = false;
|
|
77
|
+
// The field's own undo: a list of states and where we are in it.
|
|
78
|
+
let states = [{ text, caret: { start: text.length, end: text.length }, kind: 'load', at: 0 }];
|
|
79
|
+
let index = 0;
|
|
80
|
+
let travelled = true;
|
|
81
|
+
|
|
82
|
+
// ── drawing ────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function chipFor(part) {
|
|
85
|
+
const d = describeReference(part.match, describeContext || {});
|
|
86
|
+
const chip = el('span', ['twm-flow-chip', toneClass('twm-flow-chip', d.tone || 'blue'),
|
|
87
|
+
d.known ? '' : 'twm-flow-chip--unknown'].filter(Boolean).join(' '), d.label);
|
|
88
|
+
chip.contentEditable = 'false';
|
|
89
|
+
chip.setAttribute('contenteditable', 'false');
|
|
90
|
+
chip.setAttribute('draggable', 'false');
|
|
91
|
+
chip.dataset.ref = part.text;
|
|
92
|
+
chip.title = part.text;
|
|
93
|
+
if (part.match?.syntax?.name) chip.dataset.syntax = part.match.syntax.name;
|
|
94
|
+
return chip;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function render() {
|
|
98
|
+
const parts = tokenize(text, syntaxes);
|
|
99
|
+
const nodes = parts.map((p) => (p.kind === 'ref' ? chipFor(p) : document.createTextNode(p.text)));
|
|
100
|
+
if (text.endsWith('\n')) {
|
|
101
|
+
const br = document.createElement('br');
|
|
102
|
+
br.setAttribute(TAIL, '');
|
|
103
|
+
nodes.push(br);
|
|
104
|
+
}
|
|
105
|
+
host.replaceChildren(...nodes);
|
|
106
|
+
paintEmpty();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function paintEmpty() {
|
|
110
|
+
host.classList.toggle('twm-flow-chipinput--empty', text === '');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function setEditable() {
|
|
114
|
+
host.setAttribute('contenteditable', ro ? 'false' : 'true');
|
|
115
|
+
host.setAttribute('aria-readonly', ro ? 'true' : 'false');
|
|
116
|
+
host.tabIndex = 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── reading the DOM back as text ───────────────────────────────────
|
|
120
|
+
|
|
121
|
+
const chipOf = (node) => {
|
|
122
|
+
const e = node?.nodeType === 1 ? node : node?.parentElement;
|
|
123
|
+
const c = e?.closest?.('[data-ref]');
|
|
124
|
+
return c && host.contains(c) ? c : null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** A line the browser made (a `<div>` or `<p>` after something): it starts with a line break. */
|
|
128
|
+
const isLine = (node) => node?.nodeType === 1 && (node.tagName === 'DIV' || node.tagName === 'P')
|
|
129
|
+
&& Boolean(node.previousSibling);
|
|
130
|
+
|
|
131
|
+
/** How many characters of text `node` stands for. */
|
|
132
|
+
function measure(node) {
|
|
133
|
+
if (node.nodeType === 3) return node.data.length;
|
|
134
|
+
if (node.nodeType !== 1) return 0;
|
|
135
|
+
if (node.dataset?.ref !== undefined) return node.dataset.ref.length;
|
|
136
|
+
if (node.tagName === 'BR') return node.hasAttribute(TAIL) ? 0 : 1;
|
|
137
|
+
let n = isLine(node) ? 1 : 0;
|
|
138
|
+
for (const c of node.childNodes) n += measure(c);
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function serialiseNode(node) {
|
|
143
|
+
if (node.nodeType === 3) return node.data;
|
|
144
|
+
if (node.nodeType !== 1) return '';
|
|
145
|
+
if (node.dataset?.ref !== undefined) return node.dataset.ref;
|
|
146
|
+
if (node.tagName === 'BR') return node.hasAttribute(TAIL) ? '' : '\n';
|
|
147
|
+
let s = isLine(node) ? '\n' : '';
|
|
148
|
+
for (const c of node.childNodes) s += serialiseNode(c);
|
|
149
|
+
return s;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const read = () => [...host.childNodes].map(serialiseNode).join('');
|
|
153
|
+
|
|
154
|
+
/** The text offset of a DOM point; a point inside a chip is its `edge`. */
|
|
155
|
+
function offsetOf(container, offset, edge = 'end') {
|
|
156
|
+
if (!host.contains(container)) return null;
|
|
157
|
+
const chip = chipOf(container);
|
|
158
|
+
let node;
|
|
159
|
+
let total = 0;
|
|
160
|
+
if (chip) {
|
|
161
|
+
node = chip;
|
|
162
|
+
if (edge === 'end') total += measure(chip);
|
|
163
|
+
} else if (container.nodeType === 3) {
|
|
164
|
+
total += offset;
|
|
165
|
+
node = container;
|
|
166
|
+
} else {
|
|
167
|
+
const kids = container.childNodes;
|
|
168
|
+
for (let i = 0; i < Math.min(offset, kids.length); i += 1) total += measure(kids[i]);
|
|
169
|
+
if (container === host) return total;
|
|
170
|
+
if (isLine(container)) total += 1;
|
|
171
|
+
node = container;
|
|
172
|
+
}
|
|
173
|
+
while (node && node !== host) {
|
|
174
|
+
for (let s = node.previousSibling; s; s = s.previousSibling) total += measure(s);
|
|
175
|
+
const parent = node.parentNode;
|
|
176
|
+
if (parent && parent !== host && isLine(parent)) total += 1;
|
|
177
|
+
node = parent;
|
|
178
|
+
}
|
|
179
|
+
return total;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The DOM point at text offset `at` in the field as drawn (flat). */
|
|
183
|
+
function locate(at) {
|
|
184
|
+
let acc = 0;
|
|
185
|
+
const kids = [...host.childNodes];
|
|
186
|
+
for (let i = 0; i < kids.length; i += 1) {
|
|
187
|
+
const k = kids[i];
|
|
188
|
+
if (k.nodeType === 1 && k.hasAttribute(TAIL)) return { node: host, offset: i };
|
|
189
|
+
const len = measure(k);
|
|
190
|
+
if (k.nodeType === 3) {
|
|
191
|
+
if (at <= acc + len) return { node: k, offset: Math.max(0, at - acc) };
|
|
192
|
+
} else if (k.dataset?.ref !== undefined) {
|
|
193
|
+
if (at <= acc) return { node: host, offset: i };
|
|
194
|
+
if (at < acc + len) return { node: host, offset: i + 1 };
|
|
195
|
+
} else if (at <= acc) {
|
|
196
|
+
return { node: host, offset: i };
|
|
197
|
+
}
|
|
198
|
+
acc += len;
|
|
199
|
+
}
|
|
200
|
+
return { node: host, offset: kids.length };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The selection as text offsets `{start, end}`, or null when it is not in the field. */
|
|
204
|
+
function selectionOffsets() {
|
|
205
|
+
const sel = host.ownerDocument.getSelection?.();
|
|
206
|
+
if (!sel || sel.rangeCount === 0) return null;
|
|
207
|
+
const r = sel.getRangeAt(0);
|
|
208
|
+
if (!host.contains(r.startContainer) || !host.contains(r.endContainer)) return null;
|
|
209
|
+
const a = offsetOf(r.startContainer, r.startOffset, r.collapsed ? 'end' : 'start');
|
|
210
|
+
const b = r.collapsed ? a : offsetOf(r.endContainer, r.endOffset, 'end');
|
|
211
|
+
if (a === null || b === null) return null;
|
|
212
|
+
return { start: Math.min(a, b), end: Math.max(a, b) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function placeCaret(start, end = start) {
|
|
216
|
+
const len = text.length;
|
|
217
|
+
const s = Math.max(0, Math.min(start, len));
|
|
218
|
+
const e = Math.max(0, Math.min(end, len));
|
|
219
|
+
const sel = host.ownerDocument.getSelection?.();
|
|
220
|
+
if (!sel) return;
|
|
221
|
+
const a = locate(s);
|
|
222
|
+
const b = e === s ? a : locate(e);
|
|
223
|
+
const range = host.ownerDocument.createRange();
|
|
224
|
+
range.setStart(a.node, a.offset);
|
|
225
|
+
range.setEnd(b.node, b.offset);
|
|
226
|
+
sel.removeAllRanges();
|
|
227
|
+
sel.addRange(range);
|
|
228
|
+
lastCaret = { start: s, end: e };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Does the DOM draw `text` the way `tokenize` would? */
|
|
232
|
+
function drawnAsTokens() {
|
|
233
|
+
const want = tokenize(text, syntaxes).map((p) => (p.kind === 'ref' ? `r${p.text}` : `t${p.text}`));
|
|
234
|
+
const have = [];
|
|
235
|
+
for (const k of host.childNodes) {
|
|
236
|
+
if (k.nodeType === 3) {
|
|
237
|
+
if (!k.data) continue;
|
|
238
|
+
if (have.length && have[have.length - 1].startsWith('t')) have[have.length - 1] += k.data;
|
|
239
|
+
else have.push(`t${k.data}`);
|
|
240
|
+
} else if (k.nodeType === 1 && k.dataset?.ref !== undefined) {
|
|
241
|
+
have.push(`r${k.dataset.ref}`);
|
|
242
|
+
} else if (k.nodeType === 1 && k.hasAttribute(TAIL) && k === host.lastChild) {
|
|
243
|
+
continue;
|
|
244
|
+
} else {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const tail = host.lastChild?.nodeType === 1 && host.lastChild.hasAttribute(TAIL);
|
|
249
|
+
if (text.endsWith('\n') !== Boolean(tail)) return false;
|
|
250
|
+
return want.length === have.length && want.every((w, i) => w === have[i]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── changing the text ──────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
function record(kind, caret) {
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
const top = states[index];
|
|
258
|
+
if (kind === 'type' && top.kind === 'type' && !travelled && now - top.at < MERGE_MS
|
|
259
|
+
&& index === states.length - 1) {
|
|
260
|
+
states[index] = { text, caret, kind, at: now };
|
|
261
|
+
} else {
|
|
262
|
+
states = states.slice(0, index + 1);
|
|
263
|
+
states.push({ text, caret, kind, at: now });
|
|
264
|
+
index = states.length - 1;
|
|
265
|
+
}
|
|
266
|
+
travelled = false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function changed(kind, caret) {
|
|
270
|
+
record(kind, caret);
|
|
271
|
+
onInput?.(text);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Replace `[start, end)` of the text with `insert`, redraw, caret after it. */
|
|
275
|
+
function replaceRange(start, end, insert, kind = 'edit') {
|
|
276
|
+
text = text.slice(0, start) + insert + text.slice(end);
|
|
277
|
+
render();
|
|
278
|
+
const at = start + insert.length;
|
|
279
|
+
placeCaret(at);
|
|
280
|
+
changed(kind, { start: at, end: at });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function travel(to) {
|
|
284
|
+
if (to < 0 || to >= states.length) return false;
|
|
285
|
+
index = to;
|
|
286
|
+
travelled = true;
|
|
287
|
+
text = states[index].text;
|
|
288
|
+
render();
|
|
289
|
+
const c = states[index].caret || { start: text.length, end: text.length };
|
|
290
|
+
placeCaret(c.start, c.end);
|
|
291
|
+
onInput?.(text);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function handleInput() {
|
|
296
|
+
if (destroyed || composing) return;
|
|
297
|
+
const caret = selectionOffsets();
|
|
298
|
+
const next = read();
|
|
299
|
+
if (next === text) {
|
|
300
|
+
// Nothing the reader typed, but the browser may have left markup of
|
|
301
|
+
// its own (a stray <br>): draw the text again, record nothing.
|
|
302
|
+
if (!drawnAsTokens()) {
|
|
303
|
+
render();
|
|
304
|
+
if (caret) placeCaret(caret.start, caret.end);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
text = next;
|
|
309
|
+
if (!drawnAsTokens()) {
|
|
310
|
+
render();
|
|
311
|
+
if (caret) placeCaret(caret.start, caret.end);
|
|
312
|
+
} else {
|
|
313
|
+
paintEmpty();
|
|
314
|
+
}
|
|
315
|
+
changed('type', caret || { start: text.length, end: text.length });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const chipEndingAt = (at) => findReferences(text, syntaxes).find((r) => r.end === at) || null;
|
|
319
|
+
const chipStartingAt = (at) => findReferences(text, syntaxes).find((r) => r.start === at) || null;
|
|
320
|
+
|
|
321
|
+
// ── events ─────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
function onKeyDown(ev) {
|
|
324
|
+
if (destroyed || ev.isComposing || composing || ev.keyCode === 229) return;
|
|
325
|
+
const mod = (ev.ctrlKey || ev.metaKey) && !ev.altKey;
|
|
326
|
+
const k = String(ev.key || '');
|
|
327
|
+
if (mod && (k.toLowerCase() === 'z' || k.toLowerCase() === 'y')) {
|
|
328
|
+
ev.preventDefault();
|
|
329
|
+
if (ro) return;
|
|
330
|
+
if (k.toLowerCase() === 'y' || ev.shiftKey) travel(index + 1); else travel(index - 1);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (mod && ['b', 'i', 'u'].includes(k.toLowerCase())) { ev.preventDefault(); return; }
|
|
334
|
+
if (ro) return;
|
|
335
|
+
const sel = selectionOffsets();
|
|
336
|
+
if (!sel) return;
|
|
337
|
+
const collapsed = sel.start === sel.end;
|
|
338
|
+
if (k === 'Enter') {
|
|
339
|
+
ev.preventDefault();
|
|
340
|
+
if (multiline && !mod) replaceRange(sel.start, sel.end, '\n');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (k === 'Backspace' || k === 'Delete') {
|
|
344
|
+
if (!collapsed) { ev.preventDefault(); replaceRange(sel.start, sel.end, ''); return; }
|
|
345
|
+
const chip = k === 'Backspace' ? chipEndingAt(sel.start) : chipStartingAt(sel.start);
|
|
346
|
+
if (chip) { ev.preventDefault(); replaceRange(chip.start, chip.end, ''); }
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if ((k === 'ArrowLeft' || k === 'ArrowRight') && !ev.shiftKey && !mod && !ev.altKey && collapsed) {
|
|
350
|
+
const chip = k === 'ArrowLeft' ? chipEndingAt(sel.start) : chipStartingAt(sel.start);
|
|
351
|
+
if (chip) {
|
|
352
|
+
ev.preventDefault();
|
|
353
|
+
placeCaret(k === 'ArrowLeft' ? chip.start : chip.end);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function onBeforeInput(ev) {
|
|
359
|
+
if (destroyed) return;
|
|
360
|
+
const t = String(ev.inputType || '');
|
|
361
|
+
if (t === 'historyUndo' || t === 'historyRedo') {
|
|
362
|
+
ev.preventDefault();
|
|
363
|
+
if (!ro) travel(t === 'historyUndo' ? index - 1 : index + 1);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (t.startsWith('format') || t === 'insertFromDrop' || t === 'deleteByDrag') { ev.preventDefault(); return; }
|
|
367
|
+
if ((t === 'insertParagraph' || t === 'insertLineBreak') && !composing) {
|
|
368
|
+
ev.preventDefault();
|
|
369
|
+
const sel = selectionOffsets();
|
|
370
|
+
if (multiline && sel && !ro) replaceRange(sel.start, sel.end, '\n');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function onPaste(ev) {
|
|
375
|
+
if (destroyed) return;
|
|
376
|
+
ev.preventDefault();
|
|
377
|
+
if (ro) return;
|
|
378
|
+
let pasted = ev.clipboardData?.getData?.('text/plain') ?? '';
|
|
379
|
+
pasted = pasted.replace(/\r\n?/g, '\n');
|
|
380
|
+
if (!multiline) pasted = pasted.replace(/\n+/g, ' ');
|
|
381
|
+
const sel = selectionOffsets() || { start: text.length, end: text.length };
|
|
382
|
+
replaceRange(sel.start, sel.end, pasted, 'paste');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function onCopy(ev, cut = false) {
|
|
386
|
+
const sel = selectionOffsets();
|
|
387
|
+
if (!sel || sel.start === sel.end) return;
|
|
388
|
+
ev.preventDefault();
|
|
389
|
+
ev.clipboardData?.setData?.('text/plain', text.slice(sel.start, sel.end));
|
|
390
|
+
if (cut && !ro) replaceRange(sel.start, sel.end, '', 'cut');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const onSelection = () => {
|
|
394
|
+
const s = selectionOffsets();
|
|
395
|
+
if (s) lastCaret = s;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
host.addEventListener('keydown', onKeyDown);
|
|
399
|
+
host.addEventListener('beforeinput', onBeforeInput);
|
|
400
|
+
host.addEventListener('input', handleInput);
|
|
401
|
+
host.addEventListener('paste', onPaste);
|
|
402
|
+
host.addEventListener('copy', (ev) => onCopy(ev, false));
|
|
403
|
+
host.addEventListener('cut', (ev) => onCopy(ev, true));
|
|
404
|
+
host.addEventListener('drop', (ev) => ev.preventDefault());
|
|
405
|
+
host.addEventListener('compositionstart', () => { composing = true; });
|
|
406
|
+
host.addEventListener('compositionend', () => {
|
|
407
|
+
composing = false;
|
|
408
|
+
queueMicrotask(handleInput);
|
|
409
|
+
});
|
|
410
|
+
host.addEventListener('keyup', onSelection);
|
|
411
|
+
host.addEventListener('mouseup', onSelection);
|
|
412
|
+
host.addEventListener('blur', onSelection);
|
|
413
|
+
const doc = host.ownerDocument;
|
|
414
|
+
doc.addEventListener('selectionchange', onSelection);
|
|
415
|
+
|
|
416
|
+
setEditable();
|
|
417
|
+
render();
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
el: host,
|
|
421
|
+
getValue: () => text,
|
|
422
|
+
/** Replace the text from outside (not an edit the reader made: no `onInput`). */
|
|
423
|
+
setValue(next) {
|
|
424
|
+
text = String(next ?? '');
|
|
425
|
+
render();
|
|
426
|
+
states = [{ text, caret: { start: text.length, end: text.length }, kind: 'load', at: 0 }];
|
|
427
|
+
index = 0;
|
|
428
|
+
travelled = true;
|
|
429
|
+
},
|
|
430
|
+
/** Insert `snippet` where the caret is, or was when the field lost focus; else at the end. */
|
|
431
|
+
insert(snippet) {
|
|
432
|
+
if (ro) return;
|
|
433
|
+
const sel = selectionOffsets() || lastCaret || { start: text.length, end: text.length };
|
|
434
|
+
host.focus({ preventScroll: true });
|
|
435
|
+
replaceRange(Math.min(sel.start, text.length), Math.min(sel.end, text.length), String(snippet), 'insert');
|
|
436
|
+
},
|
|
437
|
+
focus() { host.focus({ preventScroll: true }); },
|
|
438
|
+
caret: () => selectionOffsets() || lastCaret,
|
|
439
|
+
restoreCaret(c) {
|
|
440
|
+
if (!c) return;
|
|
441
|
+
host.focus({ preventScroll: true });
|
|
442
|
+
placeCaret(c.start ?? 0, c.end ?? c.start ?? 0);
|
|
443
|
+
},
|
|
444
|
+
setReadOnly(next) {
|
|
445
|
+
ro = Boolean(next);
|
|
446
|
+
setEditable();
|
|
447
|
+
},
|
|
448
|
+
/** Redraw the chips (a consumer's `describe` learned something). */
|
|
449
|
+
refresh() {
|
|
450
|
+
const c = selectionOffsets();
|
|
451
|
+
render();
|
|
452
|
+
if (c) placeCaret(c.start, c.end);
|
|
453
|
+
},
|
|
454
|
+
get canUndo() { return index > 0; },
|
|
455
|
+
get canRedo() { return index < states.length - 1; },
|
|
456
|
+
destroy() {
|
|
457
|
+
destroyed = true;
|
|
458
|
+
doc.removeEventListener('selectionchange', onSelection);
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE KIT'S SMALL DOM HELPERS, AND ITS ONE POPOVER.
|
|
3
|
+
*
|
|
4
|
+
* Everything here builds elements; nothing here reads the network, storage or
|
|
5
|
+
* a consumer's vocabulary. The popover is the step picker's and *Insert a
|
|
6
|
+
* value*'s shell (36 §3.5, §3.7):
|
|
7
|
+
*
|
|
8
|
+
* - it is appended to FlexDesk's modal host (`modalHost()`, C25) or to
|
|
9
|
+
* `document.body` — the same question a modal and a context menu ask, so a
|
|
10
|
+
* consumer spanning two browser windows gets it in the window it set;
|
|
11
|
+
* - it is `position: fixed`, placed under its anchor (above when there is no
|
|
12
|
+
* room below) and CLAMPED to the viewport;
|
|
13
|
+
* - it is dismissed by a press outside it, by Escape, or by `close()`, and
|
|
14
|
+
* **it returns focus to its anchor** when it closes with focus inside it —
|
|
15
|
+
* a picker that drops focus on `<body>` drops it where an editor's keys
|
|
16
|
+
* never arrive — except when focus has just left it by Tab, which the
|
|
17
|
+
* pickers close it for (`'blur'`): that focus is going where it was sent;
|
|
18
|
+
* - there is ONE at a time: opening a second closes the first, and opening one
|
|
19
|
+
* from the anchor that holds the open one closes it (a toggle);
|
|
20
|
+
* - `closeFlowPopovers()` closes whichever is open, which is what an editor's
|
|
21
|
+
* `destroy()` calls so that nothing it opened outlives it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { modalHost } from '../../ui/components/modal.js';
|
|
25
|
+
|
|
26
|
+
/** The category tones (36 §3.1, §3.12); the stylesheet maps each to tokens. */
|
|
27
|
+
export const TONES = Object.freeze(['violet', 'teal', 'amber', 'grey', 'blue', 'indigo']);
|
|
28
|
+
|
|
29
|
+
export function el(tag, className, text) {
|
|
30
|
+
const n = document.createElement(tag);
|
|
31
|
+
if (className) n.className = className;
|
|
32
|
+
if (text !== undefined && text !== null) n.textContent = String(text);
|
|
33
|
+
return n;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A Material Symbols glyph, hidden from assistive technology. */
|
|
37
|
+
export function icon(name, className = '') {
|
|
38
|
+
const g = el('span', `material-symbols-outlined${className ? ` ${className}` : ''}`, name || '');
|
|
39
|
+
g.setAttribute('aria-hidden', 'true');
|
|
40
|
+
return g;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `block--tone`, for a known tone; `block--grey` for anything else. */
|
|
44
|
+
export function toneClass(block, tone) {
|
|
45
|
+
return `${block}--${TONES.includes(tone) ? tone : 'grey'}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The tone a value names, or `grey`. */
|
|
49
|
+
export function toneOf(tone) {
|
|
50
|
+
return TONES.includes(tone) ? tone : 'grey';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* FlexDesk's canonical button (`.twm-btn`). `iconOnly` keeps the label as the
|
|
55
|
+
* accessible name and the tooltip, and draws only the glyph.
|
|
56
|
+
*/
|
|
57
|
+
export function button({ label, icon: glyph = null, primary = false, danger = false, iconOnly = false,
|
|
58
|
+
title = null, className = '', onClick = null, type = 'button' } = {}) {
|
|
59
|
+
const b = el('button', ['twm-btn', primary ? 'twm-btn--primary' : '', danger ? 'twm-btn--danger' : '',
|
|
60
|
+
className].filter(Boolean).join(' '));
|
|
61
|
+
b.type = type;
|
|
62
|
+
if (glyph) b.appendChild(icon(glyph, 'twm-btn__glyph'));
|
|
63
|
+
if (iconOnly) {
|
|
64
|
+
b.setAttribute('aria-label', label);
|
|
65
|
+
b.title = title || label;
|
|
66
|
+
} else {
|
|
67
|
+
b.appendChild(el('span', 'twm-btn__label', label));
|
|
68
|
+
if (title) b.title = title;
|
|
69
|
+
}
|
|
70
|
+
if (onClick) b.addEventListener('click', onClick);
|
|
71
|
+
return b;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let SEQ = 0;
|
|
75
|
+
/** A document-unique id, for `aria-activedescendant` and `aria-labelledby`:
|
|
76
|
+
* `twm-uid-<stem>-<n>` — an id, never a class name. */
|
|
77
|
+
export function uid(stem = 'flow') {
|
|
78
|
+
SEQ += 1;
|
|
79
|
+
return `twm-uid-${stem}-${SEQ}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── the popover ──────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const EDGE = 8;
|
|
85
|
+
const GAP = 4;
|
|
86
|
+
let ACTIVE = null;
|
|
87
|
+
|
|
88
|
+
/** Close whichever kit popover is open. */
|
|
89
|
+
export function closeFlowPopovers() {
|
|
90
|
+
ACTIVE?.close('closed');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The open popover's element, or null — for a test. */
|
|
94
|
+
export function openFlowPopover() {
|
|
95
|
+
return ACTIVE ? ACTIVE.el : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Put `content` in a popover under `anchor`.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} o
|
|
102
|
+
* @param {HTMLElement} o.anchor what opened it; focus returns here
|
|
103
|
+
* @param {HTMLElement} o.content the popover's body
|
|
104
|
+
* @param {string} o.label its accessible name (`role="dialog"`)
|
|
105
|
+
* @param {string} [o.className] an extra block class
|
|
106
|
+
* @param {(reason: string) => void} [o.onClose] after it is gone
|
|
107
|
+
* @param {HTMLElement} [o.focus] what takes focus on open
|
|
108
|
+
* @returns {{el: HTMLElement, close: (reason?: string) => void, reposition: () => void, toggled: boolean}}
|
|
109
|
+
* `toggled` is true when the call only CLOSED the popover its anchor held.
|
|
110
|
+
*/
|
|
111
|
+
export function openPopover({ anchor, content, label, className = '', onClose = null, focus = null }) {
|
|
112
|
+
if (ACTIVE && anchor && ACTIVE.anchor === anchor) {
|
|
113
|
+
ACTIVE.close('toggle');
|
|
114
|
+
return { el: null, close() {}, reposition() {}, toggled: true };
|
|
115
|
+
}
|
|
116
|
+
ACTIVE?.close('replaced');
|
|
117
|
+
|
|
118
|
+
const pop = el('div', `twm-flow-popover${className ? ` ${className}` : ''}`);
|
|
119
|
+
pop.setAttribute('role', 'dialog');
|
|
120
|
+
pop.setAttribute('aria-label', label || '');
|
|
121
|
+
pop.tabIndex = -1;
|
|
122
|
+
pop.appendChild(content);
|
|
123
|
+
(modalHost() || document.body).appendChild(pop);
|
|
124
|
+
|
|
125
|
+
let closed = false;
|
|
126
|
+
const place = () => {
|
|
127
|
+
if (closed) return;
|
|
128
|
+
// The CLIENT width: a page scrollbar is not room to draw in.
|
|
129
|
+
const view = { w: document.documentElement.clientWidth || window.innerWidth || 1024,
|
|
130
|
+
h: document.documentElement.clientHeight || window.innerHeight || 768 };
|
|
131
|
+
const a = anchor?.getBoundingClientRect?.() || { left: 0, top: 0, bottom: 0, right: 0 };
|
|
132
|
+
const box = pop.getBoundingClientRect();
|
|
133
|
+
// Under the anchor, its left edge on the anchor's; an anchor near the
|
|
134
|
+
// right edge gets the popover's RIGHT edge on its own instead.
|
|
135
|
+
let left = a.left;
|
|
136
|
+
if (left + box.width > view.w - EDGE && a.right - box.width >= EDGE) left = a.right - box.width;
|
|
137
|
+
let top = a.bottom + GAP;
|
|
138
|
+
if (top + box.height > view.h - EDGE && a.top - GAP - box.height >= EDGE) top = a.top - GAP - box.height;
|
|
139
|
+
left = Math.max(EDGE, Math.min(left, view.w - box.width - EDGE));
|
|
140
|
+
top = Math.max(EDGE, Math.min(top, view.h - box.height - EDGE));
|
|
141
|
+
pop.style.left = `${Math.round(left)}px`;
|
|
142
|
+
pop.style.top = `${Math.round(top)}px`;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const onPress = (ev) => {
|
|
146
|
+
if (pop.contains(ev.target)) return;
|
|
147
|
+
if (anchor && anchor.contains?.(ev.target)) return; // the anchor's own click toggles
|
|
148
|
+
handle.close('outside');
|
|
149
|
+
};
|
|
150
|
+
const onKey = (ev) => {
|
|
151
|
+
if (ev.key !== 'Escape') return;
|
|
152
|
+
ev.preventDefault();
|
|
153
|
+
ev.stopPropagation();
|
|
154
|
+
handle.close('escape');
|
|
155
|
+
};
|
|
156
|
+
const onResize = () => place();
|
|
157
|
+
|
|
158
|
+
const handle = {
|
|
159
|
+
el: pop,
|
|
160
|
+
anchor,
|
|
161
|
+
toggled: false,
|
|
162
|
+
reposition: place,
|
|
163
|
+
close(reason = 'closed') {
|
|
164
|
+
if (closed) return;
|
|
165
|
+
closed = true;
|
|
166
|
+
const held = pop.contains(document.activeElement) || document.activeElement === document.body;
|
|
167
|
+
document.removeEventListener('pointerdown', onPress, true);
|
|
168
|
+
document.removeEventListener('mousedown', onPress, true);
|
|
169
|
+
pop.removeEventListener('keydown', onKey);
|
|
170
|
+
window.removeEventListener('resize', onResize);
|
|
171
|
+
pop.remove();
|
|
172
|
+
if (ACTIVE === handle) ACTIVE = null;
|
|
173
|
+
// Focus that LEFT the popover (a Tab out of it) is going somewhere the
|
|
174
|
+
// reader sent it; taking it back to the anchor would fight that move.
|
|
175
|
+
if (held && reason !== 'blur' && anchor?.isConnected) anchor.focus?.({ preventScroll: true });
|
|
176
|
+
onClose?.(reason);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
ACTIVE = handle;
|
|
180
|
+
// The press that OPENED it is still being dispatched; listen from the next turn.
|
|
181
|
+
setTimeout(() => {
|
|
182
|
+
if (closed) return;
|
|
183
|
+
document.addEventListener('pointerdown', onPress, true);
|
|
184
|
+
document.addEventListener('mousedown', onPress, true);
|
|
185
|
+
}, 0);
|
|
186
|
+
pop.addEventListener('keydown', onKey);
|
|
187
|
+
window.addEventListener('resize', onResize);
|
|
188
|
+
place();
|
|
189
|
+
(focus || pop).focus?.({ preventScroll: true });
|
|
190
|
+
return handle;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The listbox walk both pickers share: the options that may be chosen, in
|
|
195
|
+
* document order, and the next one from `current` by `step` (no wrap: the
|
|
196
|
+
* first stays first, as a list with a search box above it should).
|
|
197
|
+
*/
|
|
198
|
+
export function nextOption(listbox, current, step) {
|
|
199
|
+
const options = [...listbox.querySelectorAll('[role="option"]')]
|
|
200
|
+
.filter((o) => o.getAttribute('aria-disabled') !== 'true' && !o.hidden && !o.closest('[hidden]'));
|
|
201
|
+
if (!options.length) return null;
|
|
202
|
+
const at = options.indexOf(current);
|
|
203
|
+
if (at < 0) return step > 0 ? options[0] : options[options.length - 1];
|
|
204
|
+
return options[Math.max(0, Math.min(options.length - 1, at + step))];
|
|
205
|
+
}
|