Improve serach function with quick suggestions

This commit is contained in:
2026-05-18 15:10:34 +02:00
parent 8244874fe3
commit a25d5434ac
8 changed files with 545 additions and 61 deletions
+57 -29
View File
@@ -64,43 +64,71 @@
if (result) applyResult(result);
}
function promptDisplayText(initial, onDone) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'modal-input';
input.placeholder = 'Display text (optional)';
if (initial) input.value = initial;
var handle = openModal({
title: 'Insert link — display text?',
body: input,
confirm: {
label: 'INSERT',
onConfirm: function () {
handle.close();
onDone(input.value.trim());
}
}
});
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
// path, no empty/dot segments. Used to gate the INSERT confirm button.
function isValidWikiTarget(p) {
if (!p || p[0] !== '/') return false;
var trimmed = p.replace(/^\/+|\/+$/g, '');
if (trimmed === '') return true;
var segs = trimmed.split('/');
for (var i = 0; i < segs.length; i++) {
if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
}
return true;
}
function insertWikilink() {
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
openTreePicker({
var container = document.createElement('div');
var targetWrap = document.createElement('div');
var targetInput = document.createElement('input');
targetInput.type = 'text';
targetInput.className = 'modal-input';
targetInput.placeholder = 'Page path or search…';
targetWrap.appendChild(targetInput);
var displayInput = document.createElement('input');
displayInput.type = 'text';
displayInput.className = 'modal-input';
displayInput.placeholder = 'Display text (optional)';
if (sel) displayInput.value = sel;
container.appendChild(targetWrap);
container.appendChild(displayInput);
var handle = openModal({
title: 'Insert link',
mode: 'any',
initialPath: '/',
confirmLabel: 'NEXT',
onSelect: function (path, kind) {
if (kind === 'folder') {
promptDisplayText(sel, function (display) {
insertAtCursor(display ? '[[' + path + '::' + display + ']]' : '[[' + path + ']]');
});
} else {
var name = path.split('/').pop();
insertAtCursor('[' + (sel || name) + '](' + path + ')');
body: container,
confirm: {
label: 'INSERT',
initiallyDisabled: true,
onConfirm: function () {
var target = targetInput.value.trim();
if (!isValidWikiTarget(target)) return;
var display = displayInput.value.trim();
handle.close();
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
}
}
});
function updateConfirm() {
handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim()));
}
targetInput.addEventListener('input', updateConfirm);
window.attachSuggestions(targetInput, {
showFooter: false,
container: targetWrap,
onPick: function (r) {
targetInput.value = '/' + r.path;
updateConfirm();
displayInput.focus();
displayInput.select();
}
});
}
// --- Actions ---
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M8 13V3M3 8l5-5 5 5"/>
</svg>

After

Width:  |  Height:  |  Size: 233 B

+4 -5
View File
@@ -10,6 +10,7 @@
<link rel="stylesheet" href="/_/style.css" />
<script src="/_/modal.js"></script>
<script src="/_/global-shortcuts.js"></script>
<script src="/_/search-suggest.js" defer></script>
<script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script>
{{block "headScripts" .}}{{end}}
@@ -17,17 +18,15 @@
<body>
<header>
<nav class="breadcrumb">
<a href="/" tabindex="-1"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
{{range .Crumbs}}
<span class="sep">/</span><a href="{{.URL}}" tabindex="-1">{{.Name}}</a>
{{end}}
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
{{if .ParentURL}}<a class="nav-up" href="{{.ParentURL}}" tabindex="-1" title="Up" aria-label="Up">Up <svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square"><path d="M8 13V3M3 8l5-5 5 5"/></svg></a>{{end}}
</nav>
{{if not .EditMode}}
<form class="search-form" action="/" method="get">
<input class="search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
</form>
{{end}}
{{block "headerActions" .}}{{end}}
<div class="header-actions">{{block "headerActions" .}}{{end}}</div>
</header>
<div class="page-wrap">
<main>
+313
View File
@@ -0,0 +1,313 @@
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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; },
});
});
})();
+101 -6
View File
@@ -72,22 +72,36 @@ a:hover {
color: var(--link-hover);
}
/* === Header === */
/* === Header ===
Three-column grid (breadcrumbs left, search centre, actions right) using
named grid-areas so the centre stays reserved even when search is hidden
in editor mode. Mobile (≤1100px) collapses to a two-row layout — see the
responsive block at the bottom of this file. */
header {
padding: 0.75rem 1rem;
border-bottom: 1px dashed var(--secondary);
display: flex;
display: grid;
grid-template-columns: 1fr minmax(0, auto) 1fr;
grid-template-areas: "crumbs search actions";
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.breadcrumb {
grid-area: crumbs;
display: flex;
align-items: center;
gap: 0.25rem;
min-width: 0;
}
.header-actions {
grid-area: actions;
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
flex: 1;
}
.logo {
@@ -96,8 +110,14 @@ header {
vertical-align: center;
}
.sep {
.nav-up {
display: inline-flex;
align-items: center;
color: var(--secondary);
padding: 0 0.25rem;
}
.nav-up:hover {
color: var(--primary-hover);
}
.btn {
@@ -429,8 +449,13 @@ textarea {
/* === Search === */
.search-form {
grid-area: search;
display: flex;
gap: 0.25rem;
position: relative;
justify-self: center;
width: 24rem;
max-width: 100%;
}
.search-input {
background: var(--bg-panel);
@@ -440,13 +465,73 @@ textarea {
font-size: 0.9rem;
padding: 0.3rem 0.5rem;
min-width: 0;
width: 12rem;
width: 100%;
max-width: 100%;
outline: none;
}
.search-input:focus {
border-color: var(--primary-hover);
}
/* === Suggestion dropdown (header + editor link picker) ===
Anchored to a position:relative host (search-form, or the modal body
for the link picker). Visuals mirror .dropdown-menu — dashed border,
bg-panel-hover for the active row. */
.suggest-host {
position: relative;
}
.suggest-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 200;
background: var(--bg-panel);
border: 1px dashed var(--secondary);
border-top: none;
display: none;
}
.suggest-dropdown.is-open {
display: block;
}
.suggest-row {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.4rem 0.6rem;
cursor: pointer;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: left;
width: 100%;
}
.suggest-row + .suggest-row {
border-top: 1px solid var(--secondary);
}
.suggest-row:hover,
.suggest-row.is-active {
background: var(--bg-panel-hover);
}
.suggest-row.is-empty {
color: var(--text-muted);
cursor: default;
}
.suggest-row.is-empty:hover {
background: none;
}
.suggest-name {
color: var(--text);
}
.suggest-path {
color: var(--text-muted);
font-size: 0.8rem;
}
.suggest-footer {
color: var(--link);
font-size: 0.85rem;
}
.search-card {
display: flex;
flex-direction: column;
@@ -880,6 +965,16 @@ aside.sidebar:empty {
.page-wrap {
grid-template-columns: 1fr;
}
/* Single-row mobile header: logo + Up icon are compact so search
can take the middle flex column, with actions on the right. */
header {
grid-template-columns: auto 1fr auto;
}
.search-form {
width: 100%;
max-width: none;
justify-self: stretch;
}
/* Sidebar on mobile is a floating overlay toggled by the FAB. The aside
itself is the scroll container; its children render at natural height. */
.sidebar {