314 lines
11 KiB
JavaScript
314 lines
11 KiB
JavaScript
// search-suggest.js — instant typeahead dropdown.
|
|
//
|
|
// Exposes window.attachSuggestions(inputEl, opts) used by both the header
|
|
// search box and the editor's "Insert link" modal. Owns: debounced fetching,
|
|
// request ordering, DOM creation, keyboard handling, open/close lifecycle.
|
|
//
|
|
// opts:
|
|
// onPick(result) — called when the user selects a row
|
|
// onShowAll(query) — optional; called when the footer row activates
|
|
// showFooter (bool) — show the "Show all N matches" footer row
|
|
// container (Element) — optional parent (defaults to inputEl.parentNode)
|
|
(function () {
|
|
var DEBOUNCE_MS = 100;
|
|
var MIN_QUERY_LEN = 2;
|
|
|
|
function escapeHTML(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function tokenize(s) {
|
|
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
}
|
|
|
|
// highlight bolds the substring spans in `name` that match any of the
|
|
// query tokens (case-insensitive). Overlapping/adjacent spans merge.
|
|
// Returns a safe HTML string.
|
|
function highlight(name, tokens) {
|
|
if (!tokens.length) return escapeHTML(name);
|
|
var lower = name.toLowerCase();
|
|
var spans = [];
|
|
tokens.forEach(function (t) {
|
|
if (!t) return;
|
|
var idx = lower.indexOf(t);
|
|
if (idx >= 0) spans.push([idx, idx + t.length]);
|
|
});
|
|
if (!spans.length) return escapeHTML(name);
|
|
spans.sort(function (a, b) { return a[0] - b[0]; });
|
|
var merged = [spans[0].slice()];
|
|
for (var i = 1; i < spans.length; i++) {
|
|
var last = merged[merged.length - 1];
|
|
if (spans[i][0] <= last[1]) {
|
|
last[1] = Math.max(last[1], spans[i][1]);
|
|
} else {
|
|
merged.push(spans[i].slice());
|
|
}
|
|
}
|
|
var out = '';
|
|
var cursor = 0;
|
|
merged.forEach(function (sp) {
|
|
out += escapeHTML(name.slice(cursor, sp[0]));
|
|
out += '<strong>' + escapeHTML(name.slice(sp[0], sp[1])) + '</strong>';
|
|
cursor = sp[1];
|
|
});
|
|
out += escapeHTML(name.slice(cursor));
|
|
return out;
|
|
}
|
|
|
|
function attachSuggestions(inputEl, opts) {
|
|
if (!inputEl) return;
|
|
opts = opts || {};
|
|
var host = opts.container || inputEl.parentNode;
|
|
if (!host) return;
|
|
host.classList.add('suggest-host');
|
|
|
|
var dropdown = document.createElement('div');
|
|
dropdown.className = 'suggest-dropdown';
|
|
host.appendChild(dropdown);
|
|
|
|
var state = {
|
|
results: [],
|
|
total: 0,
|
|
query: '',
|
|
activeIdx: -1,
|
|
open: false,
|
|
reqSeq: 0,
|
|
debounceTimer: null,
|
|
blurTimer: null,
|
|
};
|
|
|
|
function rowCount() {
|
|
var n = state.results.length;
|
|
if (state.results.length === 0 && state.query.length >= MIN_QUERY_LEN) {
|
|
return 0; // "no matches" row is non-interactive
|
|
}
|
|
if (opts.showFooter && state.total > state.results.length) n += 1;
|
|
return n;
|
|
}
|
|
|
|
function isFooterIdx(idx) {
|
|
return opts.showFooter
|
|
&& state.total > state.results.length
|
|
&& idx === state.results.length;
|
|
}
|
|
|
|
function render() {
|
|
dropdown.textContent = '';
|
|
if (!state.open) {
|
|
dropdown.classList.remove('is-open');
|
|
return;
|
|
}
|
|
var tokens = tokenize(state.query);
|
|
if (state.results.length === 0) {
|
|
var empty = document.createElement('div');
|
|
empty.className = 'suggest-row is-empty';
|
|
empty.textContent = 'No matches';
|
|
dropdown.appendChild(empty);
|
|
} else {
|
|
state.results.forEach(function (r, i) {
|
|
var row = document.createElement('button');
|
|
row.type = 'button';
|
|
row.className = 'suggest-row';
|
|
row.setAttribute('data-idx', String(i));
|
|
var nameEl = document.createElement('span');
|
|
nameEl.className = 'suggest-name';
|
|
nameEl.innerHTML = highlight(r.name, tokens);
|
|
var pathEl = document.createElement('span');
|
|
pathEl.className = 'suggest-path';
|
|
pathEl.textContent = '/' + r.path;
|
|
row.appendChild(nameEl);
|
|
row.appendChild(pathEl);
|
|
if (i === state.activeIdx) row.classList.add('is-active');
|
|
row.addEventListener('mousedown', function (e) {
|
|
// mousedown (not click) so the input doesn't blur-close
|
|
// the dropdown before the pick handler fires.
|
|
e.preventDefault();
|
|
pick(i);
|
|
});
|
|
dropdown.appendChild(row);
|
|
});
|
|
if (opts.showFooter && state.total > state.results.length) {
|
|
var footer = document.createElement('button');
|
|
footer.type = 'button';
|
|
footer.className = 'suggest-row suggest-footer';
|
|
footer.textContent = 'Show all ' + state.total + ' matches';
|
|
var footerIdx = state.results.length;
|
|
if (state.activeIdx === footerIdx) footer.classList.add('is-active');
|
|
footer.addEventListener('mousedown', function (e) {
|
|
e.preventDefault();
|
|
pickFooter();
|
|
});
|
|
dropdown.appendChild(footer);
|
|
}
|
|
}
|
|
dropdown.classList.add('is-open');
|
|
}
|
|
|
|
function pick(idx) {
|
|
var r = state.results[idx];
|
|
if (!r) return;
|
|
close();
|
|
if (opts.onPick) opts.onPick(r);
|
|
}
|
|
|
|
function pickFooter() {
|
|
close();
|
|
if (opts.onShowAll) {
|
|
opts.onShowAll(state.query);
|
|
} else if (inputEl.form) {
|
|
inputEl.form.submit();
|
|
} else {
|
|
window.location.href = '/?q=' + encodeURIComponent(state.query);
|
|
}
|
|
}
|
|
|
|
function open() {
|
|
state.open = true;
|
|
render();
|
|
}
|
|
|
|
function close() {
|
|
state.open = false;
|
|
state.activeIdx = -1;
|
|
render();
|
|
}
|
|
|
|
function fetchResults(query) {
|
|
var seq = ++state.reqSeq;
|
|
fetch('/_search?q=' + encodeURIComponent(query), {
|
|
credentials: 'same-origin',
|
|
headers: { 'Accept': 'application/json' },
|
|
}).then(function (r) {
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
}).then(function (resp) {
|
|
if (seq !== state.reqSeq) return; // stale
|
|
state.results = resp.results || [];
|
|
state.total = resp.total || 0;
|
|
state.query = resp.query || query;
|
|
state.activeIdx = -1;
|
|
open();
|
|
}).catch(function () {
|
|
if (seq !== state.reqSeq) return;
|
|
state.results = [];
|
|
state.total = 0;
|
|
close();
|
|
});
|
|
}
|
|
|
|
function onInput() {
|
|
var q = inputEl.value.trim();
|
|
state.query = q;
|
|
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
if (q.length < MIN_QUERY_LEN) {
|
|
state.reqSeq++; // invalidate any in-flight response
|
|
state.results = [];
|
|
state.total = 0;
|
|
close();
|
|
return;
|
|
}
|
|
state.debounceTimer = setTimeout(function () {
|
|
fetchResults(q);
|
|
}, DEBOUNCE_MS);
|
|
}
|
|
|
|
function moveActive(delta) {
|
|
var n = rowCount();
|
|
if (n === 0) return;
|
|
var next = state.activeIdx + delta;
|
|
if (next < 0) next = n - 1;
|
|
if (next >= n) next = 0;
|
|
state.activeIdx = next;
|
|
render();
|
|
// Keep the active row in view.
|
|
var active = dropdown.querySelector('.suggest-row.is-active');
|
|
if (active && active.scrollIntoView) {
|
|
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
|
}
|
|
}
|
|
|
|
function activateCurrent() {
|
|
if (state.activeIdx < 0) return false;
|
|
if (isFooterIdx(state.activeIdx)) {
|
|
pickFooter();
|
|
return true;
|
|
}
|
|
pick(state.activeIdx);
|
|
return true;
|
|
}
|
|
|
|
inputEl.addEventListener('input', onInput);
|
|
inputEl.addEventListener('focus', function () {
|
|
if (state.blurTimer) {
|
|
clearTimeout(state.blurTimer);
|
|
state.blurTimer = null;
|
|
}
|
|
if (inputEl.value.trim().length >= MIN_QUERY_LEN
|
|
&& (state.results.length || state.query)) {
|
|
open();
|
|
}
|
|
});
|
|
inputEl.addEventListener('blur', function () {
|
|
// Delay so click/mousedown on a row still resolves.
|
|
state.blurTimer = setTimeout(close, 150);
|
|
});
|
|
inputEl.addEventListener('keydown', function (e) {
|
|
if (e.key === 'ArrowDown') {
|
|
if (!state.open) return;
|
|
e.preventDefault();
|
|
moveActive(1);
|
|
} else if (e.key === 'ArrowUp') {
|
|
if (!state.open) return;
|
|
e.preventDefault();
|
|
moveActive(-1);
|
|
} else if (e.key === 'Escape') {
|
|
if (!state.open) return;
|
|
e.preventDefault();
|
|
close();
|
|
} else if (e.key === 'Enter') {
|
|
if (state.open && state.activeIdx >= 0) {
|
|
e.preventDefault();
|
|
activateCurrent();
|
|
}
|
|
// else: native form submit behaviour (full results page)
|
|
} else if (e.key === 'Tab') {
|
|
if (!state.open || rowCount() === 0) return;
|
|
e.preventDefault();
|
|
moveActive(e.shiftKey ? -1 : 1);
|
|
}
|
|
});
|
|
|
|
// Click outside the host closes the dropdown.
|
|
document.addEventListener('mousedown', function (e) {
|
|
if (!state.open) return;
|
|
if (host.contains(e.target)) return;
|
|
close();
|
|
});
|
|
|
|
return {
|
|
close: close,
|
|
destroy: function () {
|
|
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown);
|
|
host.classList.remove('suggest-host');
|
|
},
|
|
};
|
|
}
|
|
|
|
window.attachSuggestions = attachSuggestions;
|
|
|
|
// Auto-bind to the header search input. Header search submits the form
|
|
// for the "show all" action; we route to a navigate-on-pick handler.
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
var input = document.querySelector('header .search-input');
|
|
if (!input) return;
|
|
attachSuggestions(input, {
|
|
showFooter: true,
|
|
onPick: function (r) { window.location.href = r.url; },
|
|
});
|
|
});
|
|
})();
|