From 7e37b73779caa4db9bd5fdcac88d7350fc4e1f54 Mon Sep 17 00:00:00 2001 From: Xin Date: Sun, 6 Aug 2023 01:06:32 +0100 Subject: [PATCH] feat: revamp search experience chore: hide toc on small screen chore: make sidebar responsive --- assets/css/components/search.css | 38 +++ assets/css/components/sidebar.css | 9 + assets/css/styles.css | 3 + assets/js/flexsearch.js | 328 +++++++++++++++++++++----- assets/json/search-data.json | 23 +- layouts/_default/single.html | 2 +- layouts/partials/head.html | 1 + layouts/partials/scripts.html | 10 +- layouts/partials/search.html | 4 +- layouts/partials/sidebar.html | 2 +- layouts/partials/toc.html | 2 +- layouts/partials/utils/fragments.html | 45 ++++ 12 files changed, 390 insertions(+), 77 deletions(-) create mode 100644 assets/css/components/search.css create mode 100644 assets/css/components/sidebar.css create mode 100644 layouts/partials/utils/fragments.html diff --git a/assets/css/components/search.css b/assets/css/components/search.css new file mode 100644 index 0000000..1994f84 --- /dev/null +++ b/assets/css/components/search.css @@ -0,0 +1,38 @@ +.search-wrapper { + li { + @apply mx-2.5 break-words rounded-md contrast-more:border text-gray-800 contrast-more:border-transparent dark:text-gray-300; + a { + @apply block scroll-m-12 px-2.5 py-2; + } + + .title { + @apply text-base font-semibold leading-5; + } + + .active { + @apply rounded-md bg-primary-500/10 contrast-more:border-primary-500; + } + } + + .no-result { + @apply block select-none p-8 text-center text-sm text-gray-400; + } + + .prefix { + @apply mx-2.5 mb-2 mt-6 select-none border-b border-black/10 px-2.5 pb-1.5 text-xs font-semibold + uppercase text-gray-500 first:mt-0 dark:border-white/20 dark:text-gray-300 contrast-more:border-gray-600 + contrast-more:text-gray-900 contrast-more:dark:border-gray-50 contrast-more:dark:text-gray-50; + } + + .excerpt { + @apply overflow-hidden text-ellipsis mt-1 text-sm leading-[1.35rem] text-gray-600 dark:text-gray-400 contrast-more:dark:text-gray-50; + display: -webkit-box; + line-clamp: 1; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + } + + .match { + @apply text-primary-600; + } +} diff --git a/assets/css/components/sidebar.css b/assets/css/components/sidebar.css new file mode 100644 index 0000000..3345bf0 --- /dev/null +++ b/assets/css/components/sidebar.css @@ -0,0 +1,9 @@ +@media (max-width: 767px) { + .sidebar-container { + @apply fixed pt-[calc(var(--navbar-height))] top-0 w-full bottom-0 z-[15] overscroll-contain bg-white dark:bg-dark; + /* transition: transform 0.8s cubic-bezier(0.52, 0.16, 0.04, 1); */ + will-change: transform, opacity; + contain: layout style; + backface-visibility: hidden; + } +} diff --git a/assets/css/styles.css b/assets/css/styles.css index da3936e..275edb6 100644 --- a/assets/css/styles.css +++ b/assets/css/styles.css @@ -6,6 +6,8 @@ @import "highlight.css"; @import "components/cards.css"; @import "components/steps.css"; +@import "components/search.css"; +@import "components/sidebar.css"; html { @apply text-base antialiased; @@ -19,6 +21,7 @@ body { :root { --primary-hue: 212deg; + --navbar-height: 4rem; } .dark { diff --git a/assets/js/flexsearch.js b/assets/js/flexsearch.js index dd3f1f9..23dbf6b 100644 --- a/assets/js/flexsearch.js +++ b/assets/js/flexsearch.js @@ -1,86 +1,306 @@ +// Search functionality using FlexSearch. + +// Render the search data as JSON. // {{ $searchDataFile := printf "%s.search-data.json" .Language.Lang }} -// {{ $searchData := resources.Get "json/search-data.json" | resources.ExecuteAsTemplate $searchDataFile . | resources.Minify | resources.Fingerprint }} +// {{ $searchData := resources.Get "json/search-data.json" | resources.ExecuteAsTemplate $searchDataFile . }} +// {{ if hugo.IsProduction }} +// {{ $searchData := $searchData | minify | fingerprint }} +// {{ end }} (function () { const searchDataURL = '{{ $searchData.Permalink }}'; - console.log('searchDataURL', searchDataURL); - const indexConfig = { - tokenize: "full", - cache: 100, - document: { - id: 'id', - store: ['title', 'href', 'section'], - index: ["title", "content"] - }, - context: { - resolution: 9, - depth: 2, - bidirectional: true + const inputElement = document.getElementById('search-input'); + const resultsElement = document.getElementById('search-results'); + + inputElement.addEventListener('focus', init); + inputElement.addEventListener('keyup', search); + inputElement.addEventListener('keydown', handleKeyDown); + + const INPUTS = ['input', 'select', 'button', 'textarea'] + + // Focus the search input when pressing ctrl+k/cmd+k or /. + document.addEventListener('keydown', function (e) { + const activeElement = document.activeElement; + const tagName = activeElement && activeElement.tagName; + if ( + inputElement === activeElement || + !tagName || + INPUTS.includes(tagName) || + (activeElement && activeElement.isContentEditable)) + return; + + if ( + e.key === '/' || + (e.key === 'k' && + (e.metaKey /* for Mac */ || /* for non-Mac */ e.ctrlKey)) + ) { + e.preventDefault(); + inputElement.focus(); + } else if (e.key === 'Escape' && inputElement.value) { + inputElement.blur(); + } + }); + + // Get the currently active result and its index. + function getActiveResult() { + const result = resultsElement.querySelector('.active'); + if (result) { + const index = parseInt(result.getAttribute('data-index')); + return { result, index }; + } + return { result: undefined, index: -1 }; + } + + // Set the active result by index. + function setActiveResult(index) { + const { result: activeResult } = getActiveResult(); + activeResult && activeResult.classList.remove('active'); + const result = resultsElement.querySelector(`[data-index="${index}"]`); + if (result) { + result.classList.add('active'); + result.focus(); } } - window.flexSearchIndex = new FlexSearch.Document(indexConfig); - const input = document.getElementById('search-input'); - const results = document.getElementById('search-results'); + // Get the number of search results from the DOM. + function getResultsLength() { + return resultsElement.querySelectorAll('li').length; + } - input.addEventListener('focus', init); - input.addEventListener('keyup', search); + // Finish the search by hiding the results and clearing the input. + function finishSearch() { + hideSearchResults(); + inputElement.value = ''; + inputElement.blur(); + } + function hideSearchResults() { + resultsElement.classList.add('hidden'); + } - /** - * Initializes the search functionality by adding the necessary event listeners and fetching the search data. - */ + // Handle keyboard events. + function handleKeyDown(e) { + const resultsLength = getResultsLength(); + const { result: activeResult, index: activeIndex } = getActiveResult(); + + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + if (activeIndex > 0) setActiveResult(activeIndex - 1); + break; + case 'ArrowDown': + e.preventDefault(); + if (activeIndex + 1 < resultsLength) setActiveResult(activeIndex + 1); + break; + case 'Enter': + e.preventDefault(); + if (activeResult) { + activeResult.click(); + } + finishSearch(); + case 'Escape': + e.preventDefault(); + hideSearchResults(); + inputElement.blur(); + break; + } + } + + // Initializes the search. function init() { - input.removeEventListener('focus', init); // init once + inputElement.removeEventListener('focus', init); + preloadIndex().then(search); + } - fetch(searchDataURL).then(resp => resp.json()).then(pages => { - pages.forEach(page => { - window.flexSearchIndex.add(page); + // Preload the search index. + async function preloadIndex() { + window.pageIndex = new FlexSearch.Document({ + tokenize: 'forward', + cache: 100, + document: { + id: 'id', + store: ['title'], + index: "content" + } + }); + + window.sectionIndex = new FlexSearch.Document({ + tokenize: 'forward', + cache: 100, + document: { + id: 'id', + store: ['title', 'content', 'url', 'display'], + index: "content", + tag: 'pageId' + } + }); + + const resp = await fetch(searchDataURL); + const data = await resp.json(); + let pageId = 0; + for (const route in data) { + let pageContent = ''; + ++pageId; + + for (const heading in data[route].data) { + const [hash, text] = heading.split('#'); + const url = route.trimEnd('/') + (hash ? '#' + hash : ''); + const title = text || data[route].title; + + const content = data[route].data[heading] || ''; + const paragraphs = content.split('\n').filter(Boolean); + + sectionIndex.add({ + id: url, + url, + title, + pageId: `page_${pageId}`, + content: title, + ...(paragraphs[0] && { display: paragraphs[0] }) + }); + + for (let i = 0; i < paragraphs.length; i++) { + sectionIndex.add({ + id: `${url}_${i}`, + url, + title, + pageId: `page_${pageId}`, + content: paragraphs[i] + }); + } + + pageContent += ` ${title} ${content}`; + } + + window.pageIndex.add({ + id: pageId, + title: data[route].title, + content: pageContent }); - }).then(search); + + } } function search() { - console.log('search', input.value); - while (results.firstChild) { - results.removeChild(results.firstChild); - } - - if (!input.value) { + const query = inputElement.value; + if (!inputElement.value) { + hideSearchResults(); return; } - const searchHits = window.flexSearchIndex.search(input.value, { limit: 5, enrich: true }); - showResults(searchHits); + while (resultsElement.firstChild) { + resultsElement.removeChild(resultsElement.firstChild); + } + resultsElement.classList.remove('hidden'); + + const pageResults = window.pageIndex.search(query, 5, { enrich: true, suggest: true })[0]?.result || []; + + const results = []; + const pageTitleMatches = {}; + + for (let i = 0; i < pageResults.length; i++) { + const result = pageResults[i]; + pageTitleMatches[i] = 0; + + // Show the top 5 results for each page + const sectionResults = window.sectionIndex.search(query, 5, { enrich: true, suggest: true, tag: `page_${result.id}` })[0]?.result || []; + let isFirstItemOfPage = true + const occurred = {} + + for (let j = 0; j < sectionResults.length; j++) { + const { doc } = sectionResults[j] + const isMatchingTitle = doc.display !== undefined + if (isMatchingTitle) { + pageTitleMatches[i]++ + } + const { url, title } = doc + const content = doc.display || doc.content + + if (occurred[url + '@' + content]) continue + occurred[url + '@' + content] = true + results.push({ + _page_rk: i, + _section_rk: j, + route: url, + prefix: isFirstItemOfPage ? result.doc.title : undefined, + children: { title, content } + }) + isFirstItemOfPage = false + } + } + const sortedResults = results + .sort((a, b) => { + // Sort by number of matches in the title. + if (a._page_rk === b._page_rk) { + return a._section_rk - b._section_rk + } + if (pageTitleMatches[a._page_rk] !== pageTitleMatches[b._page_rk]) { + return pageTitleMatches[b._page_rk] - pageTitleMatches[a._page_rk] + } + return a._page_rk - b._page_rk + }) + .map(res => ({ + id: `${res._page_rk}_${res._section_rk}`, + route: res.route, + prefix: res.prefix, + children: res.children + })); + displayResults(sortedResults, query); } - function showResults(hits) { - console.log('showResults', hits); - const flatResults = new Map(); // keyed by href to dedupe hits - for (const result of hits.flatMap(r => r.result)) { - if (flatResults.has(result.doc.href)) continue; - flatResults.set(result.doc.href, result.doc); + + + function displayResults(results, query) { + if (!results.length) { + resultsElement.innerHTML = `No results found.`; + return; } - console.log('flatResults', flatResults); - const create = (str) => { + + function highlightMatches(text, query) { + const escapedQuery = query.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&'); + const regex = new RegExp(escapedQuery, 'gi'); + return text.replace(regex, (match) => `${match}`); + } + + // Create a DOM element from the HTML string. + function createElement(str) { const div = document.createElement('div'); div.innerHTML = str.trim(); return div.firstChild; } + + function handleMouseMove(e) { + const target = e.target.closest('a'); + if (target) { + const active = resultsElement.querySelector('a.active'); + if (active) { + active.classList.remove('active'); + } + target.classList.add('active'); + } + } + const fragment = document.createDocumentFragment(); - - console.log(fragment) - - for (const result of flatResults.values()) { - const li = create(` -
  • - -
    ${result.title}
    -
    -
  • `); + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.prefix) { + fragment.appendChild(createElement(` +
    ${result.prefix}
    `)); + } + let li = createElement(` +
  • + +
    `+ highlightMatches(result.children.title, query) + `
    ` + + (result.children.content ? + `
    ` + highlightMatches(result.children.content, query) + `
    ` : '') + ` +
    +
  • `); + li.addEventListener('mousemove', handleMouseMove); + li.addEventListener('keydown', handleKeyDown); + li.querySelector('a').addEventListener('click', finishSearch); fragment.appendChild(li); } - results.appendChild(fragment); + resultsElement.appendChild(fragment); } })(); diff --git a/assets/json/search-data.json b/assets/json/search-data.json index 94f23ef..16d3ad7 100644 --- a/assets/json/search-data.json +++ b/assets/json/search-data.json @@ -2,18 +2,13 @@ {{- $pages = where $pages "Params.excludeSearch" "!=" true -}} {{- $pages = where $pages "Content" "!=" "" -}} -[ - {{ range $index, $page := $pages }} - {{ $pageTitle := $page.LinkTitle | default $page.File.BaseFileName }} - {{ $pageContent := $page.Plain }} - {{ $pageSection := $page.Parent.LinkTitle }} +{{- $output := dict -}} - {{ if gt $index 0}},{{end}} { - "id": {{ $index }}, - "href": "{{ $page.Permalink }}", - "title": {{ $pageTitle | jsonify }}, - "section": {{ $pageSection | jsonify }}, - "content": {{ $page.Plain | jsonify }} - } - {{- end -}} -] +{{- range $index, $page := $pages -}} + {{- $pageTitle := $page.LinkTitle | default $page.File.BaseFileName -}} + {{- $pageLink := $page.RelPermalink -}} + {{- $data := partial "utils/fragments" $page -}} + {{- $output = $output | merge (dict $pageLink (dict "title" $pageTitle "data" $data)) -}} +{{- end -}} + +{{- $output | jsonify -}} diff --git a/layouts/_default/single.html b/layouts/_default/single.html index 21cc36d..59c7240 100644 --- a/layouts/_default/single.html +++ b/layouts/_default/single.html @@ -1,6 +1,6 @@ {{ define "main" }}
    -
    +

    {{ .Title }}

    diff --git a/layouts/partials/head.html b/layouts/partials/head.html index 25f90db..2a3713e 100644 --- a/layouts/partials/head.html +++ b/layouts/partials/head.html @@ -1,5 +1,6 @@ + {{ .Title }} diff --git a/layouts/partials/scripts.html b/layouts/partials/scripts.html index d374b2b..f451750 100644 --- a/layouts/partials/scripts.html +++ b/layouts/partials/scripts.html @@ -18,9 +18,13 @@ {{ end }} -{{- $searchJSFile := printf "%s.search.js" .Language.Lang }} -{{- $searchJS := resources.Get "js/flexsearch.js" | resources.ExecuteAsTemplate $searchJSFile . | resources.Minify | resources.Fingerprint -}} - + +{{ $searchJSFile := printf "%s.search.js" .Language.Lang }} +{{ $searchJS := resources.Get "js/flexsearch.js" | resources.ExecuteAsTemplate $searchJSFile . }} +{{ if hugo.IsProduction }} + {{ $searchJS = $searchJS | minify | fingerprint }} +{{ end }} + {{ if .Page.Params.math }} diff --git a/layouts/partials/search.html b/layouts/partials/search.html index 8d21741..b10142e 100644 --- a/layouts/partials/search.html +++ b/layouts/partials/search.html @@ -8,9 +8,7 @@
    -
      diff --git a/layouts/partials/sidebar.html b/layouts/partials/sidebar.html index e425d33..345cf01 100644 --- a/layouts/partials/sidebar.html +++ b/layouts/partials/sidebar.html @@ -1,4 +1,4 @@ -