mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-10 18:34:18 +08:00
359 lines
14 KiB
JavaScript
359 lines
14 KiB
JavaScript
'use strict';
|
||
'require baseclass';
|
||
'require fs-menutree as tree';
|
||
'require fs-prefs as prefs';
|
||
'require fs-router as router';
|
||
'require fs-widgets as widgets';
|
||
|
||
/* Find a page by typing its name instead of knowing which section owns it. A loaded router
|
||
* carries ~200 menu nodes across 11 sections, and a tab such as Firewall -> Port Forwards appears
|
||
* in no menu list until you are already there; this indexes every node the dispatcher would
|
||
* render, tabs included.
|
||
*
|
||
* It costs no request: the tree is the same ACL-filtered /admin/menu blob the chrome loaded
|
||
* (fs-menutree), so the palette lists exactly the pages this session may open. The index is built
|
||
* on the first open, not at init — a user who never searches pays nothing, and only a full load
|
||
* can change the tree.
|
||
*
|
||
* Navigation is deliberately not a call into the router: every result is a real <a href>, so a
|
||
* click bubbles to the router's own document-level handler and no copy of that decision lives
|
||
* here. Enter synthesises the same click. */
|
||
|
||
/* ---- the index ---------------------------------------------------------- */
|
||
|
||
/* How deep below the mode the walk goes. `depth` counts recursion levels starting at 1 on a node
|
||
* that already has two segments, so 4 admits admin/<section>/<page>/<tab>/<subtab> — one level
|
||
* more than LuCI renders, so a deeper third-party node is still findable and the depth term in
|
||
* search() ranks it last. */
|
||
const MAX_DEPTH = 4;
|
||
|
||
let _index = null;
|
||
|
||
/* The node's own children, ACL- and title-filtered as ui.menu.getChildren() filters them, but not
|
||
* through getChildren(): on an alias node it returns the alias TARGET's children, which is right
|
||
* for drawing a menu and wrong for indexing — Network -> Firewall aliases onto the `firewall/zones`
|
||
* leaf, so all five of its tabs vanish from the index (78 nodes instead of 238).
|
||
*
|
||
* Order does not matter (search ranks by score), so only the two filters are reimplemented, not
|
||
* the sort. */
|
||
function childrenOf(node) {
|
||
const kids = node.children || {};
|
||
const out = [];
|
||
for (const name in kids) {
|
||
const c = kids[name];
|
||
if (!c || !c.satisfied || !c.title) continue;
|
||
out.push({ name: name, node: c });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function walk(node, segs, trail, out, depth) {
|
||
childrenOf(node).forEach((entry) => {
|
||
const child = entry.node;
|
||
/* the chrome carries its own Logout (partials/logout.ut); indexing it would let a search
|
||
* open a confirmation the user did not ask for */
|
||
if (depth === 1 && entry.name === 'logout')
|
||
return;
|
||
|
||
const title = _(child.title);
|
||
const csegs = segs.concat([ entry.name ]);
|
||
|
||
out.push({
|
||
segs: csegs,
|
||
path: csegs.join('/'),
|
||
title: title,
|
||
trail: trail,
|
||
depth: depth,
|
||
/* Three haystacks, in this order of preference (see tokenScore()). The English
|
||
* node name is indexed on purpose: a translated UI otherwise hides the page from
|
||
* an admin who knows OpenWrt by its English docs. */
|
||
t: title.toLowerCase(),
|
||
p: trail.join(' ').toLowerCase(),
|
||
/* …minus the root segment, `admin`, which every node shares: indexed, every
|
||
* substring of it ("ad", "min", …) hits all ~200 pages and fills the result cap */
|
||
n: csegs.slice(1).join(' ').toLowerCase()
|
||
});
|
||
|
||
if (depth < MAX_DEPTH)
|
||
walk(child, csegs, trail.concat([ title ]), out, depth + 1);
|
||
});
|
||
}
|
||
|
||
function buildIndex() {
|
||
const root = tree.tree();
|
||
const out = [];
|
||
if (!root) return out;
|
||
/* the mode (admin) is a container, not a destination: start one level in */
|
||
childrenOf(root).forEach((mode) => {
|
||
walk(mode.node, [ mode.name ], [], out, 1);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/* Built once per document: it projects the client menu tree, which `ui.menu.load()` caches for
|
||
* the life of the document, so the palette is exactly as fresh as the sidebar beside it.
|
||
* Invalidating only this half would let the two disagree, which is worse than both being stale;
|
||
* the reload a package install prompts for refreshes them together. */
|
||
function index() {
|
||
if (!_index) _index = buildIndex();
|
||
return _index;
|
||
}
|
||
|
||
/* ---- matching ----------------------------------------------------------- */
|
||
|
||
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
|
||
* a fuzzy subsequence match — on a two-letter query that matches nearly every entry and leaves the
|
||
* ranking to decide everything, which reads as random. */
|
||
const HIT_NONE = 99;
|
||
function tokenScore(e, tok) {
|
||
if (e.t.startsWith(tok)) return 0; /* the title begins with it */
|
||
if (e.t.includes(tok)) return 1; /* somewhere in the title */
|
||
if (e.n.includes(tok)) return 2; /* the English path segment */
|
||
if (e.p.includes(tok)) return 3; /* an ancestor's title */
|
||
return HIT_NONE;
|
||
}
|
||
|
||
function search(q, limit) {
|
||
const toks = q.toLowerCase().split(/\s+/).filter(Boolean);
|
||
if (!toks.length) return [];
|
||
|
||
const hits = [];
|
||
for (const e of index()) {
|
||
let sum = 0;
|
||
for (const tok of toks) {
|
||
const s = tokenScore(e, tok);
|
||
if (s === HIT_NONE) { sum = HIT_NONE; break; }
|
||
sum += s;
|
||
}
|
||
if (sum !== HIT_NONE)
|
||
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: shallower page first */
|
||
}
|
||
hits.sort((a, b) => a.score - b.score);
|
||
return hits.slice(0, limit).map((h) => h.e);
|
||
}
|
||
|
||
/* ---- recently visited --------------------------------------------------- */
|
||
|
||
/* What the palette shows before anything is typed: an admin lives in three or four pages, so the
|
||
* empty state is its most-used view.
|
||
*
|
||
* Only the path is stored, never the title — the title is resolved through the index on every
|
||
* render, so it follows the UI language and a page removed with its package drops out instead of
|
||
* lingering as a dead row. */
|
||
const RECENT_KEY = 'fs-recent';
|
||
const RECENT_MAX = 8;
|
||
|
||
/* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any
|
||
* more, and a palette that only loads when it is opened cannot be what records where the admin has
|
||
* been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
|
||
* corruption guard and the Array check; only the "these are paths" filter belongs here. */
|
||
function recentEntries() {
|
||
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
|
||
const byPath = new Map(index().map((e) => [ e.path, e ]));
|
||
return recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX);
|
||
}
|
||
|
||
/* ---- the palette -------------------------------------------------------- */
|
||
|
||
const MAX_RESULTS = 20;
|
||
|
||
/* Built on the first open and kept — the overlay, its listeners and the index survive for the life
|
||
* of the document, so a second Ctrl+K costs nothing. Until then this module is not even fetched:
|
||
* menu-footstrap-common.js holds the shortcut and requires this on the first gesture. */
|
||
let _built = null;
|
||
|
||
function build() {
|
||
const btn = document.getElementById('fs-search-btn');
|
||
if (!btn) return null;
|
||
|
||
const input = E('input', {
|
||
'type': 'text',
|
||
'class': 'fs-search-input',
|
||
'role': 'combobox',
|
||
'aria-controls': 'fs-search-list',
|
||
'aria-expanded': 'true',
|
||
'aria-autocomplete': 'list',
|
||
'aria-label': _('Search pages', 'footstrap'),
|
||
'placeholder': _('Search pages…', 'footstrap'),
|
||
'autocomplete': 'off',
|
||
'autocapitalize': 'off',
|
||
'spellcheck': 'false'
|
||
});
|
||
const list = E('div', { 'id': 'fs-search-list', 'class': 'fs-search-list', 'role': 'listbox', 'aria-label': _('Pages', 'footstrap') });
|
||
const note = E('div', { 'class': 'fs-search-note' });
|
||
const ico = E('span', { 'class': 'fs-search-ico' });
|
||
ico.innerHTML = widgets.svgIcon('<circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/>');
|
||
const box = E('div', { 'class': 'fs-search-box' }, [
|
||
E('div', { 'class': 'fs-search-row' }, [ ico, input ]),
|
||
/* the note captions the rows below it ("Recently visited") and doubles as the empty
|
||
* state, so it belongs above the list in both readings */
|
||
note,
|
||
list
|
||
]);
|
||
/* data-fs-chrome marks a zone-1 root (docs/third-party-apps.md): this overlay is parented to
|
||
* <body>, outside the <nav> that carries the mark in header.ut, so without it the fence does
|
||
* not cover the palette — the shape that once left the Appearance popover unfenced */
|
||
const ov = E('div', {
|
||
'id': 'fs-search-ov',
|
||
'class': 'fs-search-ov',
|
||
'data-fs-chrome': '',
|
||
'role': 'dialog',
|
||
'aria-modal': 'true',
|
||
'aria-label': _('Search pages', 'footstrap'),
|
||
'hidden': ''
|
||
}, [ box ]);
|
||
document.body.appendChild(ov);
|
||
|
||
let opts = [], ents = [], at = -1;
|
||
|
||
/* Warm the highlighted page's module chain, debounced: render() re-runs setActive(0) on every
|
||
* keystroke, so warming at once would pull the top result of "w", "wi", "wir"… Only arrow keys
|
||
* and typing need this — the rows are real anchors, so a mouse over one already reaches the
|
||
* router's pointerover listener. fs-router's warmClass() dedupes, so a row revisited costs
|
||
* nothing. */
|
||
let warmT = null;
|
||
function warmActive() {
|
||
if (warmT) window.clearTimeout(warmT);
|
||
warmT = window.setTimeout(() => {
|
||
warmT = null;
|
||
if (ents[at]) router.prefetchSegs(ents[at].segs);
|
||
}, 200);
|
||
}
|
||
|
||
function setActive(i) {
|
||
if (!opts.length) { at = -1; input.removeAttribute('aria-activedescendant'); return; }
|
||
at = (i + opts.length) % opts.length;
|
||
opts.forEach((o, n) => {
|
||
const on = (n === at);
|
||
o.classList.toggle('active', on);
|
||
o.setAttribute('aria-selected', on ? 'true' : 'false');
|
||
});
|
||
input.setAttribute('aria-activedescendant', opts[at].id);
|
||
opts[at].scrollIntoView({ block: 'nearest' });
|
||
warmActive();
|
||
}
|
||
|
||
function render(q) {
|
||
const entries = q ? search(q, MAX_RESULTS) : recentEntries();
|
||
ents = entries;
|
||
list.innerHTML = '';
|
||
opts = entries.map((e, i) => {
|
||
/* role="option" on the <a> itself: an option may not contain an interactive element,
|
||
* and the anchor must stay a real link — it carries the click to the router and keeps
|
||
* middle-click and "copy link" working */
|
||
const a = E('a', {
|
||
'class': 'fs-search-opt',
|
||
'role': 'option',
|
||
'id': 'fs-search-opt-' + i,
|
||
'aria-selected': 'false',
|
||
'href': L.url.apply(L, e.segs)
|
||
}, [
|
||
E('span', { 'class': 'fs-search-opt-title' }, [ e.title ]),
|
||
e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' › ') ]) : ''
|
||
]);
|
||
/* close before the click reaches the router, which re-renders the chrome underneath;
|
||
* no focus return, the user is going elsewhere */
|
||
a.addEventListener('click', () => close(false));
|
||
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
|
||
list.appendChild(a);
|
||
return a;
|
||
});
|
||
note.textContent = opts.length
|
||
? (q ? '' : _('Recently visited', 'footstrap'))
|
||
: (q ? _('No pages found', 'footstrap') : _('Start typing to find a page', 'footstrap'));
|
||
note.hidden = !note.textContent;
|
||
setActive(0);
|
||
}
|
||
|
||
function open() {
|
||
if (!ov.hidden) return;
|
||
ov.hidden = false;
|
||
btn.setAttribute('aria-expanded', 'true');
|
||
input.value = '';
|
||
render('');
|
||
input.focus();
|
||
}
|
||
|
||
function close(returnFocus = true) {
|
||
if (ov.hidden) return;
|
||
ov.hidden = true;
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
if (returnFocus) btn.focus();
|
||
}
|
||
|
||
input.addEventListener('input', () => render(input.value.trim()));
|
||
|
||
/* keys are handled on the overlay, not the input: a click on the scrim moves focus to the
|
||
* overlay itself, where Escape must still close */
|
||
ov.addEventListener('keydown', (ev) => {
|
||
switch (ev.key) {
|
||
case 'Escape':
|
||
ev.preventDefault();
|
||
close();
|
||
return;
|
||
case 'ArrowDown':
|
||
ev.preventDefault(); setActive(at + 1); return;
|
||
case 'ArrowUp':
|
||
ev.preventDefault(); setActive(at - 1); return;
|
||
case 'Home':
|
||
if (!input.value) { ev.preventDefault(); setActive(0); }
|
||
return;
|
||
case 'End':
|
||
if (!input.value) { ev.preventDefault(); setActive(opts.length - 1); }
|
||
return;
|
||
case 'Enter':
|
||
if (at < 0 || !opts[at]) return;
|
||
ev.preventDefault();
|
||
/* a synthetic click carries detail 0, which the router reads as a keyboard
|
||
* activation, so focus lands where a keyboard navigation puts it */
|
||
opts[at].click();
|
||
return;
|
||
case 'Tab':
|
||
/* aria-modal="true" promises Tab cannot walk out into the page behind, and the input
|
||
* is the dialog's only tabbable element. Escape or an outside click is the way out,
|
||
* both handing focus back to the trigger. */
|
||
ev.preventDefault();
|
||
input.focus();
|
||
return;
|
||
}
|
||
});
|
||
|
||
/* a click on the scrim — anywhere outside the box — closes */
|
||
ov.addEventListener('click', (ev) => { if (ev.target === ov) close(); });
|
||
|
||
btn.addEventListener('click', () => { ov.hidden ? open() : close(); });
|
||
|
||
/* Back and Forward are navigations no listener above can see — every other close is a user act
|
||
* on the document — so without this an open palette rides a popstate onto the next page,
|
||
* aria-modal and Tab-trapped. returnFocus=false: the router places focus itself. */
|
||
router.onNavigate(() => close(false));
|
||
|
||
/* Ctrl/Cmd+K and `/`, the two shortcuts users already have. `/` only when the user is not
|
||
* typing somewhere — an <input>, a contenteditable, or a .cbi-dropdown, where fs-select.js's
|
||
* typeahead reads it as a search character. */
|
||
document.addEventListener('keydown', (ev) => {
|
||
if (ev.defaultPrevented) return;
|
||
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
|
||
ev.preventDefault();
|
||
ov.hidden ? open() : close();
|
||
return;
|
||
}
|
||
if (ev.key !== '/' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
||
if (ev.target.closest?.('input, textarea, select, [contenteditable], .cbi-dropdown')) return;
|
||
ev.preventDefault();
|
||
open();
|
||
});
|
||
|
||
return { open, close };
|
||
}
|
||
|
||
/* the one entry point: build if this is the first gesture, then open */
|
||
function openPalette() {
|
||
if (!_built) _built = build();
|
||
if (_built) _built.open();
|
||
}
|
||
|
||
return baseclass.extend({
|
||
open: openPalette
|
||
});
|