diff --git a/assets/search-suggest.js b/assets/search-suggest.js index a15f996..87d0194 100644 --- a/assets/search-suggest.js +++ b/assets/search-suggest.js @@ -1,63 +1,18 @@ // 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. +// search box and the editor's "Insert link" modal. Fetching is htmx: the input +// requests /_search as you type (debounced; a newer request replaces an +// in-flight one) and the server renders the rows (assets/search/suggest.html). +// This file owns the dropdown's open/close lifecycle and keyboard handling. // // opts: -// onPick(result) — called when the user selects a row -// onShowAll(query) — optional; called when the footer row activates +// onPick({name}) — optional; called when the user selects a row. +// Without it rows are plain links and navigate. // 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, '"'); - } - - 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 += '' + escapeHTML(name.slice(sp[0], sp[1])) + ''; - cursor = sp[1]; - }); - out += escapeHTML(name.slice(cursor)); - return out; - } + var seq = 0; function attachSuggestions(inputEl, opts) { if (!inputEl) return; @@ -68,237 +23,116 @@ var dropdown = document.createElement('div'); dropdown.className = 'suggest-dropdown'; + dropdown.id = 'suggest-' + (++seq); host.appendChild(dropdown); - function makeRow(cls, tabbable) { - var tr = document.createElement('tr'); - tr.className = cls; - if (tabbable) tr.setAttribute('tabindex', '0'); - var td = document.createElement('td'); - tr.appendChild(td); - return { tr: tr, td: td }; + // htmx sends the input's value under its name. + if (!inputEl.name) inputEl.name = 'q'; + inputEl.setAttribute('hx-get', '/_search' + (opts.showFooter ? '?footer' : '')); + inputEl.setAttribute('hx-trigger', 'input changed delay:100ms'); + inputEl.setAttribute('hx-sync', 'this:replace'); + inputEl.setAttribute('hx-target', '#' + dropdown.id); + htmx.process(inputEl); + + var activeIdx = -1; + + function rows() { + return dropdown.querySelectorAll('.suggest-row'); } - 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 table = document.createElement('table'); - table.className = 'data-table'; - var tbody = document.createElement('tbody'); - table.appendChild(tbody); - - var tokens = tokenize(state.query); - if (state.results.length === 0) { - var empty = makeRow('is-empty', false); - empty.td.textContent = 'No matches'; - tbody.appendChild(empty.tr); - } else { - state.results.forEach(function (r, i) { - var row = makeRow('suggest-row', true); - row.tr.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.td.appendChild(nameEl); - row.td.appendChild(pathEl); - if (i === state.activeIdx) row.tr.classList.add('is-active'); - row.tr.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); - }); - tbody.appendChild(row.tr); - }); - if (opts.showFooter && state.total > state.results.length) { - var footer = makeRow('suggest-row suggest-footer', true); - footer.td.textContent = 'Show all ' + state.total + ' matches'; - var footerIdx = state.results.length; - if (state.activeIdx === footerIdx) footer.tr.classList.add('is-active'); - footer.tr.addEventListener('mousedown', function (e) { - e.preventDefault(); - pickFooter(); - }); - tbody.appendChild(footer.tr); - } - } - dropdown.appendChild(table); - 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 isOpen() { + return dropdown.classList.contains('is-open'); } function open() { - state.open = true; - render(); + if (dropdown.firstElementChild) dropdown.classList.add('is-open'); } function close() { - state.open = false; - state.activeIdx = -1; - render(); + dropdown.classList.remove('is-open'); + setActive(-1); } - 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; + function setActive(idx) { + var rs = rows(); + if (rs[activeIdx]) rs[activeIdx].classList.remove('is-active'); + activeIdx = idx; + if (rs[idx]) { + rs[idx].classList.add('is-active'); + try { rs[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {} } - state.debounceTimer = setTimeout(function () { - fetchResults(q); - }, DEBOUNCE_MS); } function moveActive(delta) { - var n = rowCount(); + var n = rows().length; if (n === 0) return; - var next = state.activeIdx + delta; + var next = 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('tr.is-active'); - if (active && active.scrollIntoView) { - try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {} + setActive(next); + } + + // pick activates a row: onPick for data rows when given, otherwise + // (and always for the footer) the row's link. + function pick(row) { + close(); + if (opts.onPick && !row.classList.contains('suggest-footer')) { + opts.onPick({ name: row.dataset.name }); + } else { + row.querySelector('a').click(); } } - 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)) { + dropdown.addEventListener('htmx:afterSwap', function () { + activeIdx = -1; + if (document.activeElement === inputEl && dropdown.firstElementChild) { open(); + } else { + close(); } }); - inputEl.addEventListener('blur', function () { - // Delay so click/mousedown on a row still resolves. - state.blurTimer = setTimeout(close, 150); + + // mousedown keeps focus in the input so blur doesn't close the + // dropdown before the click lands. + dropdown.addEventListener('mousedown', function (e) { e.preventDefault(); }); + dropdown.addEventListener('click', function (e) { + var row = e.target.closest('.suggest-row'); + if (!row) return; + if (opts.onPick && !row.classList.contains('suggest-footer')) { + e.preventDefault(); + pick(row); + } else { + close(); // the link navigates + } }); + + inputEl.addEventListener('focus', open); + inputEl.addEventListener('blur', close); inputEl.addEventListener('keydown', function (e) { + if (!isOpen()) return; 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) { + // Without an active row: native submit (full results page). + var row = rows()[activeIdx]; + if (row) { e.preventDefault(); - activateCurrent(); + pick(row); } - // else: native form submit behaviour (full results page) } else if (e.key === 'Tab') { - if (!state.open || rowCount() === 0) return; + if (rows().length === 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 () { @@ -310,14 +144,7 @@ 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; }, - }); + attachSuggestions(document.querySelector('header .search-input'), { showFooter: true }); }); })(); diff --git a/assets/search/suggest.html b/assets/search/suggest.html new file mode 100644 index 0000000..3b324b9 --- /dev/null +++ b/assets/search/suggest.html @@ -0,0 +1,8 @@ +{{/* Search typeahead dropdown (/_search, htmx requests). Rows are links, so a +pick in the header is plain navigation; the editor's link dialog intercepts +the click and reads data-name. Empty output closes the dropdown. */}} +{{define "suggest"}}{{if .Show}} +{{range .Results}} +{{else}} +{{end}}{{if .AllURL}} +{{end}}
{{.Highlighted}}/{{.Path}}
No matches
{{end}}{{end}} diff --git a/assets/style.css b/assets/style.css index 13fd838..d8dda33 100644 --- a/assets/style.css +++ b/assets/style.css @@ -447,11 +447,13 @@ main > h2 { } .suggest-dropdown.is-open { display: block; } .suggest-row { cursor: pointer; } -.suggest-row > td { padding: 0.4rem 0.6rem; } +.suggest-row > td { padding: 0; } +/* The link fills the row, so every part of it is the click target. */ +.suggest-link { display: block; padding: 0.4rem 0.6rem; color: inherit; text-decoration: none; } .suggest-name, .suggest-path { display: block; } .suggest-name { color: var(--text); } .suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; } -.suggest-footer > td { color: var(--link); font-size: var(--font-sm); } +.suggest-footer .suggest-link { color: var(--link); font-size: var(--font-sm); } /* === Editor toolbar === Single non-wrapping row that scrolls horizontally (swipe on mobile) rather diff --git a/e2e/tests/pages.spec.js b/e2e/tests/pages.spec.js index 0f50c74..38bcb0f 100644 --- a/e2e/tests/pages.spec.js +++ b/e2e/tests/pages.spec.js @@ -270,3 +270,71 @@ test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => { await overlay.getByRole("link", { name: "Projects" }).click(); await expect(overlay).toHaveCount(0); }); + +async function seedSearch(page) { + for (const n of ["Zebra Crossing", "Zebra Finch", "Zeta1", "Zeta2", "Zeta3", "Zeta4", "Zeta5", "Zeta6"]) { + mkpage(`Suggest/${n}`, n); + } + expect((await page.request.post("/_reindex")).status()).toBe(204); +} +const suggest = (page) => page.locator("header .suggest-dropdown"); + +test("header typeahead: highlight, keyboard pick", async ({ page }) => { + await seedSearch(page); + await page.goto("/"); + const input = page.locator("header .search-input"); + await input.fill("finch"); + await expect(suggest(page)).toBeVisible(); + await expect(suggest(page).locator(".suggest-row").first().locator("strong")).toHaveText("Finch"); + await input.press("ArrowDown"); + await expect(suggest(page).locator(".suggest-row.is-active")).toContainText("Zebra Finch"); + await input.press("Enter"); + await expect(page).toHaveURL(/\/Suggest\/Zebra%20Finch\/$/); +}); + +test("header typeahead: click, footer, escape, short query", async ({ page }) => { + await seedSearch(page); + await page.goto("/"); + const input = page.locator("header .search-input"); + await input.fill("z"); + await page.waitForTimeout(300); + await expect(suggest(page)).toBeHidden(); + await input.fill("zeta"); + const footer = suggest(page).locator(".suggest-footer"); + const { total } = await (await page.request.get("/_search?q=zeta")).json(); + expect(total).toBeGreaterThan(5); + await expect(footer).toHaveText(`Show all ${total} matches`); + await expect(suggest(page).locator(".suggest-row:not(.suggest-footer)")).toHaveCount(5); + await input.press("Escape"); + await expect(suggest(page)).toBeHidden(); + await input.fill("zebra c"); + await suggest(page).locator(".suggest-name", { hasText: "Zebra Crossing" }).click(); + await expect(page).toHaveURL(/\/Suggest\/Zebra%20Crossing\/$/); + await page.goto("/"); + await page.locator("header .search-input").fill("zeta"); + await suggest(page).locator(".suggest-footer").click(); + await expect(page).toHaveURL(/\/\?q=zeta$/); +}); + +test("editor link dialog fills the query from a suggestion", async ({ page }) => { + await seedSearch(page); + await page.goto("/Suggest/Zeta1/?edit"); + await page.locator(".cm-content").click(); + await page.keyboard.press("Alt+Shift+P"); + const query = modal(page).getByPlaceholder("Search query"); + await query.fill("crossing"); + await modal(page).locator(".suggest-row", { hasText: "Zebra Crossing" }).click(); + await expect(query).toHaveValue("Zebra Crossing"); + await expect(modal(page).getByPlaceholder("Display text (optional)")).toBeFocused(); + await modal(page).getByRole("button", { name: "INSERT" }).click(); + await expect(page.locator(".cm-content")).toContainText("[[Zebra Crossing]]"); +}); + +test("editor [[ autocomplete still uses the JSON endpoint", async ({ page }) => { + await seedSearch(page); + await page.goto("/Suggest/Zeta2/?edit"); + await page.locator(".cm-content").click(); + await page.keyboard.press("Control+End"); + await page.keyboard.type("\n[[finc"); + await expect(page.locator(".cm-tooltip-autocomplete")).toContainText("Zebra Finch"); +}); diff --git a/search.go b/search.go index 29ed48f..ab5f400 100644 --- a/search.go +++ b/search.go @@ -293,20 +293,23 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s return score } -// handleSearchSuggest serves the JSON typeahead for the header dropdown and -// the editor's link picker. Caps results at 5; reports total so the UI can -// surface a "show all" footer when more matches exist. Empty/whitespace query -// is a no-op (200 with empty results), not a 400 — every keystroke fires this. +type suggestResult struct { + Name string `json:"name"` + Path string `json:"path"` + URL string `json:"url"` +} + +// handleSearchSuggest serves the typeahead: rendered rows for the dropdown +// (header search, editor link dialog; htmx requests) and JSON for the +// editor's [[ autocomplete. Caps results at 5; reports total so the dropdown +// can offer a "show all" footer (?footer) when more matches exist. An +// empty/whitespace query is a no-op (200, no results), not a 400 — every +// keystroke fires this. func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) { if !h.checkAuth(w, r) { return } query := strings.TrimSpace(r.URL.Query().Get("q")) - type suggestResult struct { - Name string `json:"name"` - Path string `json:"path"` - URL string `json:"url"` - } type suggestResp struct { Query string `json:"query"` Results []suggestResult `json:"results"` @@ -328,12 +331,92 @@ func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) { }) } } + // htmx (the typeahead dropdown) gets rendered rows; other callers (the + // editor's [[ autocomplete) keep the JSON. + w.Header().Set("Vary", "HX-Request") + if isHX(r) { + renderSuggest(w, resp.Query, resp.Results, resp.Total, r.URL.Query().Has("footer")) + return + } w.Header().Set("Content-Type", "application/json; charset=utf-8") if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("search suggest encode error: %v", err) } } +var suggestTmpl = template.Must(template.ParseFS(assets, "assets/search/suggest.html")) + +// suggestMinLen is the shortest query (in characters) the typeahead answers; +// shorter ones render nothing, which closes the dropdown. +const suggestMinLen = 2 + +type suggestRow struct { + Name, Path, URL string + Highlighted template.HTML +} + +// renderSuggest writes the typeahead dropdown for query. footer adds a +// "Show all" row linking to the full results page when some were cut off. +func renderSuggest(w http.ResponseWriter, query string, results []suggestResult, total int, footer bool) { + data := struct { + Show bool + Results []suggestRow + Total int + AllURL string + }{Show: utf8.RuneCountInString(query) >= suggestMinLen, Total: total} + tokens := suggestTokens(query) + for _, res := range results { + data.Results = append(data.Results, suggestRow{res.Name, res.Path, res.URL, highlightMatches(res.Name, tokens)}) + } + if footer && total > len(results) { + data.AllURL = "/?q=" + url.QueryEscape(query) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := suggestTmpl.ExecuteTemplate(w, "suggest", data); err != nil { + log.Printf("suggest template error: %v", err) + } +} + +// suggestTokens lowercases query and splits it on anything that is not a +// letter or digit. +func suggestTokens(query string) []string { + return strings.FieldsFunc(strings.ToLower(query), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsNumber(r) + }) +} + +// highlightMatches bolds the first occurrence of each token in name +// (case-insensitive), merging overlapping spans, and escapes the rest. +func highlightMatches(name string, tokens []string) template.HTML { + lower := strings.ToLower(name) + if len(lower) != len(name) { + // Lowercasing changed byte lengths, so offsets would not line up. + return template.HTML(template.HTMLEscapeString(name)) + } + type span struct{ from, to int } + var spans []span + for _, t := range tokens { + if i := strings.Index(lower, t); i >= 0 { + spans = append(spans, span{i, i + len(t)}) + } + } + sort.Slice(spans, func(i, j int) bool { return spans[i].from < spans[j].from }) + var b strings.Builder + cursor := 0 + for i := 0; i < len(spans); i++ { + sp := spans[i] + for i+1 < len(spans) && spans[i+1].from <= sp.to { + i++ + sp.to = max(sp.to, spans[i].to) + } + b.WriteString(template.HTMLEscapeString(name[cursor:sp.from])) + b.WriteString("" + template.HTMLEscapeString(name[sp.from:sp.to]) + "") + cursor = sp.to + } + b.WriteString(template.HTMLEscapeString(name[cursor:])) + return template.HTML(b.String()) +} + // handleReindex rebuilds the folder index synchronously and returns 204. // The frontend reloads the page on success. Serialized via buildMu so a // double-click waits rather than running two walks in parallel. diff --git a/suggest_test.go b/suggest_test.go new file mode 100644 index 0000000..1fe710b --- /dev/null +++ b/suggest_test.go @@ -0,0 +1,21 @@ +package main + +import "testing" + +func TestHighlightMatches(t *testing.T) { + cases := []struct{ name, query, want string }{ + {"Project Alpha", "alpha", "Project Alpha"}, + // Overlapping and adjacent spans merge. + {"Project Alpha", "pro ject", "Project Alpha"}, + {"Project Alpha", "roj proj", "Project Alpha"}, + {"a&c", "b", "a<b>&c"}, + {"Notes", "zzz", "Notes"}, + // Lowercasing changes the byte length: no highlight, still escaped. + {"İstanbul ", "stan", "İstanbul <x>"}, + } + for _, c := range cases { + if got := string(highlightMatches(c.name, suggestTokens(c.query))); got != c.want { + t.Errorf("highlightMatches(%q, %q) = %q, want %q", c.name, c.query, got, c.want) + } + } +}