flexdesk 0.8.0 → 0.9.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 +34 -0
- package/css/flexdesk.css +127 -0
- package/css/overrides.css +127 -0
- package/css/tokens.css +2 -0
- package/dist/flexdesk.css +127 -0
- package/dist/tokens.css +2 -0
- package/dist/wm.js +498 -29
- package/dist/wm.js.map +3 -3
- package/package.json +1 -1
- package/src/tiling/command_palette.js +573 -33
- package/src/tiling/shell.js +11 -1
- package/src/tiling/tile_renderer.js +9 -3
- package/src/tiling/wm.js +3 -1
|
@@ -16,6 +16,66 @@
|
|
|
16
16
|
* loads it into the primary tile. The toggles flip the corresponding
|
|
17
17
|
* panel-tile on/off. Typing searches the injected entity catalog
|
|
18
18
|
* (fetched lazily on first open).
|
|
19
|
+
*
|
|
20
|
+
* ══ C39. THE GROUPED LAYOUT — "FIND ANYTHING" ═══════════════════════════
|
|
21
|
+
*
|
|
22
|
+
* An embedder whose palette is its product's main opener wants a box that
|
|
23
|
+
* finds everything and shows nothing it did not ask for. Every option below
|
|
24
|
+
* is OFF unless passed, so the box above is what an embedder that passes
|
|
25
|
+
* none of them still gets:
|
|
26
|
+
*
|
|
27
|
+
* ┌─────────────────────────────────────────┐
|
|
28
|
+
* │ 🔍 [scope ×] search… Esc │
|
|
29
|
+
* ├─────────────────────────────────────────┤
|
|
30
|
+
* │ TABLES │ a header per group, groups
|
|
31
|
+
* │ ▦ Cust·omers Sales ↵ open Tab rows │ ordered by their best hit;
|
|
32
|
+
* │ ▦ Cust·omer contacts Table │ matched characters marked
|
|
33
|
+
* │ ⌄ Show all 9 tables │ the cap's way out
|
|
34
|
+
* ├─────────────────────────────────────────┤
|
|
35
|
+
* │ ↑↓ move ↵ open note │
|
|
36
|
+
* └─────────────────────────────────────────┘
|
|
37
|
+
*
|
|
38
|
+
* chrome `{ pages: false, panels: false }` omits the chip strip and
|
|
39
|
+
* the panel toggles.
|
|
40
|
+
* layout `'grouped'` turns on the layout above: groups, the tiered
|
|
41
|
+
* scorer with marked matches, the taxonomy's word for a kind
|
|
42
|
+
* instead of the kind's id, key hints on the active row.
|
|
43
|
+
* groupCap rows per group before *Show all N* (grouped, unscoped; 4).
|
|
44
|
+
* empty `() => rows | Promise<rows>` — what the box shows before
|
|
45
|
+
* anything is typed, under `emptyLabel` ('Recent').
|
|
46
|
+
* commands `() => rows` — the embedder's commands, beside the built-in
|
|
47
|
+
* ones. A row with `action` runs it; one without is handed to
|
|
48
|
+
* `onPick`. In the grouped layout they compete only once
|
|
49
|
+
* `commandMinChars` (2) are typed, and `>` restricts to them.
|
|
50
|
+
* drill `(pick) => scope | null` — Tab on a row enters a scope:
|
|
51
|
+
* `{ id?, label, icon?, placeholder?, note?, emptyText?,
|
|
52
|
+
* minChars?, search(q, signal) }`. `search` answers remotely;
|
|
53
|
+
* it is debounced, and a stale answer is aborted and never
|
|
54
|
+
* drawn over a newer one.
|
|
55
|
+
* cache true: a re-open draws the previous catalogue at once and
|
|
56
|
+
* refreshes behind it. `palette.invalidate()` drops it.
|
|
57
|
+
* tips strings drawn under Recent when nothing is typed.
|
|
58
|
+
* footerNote a quiet sentence at the footer's right end.
|
|
59
|
+
*
|
|
60
|
+
* A row may carry, besides `{kind, id, label, hint}`:
|
|
61
|
+
* context drawn after the label, NOT searched
|
|
62
|
+
* keywords searched, NOT drawn (a string or an array of strings)
|
|
63
|
+
* cells short values drawn in place of `context` (a scope's rows)
|
|
64
|
+
* group the group key, when it is not `kind`
|
|
65
|
+
* groupLabel the group's header, when the group is not a taxonomy kind
|
|
66
|
+
* kindLabel the word drawn on the right, when the taxonomy's is wrong
|
|
67
|
+
* boost added to the match score (an embedder's "recently opened")
|
|
68
|
+
* modes which of 'split' / 'window' the row offers (key hints)
|
|
69
|
+
* drillLabel the Tab hint ("search rows"); Tab is offered only with it
|
|
70
|
+
* shortcut a key binding drawn on the right
|
|
71
|
+
* verb the Enter hint ('open' by default, 'run' for a command)
|
|
72
|
+
* `hint` keeps its old meaning — drawn AND searched — for a row that sets
|
|
73
|
+
* neither `context` nor `keywords`. A group's header is the taxonomy's
|
|
74
|
+
* `plural` for the kind, else its `label`.
|
|
75
|
+
*
|
|
76
|
+
* `onPick(pick, { mode, scope })` is told how the pick was made: 'open'
|
|
77
|
+
* (Enter, click), 'split' (Ctrl+Enter, Ctrl+click) or 'window'
|
|
78
|
+
* (Shift+Enter, Shift+click); `scope` is the drilled scope's `id`, if any.
|
|
19
79
|
*/
|
|
20
80
|
|
|
21
81
|
import { HelpModal } from '../help/help_modal.js';
|
|
@@ -26,46 +86,68 @@ const ROOT_ID = 'twm-cmdpal';
|
|
|
26
86
|
// carry an `action` so `commit()` runs them instead of opening a tile.
|
|
27
87
|
const STATIC_COMMANDS = [
|
|
28
88
|
{ kind: 'command', id: 'shortcuts', label: 'Keyboard shortcuts',
|
|
29
|
-
hint: 'Show all key bindings (?)', icon: 'keyboard',
|
|
89
|
+
hint: 'Show all key bindings (?)', icon: 'keyboard', shortcut: '?', verb: 'run',
|
|
30
90
|
action: () => HelpModal.open('keyboard-shortcuts') },
|
|
31
91
|
];
|
|
32
92
|
|
|
93
|
+
const DEFAULT_GROUP_CAP = 4;
|
|
94
|
+
const DEFAULT_COMMAND_MIN_CHARS = 2;
|
|
95
|
+
const REMOTE_DEBOUNCE_MS = 150;
|
|
96
|
+
const MAX_ROWS = 200;
|
|
97
|
+
|
|
33
98
|
/** @param taxonomy injected ontology — supplies the chip strip + row icons
|
|
34
99
|
* @param catalog injected entity catalog — supplies the searchable rows
|
|
35
100
|
* @param placeholder input placeholder. It names the embedder's entity types
|
|
36
101
|
* ("try foo:bar"), so the embedder owns the string.
|
|
37
|
-
* @param onPick C30. `(pick) => truthy` CLAIMS the open, exactly the
|
|
38
|
-
* `ManagedWindow.onMaximize` claims the maximise
|
|
39
|
-
* See `commit` for what it is for; omitted, the
|
|
40
|
-
* behaves as it always has.
|
|
102
|
+
* @param onPick C30. `(pick, how) => truthy` CLAIMS the open, exactly the
|
|
103
|
+
* way `ManagedWindow.onMaximize` claims the maximise
|
|
104
|
+
* gesture. See `commit` for what it is for; omitted, the
|
|
105
|
+
* palette behaves as it always has.
|
|
106
|
+
* @param chrome, layout, groupCap, empty, emptyLabel, commands,
|
|
107
|
+
* commandMinChars, drill, cache, tips, footerNote — C39, above. */
|
|
41
108
|
export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = 'Search…',
|
|
42
|
-
onPick = null
|
|
109
|
+
onPick = null,
|
|
110
|
+
chrome = {}, layout = 'flat',
|
|
111
|
+
groupCap = DEFAULT_GROUP_CAP,
|
|
112
|
+
empty = null, emptyLabel = 'Recent',
|
|
113
|
+
commands = null,
|
|
114
|
+
commandMinChars = DEFAULT_COMMAND_MIN_CHARS,
|
|
115
|
+
drill = null, cache = false,
|
|
116
|
+
tips = null, footerNote = '' }) {
|
|
43
117
|
if (!taxonomy) throw new Error('createCommandPalette: a taxonomy is required');
|
|
44
118
|
if (!catalog) throw new Error('createCommandPalette: an entity catalog is required');
|
|
119
|
+
const grouped = layout === 'grouped';
|
|
45
120
|
let overlay = null;
|
|
46
121
|
let entities = [];
|
|
122
|
+
let cached = null; // C39 `cache`: the last loaded catalogue
|
|
123
|
+
let onClose = null;
|
|
47
124
|
|
|
48
125
|
const isOpen = () => !!overlay;
|
|
49
126
|
const close = () => {
|
|
50
127
|
if (!overlay) return;
|
|
128
|
+
try { onClose?.(); } catch { /* ignore */ }
|
|
129
|
+
onClose = null;
|
|
51
130
|
overlay.remove();
|
|
52
131
|
overlay = null;
|
|
53
132
|
};
|
|
54
133
|
const toggle = () => { isOpen() ? close() : open(); };
|
|
134
|
+
const invalidate = () => { cached = null; };
|
|
55
135
|
|
|
56
136
|
const open = async () => {
|
|
57
137
|
if (overlay) return;
|
|
58
138
|
overlay = document.createElement('div');
|
|
59
139
|
overlay.id = ROOT_ID;
|
|
60
|
-
overlay.className = 'twm-cmdpal-overlay';
|
|
61
|
-
overlay.innerHTML = _markup(taxonomy, placeholder, wm);
|
|
140
|
+
overlay.className = 'twm-cmdpal-overlay' + (grouped ? ' twm-cmdpal-overlay--grouped' : '');
|
|
141
|
+
overlay.innerHTML = _markup(taxonomy, placeholder, wm, { chrome, grouped });
|
|
62
142
|
document.body.appendChild(overlay);
|
|
143
|
+
const mine = overlay;
|
|
63
144
|
overlay.addEventListener('click', (e) => {
|
|
64
145
|
if (e.target === overlay) close();
|
|
65
146
|
});
|
|
66
147
|
|
|
67
148
|
const input = overlay.querySelector('.twm-cmdpal__input');
|
|
68
149
|
const list = overlay.querySelector('[data-role="list"]');
|
|
150
|
+
const foot = overlay.querySelector('[data-role="footer"]');
|
|
69
151
|
const chipSlot = overlay.querySelector('[data-role="type-chip"]');
|
|
70
152
|
const chips = overlay.querySelectorAll('[data-shortcut]');
|
|
71
153
|
const toggles = overlay.querySelectorAll('[data-toggle]');
|
|
@@ -101,7 +183,32 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
101
183
|
let typeFilter = null; // null | string[] (navKinds)
|
|
102
184
|
let typeFilterLabel = null; // chip text, derived from the resolved kinds
|
|
103
185
|
|
|
186
|
+
// C39 state: the rows the empty box shows, a drilled scope and its
|
|
187
|
+
// in-flight remote search.
|
|
188
|
+
let emptyRows = [];
|
|
189
|
+
let scope = null;
|
|
190
|
+
let remote = { seq: 0, ctl: null, timer: null, rows: [], pending: false, error: null };
|
|
191
|
+
const cancelRemote = () => {
|
|
192
|
+
clearTimeout(remote.timer);
|
|
193
|
+
try { remote.ctl?.abort(); } catch { /* ignore */ }
|
|
194
|
+
remote.ctl = null;
|
|
195
|
+
};
|
|
196
|
+
onClose = cancelRemote;
|
|
197
|
+
|
|
104
198
|
const renderChip = () => {
|
|
199
|
+
if (scope) {
|
|
200
|
+
chipSlot.innerHTML = `
|
|
201
|
+
<span class="twm-cmdpal__type-chip" title="${_esc(scope.label)}">
|
|
202
|
+
<span class="material-symbols-outlined twm-cmdpal__type-chip-icon">${_esc(scope.icon || 'arrow_right')}</span>
|
|
203
|
+
<span class="twm-cmdpal__type-chip-label twm-cmdpal__type-chip-label--scope">${_esc(scope.label)}</span>
|
|
204
|
+
<button type="button" class="twm-cmdpal__type-chip-x"
|
|
205
|
+
data-action="clear-type"
|
|
206
|
+
aria-label="Leave ${_esc(scope.label)}">×</button>
|
|
207
|
+
</span>`;
|
|
208
|
+
chipSlot.querySelector('[data-action="clear-type"]')
|
|
209
|
+
?.addEventListener('click', (ev) => { ev.preventDefault(); leaveScope(); input.focus(); });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
105
212
|
if (!typeFilter) {
|
|
106
213
|
chipSlot.innerHTML = '';
|
|
107
214
|
return;
|
|
@@ -135,7 +242,8 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
135
242
|
};
|
|
136
243
|
|
|
137
244
|
const render = () => {
|
|
138
|
-
|
|
245
|
+
if (grouped) { renderGrouped(); return; }
|
|
246
|
+
list.innerHTML = filtered.slice(0, MAX_ROWS).map((e, i) => `
|
|
139
247
|
<div class="twm-cmdpal__item${i === active ? ' twm-cmdpal__item--active' : ''}"
|
|
140
248
|
data-idx="${i}">
|
|
141
249
|
<span class="material-symbols-outlined twm-cmdpal__icon">${e.icon || 'arrow_right'}</span>
|
|
@@ -148,6 +256,122 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
148
256
|
el.addEventListener('click', () => commit(Number(el.dataset.idx)));
|
|
149
257
|
});
|
|
150
258
|
};
|
|
259
|
+
|
|
260
|
+
// ── C39: the grouped layout ─────────────────────────────────────
|
|
261
|
+
//
|
|
262
|
+
// `filtered` stays the ONE flat list the keys walk; `sections` says
|
|
263
|
+
// where its headers go. A "show all" row is an item like any other
|
|
264
|
+
// (`more: <group key>`), so arrows and Enter need no special case.
|
|
265
|
+
let sections = []; // [{ title, start, count }]
|
|
266
|
+
|
|
267
|
+
// A row may name its own group (`groupLabel`) when the group is not a
|
|
268
|
+
// taxonomy kind — "Go to" over an embedder's pages, say.
|
|
269
|
+
const namedGroups = new Map();
|
|
270
|
+
const groupLabel = (key) => {
|
|
271
|
+
if (namedGroups.has(key)) return namedGroups.get(key);
|
|
272
|
+
if (key === 'command') return 'Commands';
|
|
273
|
+
const m = taxonomy.meta(key);
|
|
274
|
+
return m?.plural || m?.label || _titleCase(key);
|
|
275
|
+
};
|
|
276
|
+
const kindLabel = (row) => {
|
|
277
|
+
if (row.kindLabel) return row.kindLabel;
|
|
278
|
+
if (row.kind === 'command') return 'Command';
|
|
279
|
+
return taxonomy.meta(row.kind)?.label || _titleCase(row.kind);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const rowHtml = (r, i) => {
|
|
283
|
+
const on = i === active ? ' twm-cmdpal__item--active' : '';
|
|
284
|
+
if (r.more) {
|
|
285
|
+
return `<div class="twm-cmdpal__item twm-cmdpal__item--more${on}" data-idx="${i}" role="option">
|
|
286
|
+
<span class="material-symbols-outlined twm-cmdpal__icon">expand_more</span>
|
|
287
|
+
<span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_esc(r.label)}</span></span>
|
|
288
|
+
<span class="twm-cmdpal__side"><span class="twm-cmdpal__acts"><span><kbd>Enter</kbd> show all</span></span></span>
|
|
289
|
+
</div>`;
|
|
290
|
+
}
|
|
291
|
+
const drawn = _drawn(r);
|
|
292
|
+
const ctx = r.cells?.length
|
|
293
|
+
? `<span class="twm-cmdpal__cells">${r.cells.map(_esc).join(' · ')}</span>`
|
|
294
|
+
: drawn ? `<span class="twm-cmdpal__context">${_esc(drawn)}</span>` : '';
|
|
295
|
+
const verb = r.verb || (r.action || r.kind === 'command' ? 'run' : 'open');
|
|
296
|
+
const modes = Array.isArray(r.modes) ? r.modes : [];
|
|
297
|
+
const acts = [`<span><kbd>Enter</kbd> ${_esc(verb)}</span>`];
|
|
298
|
+
if (r.drillLabel && drill) acts.push(`<span><kbd>Tab</kbd> ${_esc(r.drillLabel)}</span>`);
|
|
299
|
+
if (modes.includes('split')) acts.push('<span><kbd>Ctrl Enter</kbd> split</span>');
|
|
300
|
+
if (modes.includes('window')) acts.push('<span><kbd>Shift Enter</kbd> window</span>');
|
|
301
|
+
const side = r.shortcut
|
|
302
|
+
? `<kbd class="twm-cmdpal__shortcut">${_esc(r.shortcut)}</kbd>`
|
|
303
|
+
: `<span class="twm-cmdpal__kind">${_esc(kindLabel(r))}</span>`;
|
|
304
|
+
return `<div class="twm-cmdpal__item${on}" data-idx="${i}" role="option">
|
|
305
|
+
<span class="material-symbols-outlined twm-cmdpal__icon">${_esc(r.icon || 'arrow_right')}</span>
|
|
306
|
+
<span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_mark(r.label || r.id, r._hits)}</span>${ctx}</span>
|
|
307
|
+
<span class="twm-cmdpal__side">${side}<span class="twm-cmdpal__acts">${acts.join('')}</span></span>
|
|
308
|
+
</div>`;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const renderGrouped = () => {
|
|
312
|
+
let html = '';
|
|
313
|
+
for (const s of sections) {
|
|
314
|
+
if (!s.count) continue;
|
|
315
|
+
if (s.title) html += `<div class="twm-cmdpal__group" role="presentation">${_esc(s.title)}</div>`;
|
|
316
|
+
for (let i = s.start; i < s.start + s.count; i++) html += rowHtml(filtered[i], i);
|
|
317
|
+
}
|
|
318
|
+
const q = input.value.trim();
|
|
319
|
+
if (!filtered.length) {
|
|
320
|
+
if (scope) {
|
|
321
|
+
const msg = remote.error ? remote.error
|
|
322
|
+
: remote.pending ? 'Searching…'
|
|
323
|
+
: q ? `Nothing in ${scope.label} matches “${q}”.`
|
|
324
|
+
: (scope.emptyText || `Type to search ${scope.label}.`);
|
|
325
|
+
html += `<div class="twm-cmdpal__empty">${_esc(msg)}</div>`;
|
|
326
|
+
} else if (q && q !== '>') {
|
|
327
|
+
html += `<div class="twm-cmdpal__empty">Nothing matches “${_esc(q)}”.</div>`;
|
|
328
|
+
}
|
|
329
|
+
} else if (scope && remote.pending) {
|
|
330
|
+
html += '<div class="twm-cmdpal__empty twm-cmdpal__empty--quiet">Searching…</div>';
|
|
331
|
+
}
|
|
332
|
+
if (!q && !scope && !typeFilter && Array.isArray(tips) && tips.length) {
|
|
333
|
+
html += `<div class="twm-cmdpal__tips">${tips.map((t) => `<span>${_esc(t)}</span>`).join('')}</div>`;
|
|
334
|
+
}
|
|
335
|
+
list.innerHTML = html;
|
|
336
|
+
renderFooter();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// Moving the pointer re-paints CLASSES, never the rows: a list
|
|
340
|
+
// rebuilt between mousedown and mouseup loses the click (the press
|
|
341
|
+
// and the release land on different nodes).
|
|
342
|
+
const paintActive = () => {
|
|
343
|
+
list.querySelectorAll('[data-idx]').forEach((el) => {
|
|
344
|
+
const on = Number(el.dataset.idx) === active;
|
|
345
|
+
el.classList.toggle('twm-cmdpal__item--active', on);
|
|
346
|
+
el.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
347
|
+
if (on) el.scrollIntoView?.({ block: 'nearest' });
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const renderFooter = () => {
|
|
352
|
+
if (!foot || !grouped) return;
|
|
353
|
+
const parts = ['<span><kbd>↑</kbd><kbd>↓</kbd> move</span>', '<span><kbd>Enter</kbd> open</span>'];
|
|
354
|
+
if (scope || typeFilter) parts.push('<span><kbd>Backspace</kbd> leave</span>');
|
|
355
|
+
parts.push('<span><kbd>Esc</kbd> close</span>');
|
|
356
|
+
const note = scope?.note || footerNote;
|
|
357
|
+
foot.innerHTML = parts.join('')
|
|
358
|
+
+ (note ? `<span class="twm-cmdpal__footnote">${_esc(note)}</span>` : '');
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
if (grouped) {
|
|
362
|
+
list.addEventListener('mousemove', (e) => {
|
|
363
|
+
const el = e.target.closest?.('[data-idx]');
|
|
364
|
+
if (!el) return;
|
|
365
|
+
const i = Number(el.dataset.idx);
|
|
366
|
+
if (i !== active) { active = i; paintActive(); }
|
|
367
|
+
});
|
|
368
|
+
list.addEventListener('click', (e) => {
|
|
369
|
+
const el = e.target.closest?.('[data-idx]');
|
|
370
|
+
if (!el) return;
|
|
371
|
+
commit(Number(el.dataset.idx), _modeOf(e));
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
151
375
|
/**
|
|
152
376
|
* ══ C30. THE PALETTE IS A DOOR, NOT A PLACEMENT POLICY ══════════
|
|
153
377
|
*
|
|
@@ -177,18 +401,23 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
177
401
|
* and unclaimed picks still land in the primary tile, so no existing
|
|
178
402
|
* embedder changes behaviour by upgrading.
|
|
179
403
|
*
|
|
180
|
-
* `pick` is the catalogue row
|
|
181
|
-
*
|
|
182
|
-
*
|
|
404
|
+
* `pick` is the catalogue row (`{kind, id, label, hint, props?}`)
|
|
405
|
+
* rather than a re-shaped argument: the embedder wrote the `shape`
|
|
406
|
+
* that produced it, so it is the one party that can read it. C39 adds
|
|
407
|
+
* a second argument, `{ mode, scope }`; an embedder that reads one
|
|
408
|
+
* argument is unaffected.
|
|
183
409
|
*/
|
|
184
|
-
const commit = (i) => {
|
|
185
|
-
const
|
|
186
|
-
if (!
|
|
410
|
+
const commit = (i, mode = 'open') => {
|
|
411
|
+
const picked = filtered[i];
|
|
412
|
+
if (!picked) return;
|
|
413
|
+
if (picked.more) { showAll(picked.more); return; }
|
|
414
|
+
const pick = _public(picked);
|
|
415
|
+
const how = { mode, scope: scope?.id ?? null };
|
|
187
416
|
close();
|
|
188
|
-
if (pick.action) { try { pick.action(); } catch (err) { console.error(err); } return; }
|
|
417
|
+
if (pick.action) { try { pick.action(how); } catch (err) { console.error(err); } return; }
|
|
189
418
|
if (onPick) {
|
|
190
419
|
let handled = false;
|
|
191
|
-
try { handled = onPick(pick) ?? false; }
|
|
420
|
+
try { handled = onPick(pick, how) ?? false; }
|
|
192
421
|
catch (err) { console.error('[cmdpal] onPick threw', err); }
|
|
193
422
|
if (handled) return;
|
|
194
423
|
}
|
|
@@ -201,7 +430,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
201
430
|
* true when a chip was set so the caller can decide whether
|
|
202
431
|
* to re-render. */
|
|
203
432
|
const tryConsumePrefix = () => {
|
|
204
|
-
if (typeFilter) return false;
|
|
433
|
+
if (typeFilter || scope) return false;
|
|
205
434
|
const m = input.value.match(/^([a-zA-Z][a-zA-Z0-9_-]*):/);
|
|
206
435
|
if (!m) return false;
|
|
207
436
|
const kinds = catalog.resolveTypePrefix(m[1]);
|
|
@@ -209,12 +438,168 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
209
438
|
typeFilter = kinds;
|
|
210
439
|
typeFilterLabel = _titleCase(m[1]);
|
|
211
440
|
input.value = input.value.slice(m[0].length);
|
|
441
|
+
if (grouped) {
|
|
442
|
+
input.value = input.value.replace(/^\s+/, '');
|
|
443
|
+
typeFilterLabel = groupLabel(kinds[0]);
|
|
444
|
+
}
|
|
212
445
|
renderChip();
|
|
213
446
|
return true;
|
|
214
447
|
};
|
|
215
448
|
|
|
449
|
+
/** *Show all N <group>* — the cap's way out. It narrows the box to
|
|
450
|
+
* that group exactly as a typed `kind:` would, so Backspace leaves
|
|
451
|
+
* it the same way. */
|
|
452
|
+
const showAll = (key) => {
|
|
453
|
+
typeFilter = [key];
|
|
454
|
+
typeFilterLabel = groupLabel(key);
|
|
455
|
+
if (input.value.startsWith('>')) input.value = input.value.slice(1);
|
|
456
|
+
renderChip();
|
|
457
|
+
refilter();
|
|
458
|
+
input.focus();
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const enterScope = (row) => {
|
|
462
|
+
if (!drill || !row || row.more) return false;
|
|
463
|
+
let next = null;
|
|
464
|
+
try { next = drill(_public(row)); } catch (err) { console.error('[cmdpal] drill threw', err); }
|
|
465
|
+
if (!next || typeof next.search !== 'function') return false;
|
|
466
|
+
cancelRemote();
|
|
467
|
+
scope = next;
|
|
468
|
+
typeFilter = null;
|
|
469
|
+
typeFilterLabel = null;
|
|
470
|
+
input.value = '';
|
|
471
|
+
input.placeholder = next.placeholder || `Search ${next.label}…`;
|
|
472
|
+
remote = { seq: 0, ctl: null, timer: null, rows: [], pending: false, error: null };
|
|
473
|
+
renderChip();
|
|
474
|
+
refilter();
|
|
475
|
+
return true;
|
|
476
|
+
};
|
|
477
|
+
const leaveScope = () => {
|
|
478
|
+
cancelRemote();
|
|
479
|
+
remote.seq++;
|
|
480
|
+
scope = null;
|
|
481
|
+
typeFilter = null;
|
|
482
|
+
typeFilterLabel = null;
|
|
483
|
+
input.placeholder = placeholder;
|
|
484
|
+
renderChip();
|
|
485
|
+
refilter();
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const runRemote = (q) => {
|
|
489
|
+
cancelRemote();
|
|
490
|
+
const seq = ++remote.seq;
|
|
491
|
+
if (q.length < (scope.minChars ?? 1)) {
|
|
492
|
+
remote.rows = []; remote.pending = false; remote.error = null;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
remote.pending = true;
|
|
496
|
+
remote.error = null;
|
|
497
|
+
const s = scope;
|
|
498
|
+
remote.timer = setTimeout(async () => {
|
|
499
|
+
const ctl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
500
|
+
remote.ctl = ctl;
|
|
501
|
+
let rows = [];
|
|
502
|
+
let error = null;
|
|
503
|
+
try { rows = await s.search(q, ctl?.signal); }
|
|
504
|
+
catch (err) {
|
|
505
|
+
if (err?.name === 'AbortError') return;
|
|
506
|
+
error = err?.userMessage || 'The search failed.';
|
|
507
|
+
console.warn('[cmdpal] scope search failed', err);
|
|
508
|
+
}
|
|
509
|
+
// A newer keystroke, a left scope or a closed box: drop it.
|
|
510
|
+
if (seq !== remote.seq || scope !== s || overlay !== mine) return;
|
|
511
|
+
remote.rows = _decorateIcons(Array.isArray(rows) ? rows : [], taxonomy)
|
|
512
|
+
.map((r) => ({ ...r, _hits: _match(String(r.label || ''), q)?.hits || [] }));
|
|
513
|
+
remote.pending = false;
|
|
514
|
+
remote.error = error;
|
|
515
|
+
filtered = remote.rows.slice(0, MAX_ROWS);
|
|
516
|
+
sections = [{ title: s.label, start: 0, count: filtered.length }];
|
|
517
|
+
active = 0;
|
|
518
|
+
render();
|
|
519
|
+
}, REMOTE_DEBOUNCE_MS);
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const refilterGrouped = () => {
|
|
523
|
+
const raw = input.value;
|
|
524
|
+
if (scope) {
|
|
525
|
+
runRemote(raw.trim());
|
|
526
|
+
filtered = remote.rows;
|
|
527
|
+
sections = [{ title: scope.label, start: 0, count: filtered.length }];
|
|
528
|
+
active = 0;
|
|
529
|
+
render();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const commandsOnly = !typeFilter && raw.startsWith('>');
|
|
533
|
+
const q = (commandsOnly ? raw.slice(1) : raw).trim();
|
|
534
|
+
const filterSet = typeFilter ? new Set(typeFilter) : null;
|
|
535
|
+
if (!q && !commandsOnly && !filterSet) {
|
|
536
|
+
filtered = emptyRows.slice(0, MAX_ROWS).map((r) => ({ ...r, _hits: [] }));
|
|
537
|
+
sections = [{ title: filtered.length ? emptyLabel : '', start: 0, count: filtered.length }];
|
|
538
|
+
active = 0;
|
|
539
|
+
render();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
let pool = entities;
|
|
543
|
+
if (commandsOnly) pool = pool.filter((e) => e.kind === 'command');
|
|
544
|
+
else if (filterSet) pool = pool.filter((e) => filterSet.has(_groupOf(e)) || filterSet.has(e.kind));
|
|
545
|
+
else if (q.length < commandMinChars) pool = pool.filter((e) => e.kind !== 'command');
|
|
546
|
+
|
|
547
|
+
const scored = [];
|
|
548
|
+
if (!q) {
|
|
549
|
+
for (const e of pool) scored.push({ ...e, _hits: [], _score: 0 });
|
|
550
|
+
scored.sort((a, b) => String(a.label || a.id).localeCompare(String(b.label || b.id)));
|
|
551
|
+
} else {
|
|
552
|
+
for (const e of pool) {
|
|
553
|
+
let m = _match(String(e.label || e.id || ''), q);
|
|
554
|
+
if (!m) {
|
|
555
|
+
const extra = _searched(e);
|
|
556
|
+
const k = extra ? _match(extra, q) : null;
|
|
557
|
+
if (k) m = { s: Math.min(k.s, 20), hits: [] };
|
|
558
|
+
}
|
|
559
|
+
if (!m) continue;
|
|
560
|
+
// A command a few points under an object it ties with: typing a
|
|
561
|
+
// table's name must not lead with "Customise interface".
|
|
562
|
+
const penalty = e.kind === 'command' && !commandsOnly ? 5 : 0;
|
|
563
|
+
scored.push({ ...e, _hits: m.hits, _score: m.s + (Number(e.boost) || 0) - penalty });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// Group, and order the groups by their best hit (ties: first seen).
|
|
567
|
+
const order = [];
|
|
568
|
+
const by = new Map();
|
|
569
|
+
for (const r of scored) {
|
|
570
|
+
const key = commandsOnly ? 'command' : _groupOf(r);
|
|
571
|
+
if (!by.has(key)) { by.set(key, []); order.push(key); }
|
|
572
|
+
by.get(key).push(r);
|
|
573
|
+
if (r.groupLabel && !namedGroups.has(key)) namedGroups.set(key, r.groupLabel);
|
|
574
|
+
}
|
|
575
|
+
// A group is as good as its best row, compared the way rows are
|
|
576
|
+
// (score, then the shorter label): "Customers settings" must not
|
|
577
|
+
// lead the table called "Customers" because both are prefixes.
|
|
578
|
+
const top = (k) => by.get(k).slice().sort(_byScore)[0];
|
|
579
|
+
const keys = order.slice().sort((a, b) => (q ? _byScore(top(a), top(b)) : 0)
|
|
580
|
+
|| order.indexOf(a) - order.indexOf(b));
|
|
581
|
+
const capped = !filterSet && !commandsOnly;
|
|
582
|
+
filtered = [];
|
|
583
|
+
sections = [];
|
|
584
|
+
for (const key of keys) {
|
|
585
|
+
const rows = by.get(key);
|
|
586
|
+
if (q) rows.sort(_byScore);
|
|
587
|
+
const shown = capped ? rows.slice(0, groupCap) : rows;
|
|
588
|
+
const start = filtered.length;
|
|
589
|
+
filtered.push(...shown);
|
|
590
|
+
if (shown.length < rows.length) {
|
|
591
|
+
filtered.push({ more: key, label: `Show all ${rows.length} ${groupLabel(key).toLowerCase()}` });
|
|
592
|
+
}
|
|
593
|
+
sections.push({ title: groupLabel(key), start, count: filtered.length - start });
|
|
594
|
+
if (filtered.length >= MAX_ROWS) break;
|
|
595
|
+
}
|
|
596
|
+
active = 0;
|
|
597
|
+
render();
|
|
598
|
+
};
|
|
599
|
+
|
|
216
600
|
const refilter = () => {
|
|
217
601
|
tryConsumePrefix();
|
|
602
|
+
if (grouped) { refilterGrouped(); return; }
|
|
218
603
|
const q = input.value.trim().toLowerCase();
|
|
219
604
|
const filterSet = typeFilter ? new Set(typeFilter) : null;
|
|
220
605
|
const pool = filterSet
|
|
@@ -237,25 +622,32 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
237
622
|
};
|
|
238
623
|
input.addEventListener('input', refilter);
|
|
239
624
|
input.addEventListener('keydown', (e) => {
|
|
625
|
+
const move = (to) => {
|
|
626
|
+
active = to;
|
|
627
|
+
if (grouped) paintActive(); else render();
|
|
628
|
+
};
|
|
240
629
|
if (e.key === 'ArrowDown') {
|
|
241
630
|
e.preventDefault();
|
|
242
|
-
|
|
243
|
-
render();
|
|
631
|
+
move(Math.min(filtered.length - 1, active + 1));
|
|
244
632
|
} else if (e.key === 'ArrowUp') {
|
|
245
633
|
e.preventDefault();
|
|
246
|
-
|
|
247
|
-
render();
|
|
634
|
+
move(Math.max(0, active - 1));
|
|
248
635
|
} else if (e.key === 'Enter') {
|
|
249
636
|
e.preventDefault();
|
|
250
|
-
commit(active);
|
|
637
|
+
commit(active, _modeOf(e));
|
|
251
638
|
} else if (e.key === 'Escape') {
|
|
252
639
|
close();
|
|
253
|
-
} else if (e.key === '
|
|
640
|
+
} else if (grouped && e.key === 'Tab' && !e.shiftKey) {
|
|
641
|
+
const row = filtered[active];
|
|
642
|
+
if (row?.more) { e.preventDefault(); showAll(row.more); }
|
|
643
|
+
else if (row?.drillLabel && enterScope(row)) e.preventDefault();
|
|
644
|
+
} else if (e.key === 'Backspace' && input.value === '' && (typeFilter || scope)) {
|
|
254
645
|
// Empty input + active chip → consume Backspace to drop
|
|
255
646
|
// the chip. Same idiom as Gmail / Linear / VS Code's
|
|
256
647
|
// command palette: the chip behaves like a token before
|
|
257
648
|
// the caret.
|
|
258
649
|
e.preventDefault();
|
|
650
|
+
if (scope) { leaveScope(); return; }
|
|
259
651
|
typeFilter = null;
|
|
260
652
|
typeFilterLabel = null;
|
|
261
653
|
renderChip();
|
|
@@ -268,23 +660,48 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
268
660
|
// — the palette stays usable for shortcuts/commands while we wait.
|
|
269
661
|
// The set is rebuilt on every open so the palette reflects
|
|
270
662
|
// entities the user just added this session (cheap; the catalog
|
|
271
|
-
// runs every source in parallel).
|
|
272
|
-
|
|
663
|
+
// runs every source in parallel). With `cache`, the previous set
|
|
664
|
+
// is drawn first and the rebuild replaces it when it lands.
|
|
665
|
+
const commandRows = () => {
|
|
666
|
+
let extra = [];
|
|
667
|
+
if (typeof commands === 'function') {
|
|
668
|
+
try { extra = (commands() || []).map((c) => ({ kind: 'command', ...c })); }
|
|
669
|
+
catch (err) { console.warn('[cmdpal] commands() threw', err); }
|
|
670
|
+
}
|
|
671
|
+
return [...STATIC_COMMANDS, ...extra];
|
|
672
|
+
};
|
|
673
|
+
// Objects before commands in the grouped layout, so that a tie that
|
|
674
|
+
// survives every rule goes to the object (first seen wins).
|
|
675
|
+
const withCommands = (rows) => (grouped ? [...rows, ...commandRows()] : [...commandRows(), ...rows]);
|
|
676
|
+
entities = _decorateIcons(withCommands(cache && cached ? cached : []), taxonomy);
|
|
677
|
+
if (grouped) {
|
|
678
|
+
refilter();
|
|
679
|
+
if (typeof empty === 'function') {
|
|
680
|
+
try {
|
|
681
|
+
const got = await empty();
|
|
682
|
+
if (overlay !== mine) return;
|
|
683
|
+
emptyRows = _decorateIcons(Array.isArray(got) ? got : [], taxonomy);
|
|
684
|
+
if (!input.value && !typeFilter && !scope) refilter();
|
|
685
|
+
} catch (err) { console.warn('[cmdpal] empty() failed', err); }
|
|
686
|
+
}
|
|
687
|
+
}
|
|
273
688
|
if (api) {
|
|
274
689
|
try {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (
|
|
690
|
+
const loaded = await catalog.loadAll(api);
|
|
691
|
+
if (cache) cached = loaded;
|
|
692
|
+
if (overlay !== mine) return;
|
|
693
|
+
entities = _decorateIcons(withCommands(loaded), taxonomy);
|
|
694
|
+
if (grouped ? (!scope && (input.value || typeFilter)) : input.value) refilter();
|
|
278
695
|
} catch (err) {
|
|
279
696
|
console.warn('[cmdpal] entity load failed', err);
|
|
280
697
|
}
|
|
281
698
|
}
|
|
282
699
|
};
|
|
283
700
|
|
|
284
|
-
return { open, close, toggle, isOpen };
|
|
701
|
+
return { open, close, toggle, isOpen, invalidate };
|
|
285
702
|
}
|
|
286
703
|
|
|
287
|
-
function _markup(taxonomy, placeholder, wm) {
|
|
704
|
+
function _markup(taxonomy, placeholder, wm, { chrome = {}, grouped = false } = {}) {
|
|
288
705
|
// ONLY THE PANELS THIS SHELL CAN ACTUALLY SHOW.
|
|
289
706
|
//
|
|
290
707
|
// The three toggles were hardcoded, so the palette offered `panel:left` to
|
|
@@ -299,7 +716,7 @@ function _markup(taxonomy, placeholder, wm) {
|
|
|
299
716
|
{ side: 'right', icon: 'dock_to_left', label: 'Right panel' },
|
|
300
717
|
{ side: 'bottom', icon: 'dock_to_bottom', label: 'Bottom panel' },
|
|
301
718
|
];
|
|
302
|
-
const panelToggles = PANEL_CHIPS
|
|
719
|
+
const panelToggles = chrome.panels === false ? '' : PANEL_CHIPS
|
|
303
720
|
.filter((p) => wm.content?.has?.(`panel:${p.side}`) !== false)
|
|
304
721
|
.map((p) => `
|
|
305
722
|
<button class="twm-chip" data-toggle="${p.side}">
|
|
@@ -307,12 +724,32 @@ function _markup(taxonomy, placeholder, wm) {
|
|
|
307
724
|
${p.label}
|
|
308
725
|
</button>`).join('');
|
|
309
726
|
|
|
310
|
-
const chips = taxonomy.topNavEntries().map((k) => `
|
|
727
|
+
const chips = chrome.pages === false ? '' : taxonomy.topNavEntries().map((k) => `
|
|
311
728
|
<button class="twm-chip" data-shortcut="${k.kind}">
|
|
312
729
|
<span class="material-symbols-outlined">${k.icon}</span>
|
|
313
730
|
${k.label}
|
|
314
731
|
</button>
|
|
315
732
|
`).join('');
|
|
733
|
+
if (grouped) {
|
|
734
|
+
// C39. A strip with nothing in it is not drawn at all: an empty
|
|
735
|
+
// bordered div is a 1px line between the input and the results.
|
|
736
|
+
return `
|
|
737
|
+
<div class="twm-cmdpal twm-cmdpal--grouped" role="dialog" aria-label="Command palette">
|
|
738
|
+
<div class="twm-cmdpal__inputrow">
|
|
739
|
+
<span class="material-symbols-outlined twm-cmdpal__search-icon">search</span>
|
|
740
|
+
<span class="twm-cmdpal__type-chip-slot" data-role="type-chip"></span>
|
|
741
|
+
<input type="text" class="twm-cmdpal__input"
|
|
742
|
+
placeholder="${_esc(placeholder)}"
|
|
743
|
+
autocomplete="off" spellcheck="false" role="combobox"
|
|
744
|
+
aria-expanded="true" aria-controls="twm-cmdpal-list" />
|
|
745
|
+
<kbd class="twm-cmdpal__esc">Esc</kbd>
|
|
746
|
+
</div>
|
|
747
|
+
${chips ? `<div class="twm-cmdpal__chips">${chips}</div>` : ''}
|
|
748
|
+
${panelToggles ? `<div class="twm-cmdpal__toggles">${panelToggles}</div>` : ''}
|
|
749
|
+
<div class="twm-cmdpal__list" id="twm-cmdpal-list" role="listbox" data-role="list"></div>
|
|
750
|
+
<div class="twm-cmdpal__footer" data-role="footer"></div>
|
|
751
|
+
</div>`;
|
|
752
|
+
}
|
|
316
753
|
return `
|
|
317
754
|
<div class="twm-cmdpal" role="dialog" aria-label="Command palette">
|
|
318
755
|
<div class="twm-cmdpal__inputrow">
|
|
@@ -364,6 +801,109 @@ function _search(entities, q) {
|
|
|
364
801
|
return scored.map((x) => x.e);
|
|
365
802
|
}
|
|
366
803
|
|
|
804
|
+
// ── C39: matching, and what a row draws and searches ─────────────────────
|
|
805
|
+
|
|
806
|
+
/** What a row draws after its label: `context`, else the old `hint`. */
|
|
807
|
+
function _drawn(r) {
|
|
808
|
+
return r.context !== undefined ? r.context : (r.hint || '');
|
|
809
|
+
}
|
|
810
|
+
/** What a row is searched by besides its label: `keywords`, else `hint`. */
|
|
811
|
+
function _searched(r) {
|
|
812
|
+
const k = r.keywords !== undefined ? r.keywords : r.hint;
|
|
813
|
+
return Array.isArray(k) ? k.join(' ') : String(k || '');
|
|
814
|
+
}
|
|
815
|
+
function _groupOf(r) { return r.group || r.kind; }
|
|
816
|
+
|
|
817
|
+
/** Strip the palette's private fields before a row leaves it. */
|
|
818
|
+
function _public(r) {
|
|
819
|
+
const { _hits, _score, ...rest } = r;
|
|
820
|
+
return rest;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function _byScore(a, b) {
|
|
824
|
+
return b._score - a._score
|
|
825
|
+
|| String(a.label || '').length - String(b.label || '').length
|
|
826
|
+
|| String(a.label || '').localeCompare(String(b.label || ''));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function _modeOf(e) {
|
|
830
|
+
if (e?.ctrlKey || e?.metaKey) return 'split';
|
|
831
|
+
if (e?.shiftKey) return 'window';
|
|
832
|
+
return 'open';
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const _WORD_BREAK = /[\s_\-–—.:/\\()[\]&,+·›]/;
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Score `text` against `query`, returning `{ s, hits }` — the tier's score
|
|
839
|
+
* and the matched character positions — or null.
|
|
840
|
+
*
|
|
841
|
+
* 100 exact
|
|
842
|
+
* 90 prefix
|
|
843
|
+
* ~72 every query word starts a word of the text, in order
|
|
844
|
+
* (`ord li` → *Order lines*, `cust no` → `customer_no`)
|
|
845
|
+
* 60 substring
|
|
846
|
+
* ≤30 subsequence, worse the more it is spread out
|
|
847
|
+
*/
|
|
848
|
+
export function matchLabel(text, query) { return _match(text, query); }
|
|
849
|
+
|
|
850
|
+
function _match(text, query) {
|
|
851
|
+
const t = String(text || '').toLowerCase();
|
|
852
|
+
const q = String(query || '').toLowerCase().trim();
|
|
853
|
+
if (!q || !t) return null;
|
|
854
|
+
const span = (a, n) => Array.from({ length: n }, (_, i) => a + i);
|
|
855
|
+
if (t === q) return { s: 100, hits: span(0, q.length) };
|
|
856
|
+
if (t.startsWith(q)) return { s: 90, hits: span(0, q.length) };
|
|
857
|
+
|
|
858
|
+
const words = q.split(/\s+/).filter(Boolean);
|
|
859
|
+
{
|
|
860
|
+
const hits = [];
|
|
861
|
+
let from = 0;
|
|
862
|
+
let ok = true;
|
|
863
|
+
for (const w of words) {
|
|
864
|
+
let at = -1;
|
|
865
|
+
for (let i = from; i <= t.length - w.length; i++) {
|
|
866
|
+
if ((i === 0 || _WORD_BREAK.test(t[i - 1])) && t.startsWith(w, i)) { at = i; break; }
|
|
867
|
+
}
|
|
868
|
+
if (at < 0) { ok = false; break; }
|
|
869
|
+
hits.push(...span(at, w.length));
|
|
870
|
+
from = at + w.length;
|
|
871
|
+
}
|
|
872
|
+
if (ok) return { s: 75 - Math.min(words.length, 5), hits };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const sub = t.indexOf(q);
|
|
876
|
+
if (sub >= 0) return { s: 60, hits: span(sub, q.length) };
|
|
877
|
+
|
|
878
|
+
const flat = q.replace(/\s+/g, '');
|
|
879
|
+
const hits = [];
|
|
880
|
+
let j = 0;
|
|
881
|
+
for (let i = 0; i < t.length && j < flat.length; i++) {
|
|
882
|
+
if (t[i] === flat[j]) { hits.push(i); j++; }
|
|
883
|
+
}
|
|
884
|
+
if (j < flat.length) return null;
|
|
885
|
+
const spread = hits[hits.length - 1] - hits[0] + 1 - flat.length;
|
|
886
|
+
if (spread > 3 * flat.length + 6) return null; // letters strewn across a long label
|
|
887
|
+
return { s: Math.max(1, 30 - spread), hits };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** The label with matched characters wrapped in `<mark>`, escaped. */
|
|
891
|
+
function _mark(text, hits) {
|
|
892
|
+
const s = String(text ?? '');
|
|
893
|
+
if (!hits?.length) return _esc(s);
|
|
894
|
+
const set = new Set(hits);
|
|
895
|
+
let out = '';
|
|
896
|
+
let open = false;
|
|
897
|
+
for (let i = 0; i < s.length; i++) {
|
|
898
|
+
const on = set.has(i);
|
|
899
|
+
if (on && !open) { out += '<mark>'; open = true; }
|
|
900
|
+
if (!on && open) { out += '</mark>'; open = false; }
|
|
901
|
+
out += _esc(s[i]);
|
|
902
|
+
}
|
|
903
|
+
if (open) out += '</mark>';
|
|
904
|
+
return out;
|
|
905
|
+
}
|
|
906
|
+
|
|
367
907
|
function _esc(s) {
|
|
368
908
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
369
909
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|