| | |
| | | import FlexSearch from "flexsearch" |
| | | import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch" |
| | | import { ContentDetails } from "../../plugins/emitters/contentIndex" |
| | | import { registerEscapeHandler, removeAllChildren } from "./util" |
| | | import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" |
| | |
| | | title: string |
| | | content: string |
| | | tags: string[] |
| | | [key: string]: any |
| | | } |
| | | |
| | | // Can be expanded with things like "term" in the future |
| | | type SearchType = "basic" | "tags" |
| | | let searchType: SearchType = "basic" |
| | | let currentSearchTerm: string = "" |
| | | const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/) |
| | | const encoder = (str: string): string[] => { |
| | | const tokens: string[] = [] |
| | | let bufferStart = -1 |
| | | let bufferEnd = -1 |
| | | const lower = str.toLowerCase() |
| | | |
| | | let i = 0 |
| | | for (const char of lower) { |
| | | const code = char.codePointAt(0)! |
| | | |
| | | const isCJK = |
| | | (code >= 0x3040 && code <= 0x309f) || |
| | | (code >= 0x30a0 && code <= 0x30ff) || |
| | | (code >= 0x4e00 && code <= 0x9fff) || |
| | | (code >= 0xac00 && code <= 0xd7af) || |
| | | (code >= 0x20000 && code <= 0x2a6df) |
| | | |
| | | const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13 |
| | | |
| | | if (isCJK) { |
| | | if (bufferStart !== -1) { |
| | | tokens.push(lower.slice(bufferStart, bufferEnd)) |
| | | bufferStart = -1 |
| | | } |
| | | tokens.push(char) |
| | | } else if (isWhitespace) { |
| | | if (bufferStart !== -1) { |
| | | tokens.push(lower.slice(bufferStart, bufferEnd)) |
| | | bufferStart = -1 |
| | | } |
| | | } else { |
| | | if (bufferStart === -1) bufferStart = i |
| | | bufferEnd = i + char.length |
| | | } |
| | | |
| | | i += char.length |
| | | } |
| | | |
| | | if (bufferStart !== -1) { |
| | | tokens.push(lower.slice(bufferStart)) |
| | | } |
| | | |
| | | return tokens |
| | | } |
| | | |
| | | let index = new FlexSearch.Document<Item>({ |
| | | charset: "latin:extra", |
| | | encode: encoder, |
| | | document: { |
| | | id: "id", |
| | | tag: "tags", |
| | | index: [ |
| | | { |
| | | field: "title", |
| | |
| | | return html.body |
| | | } |
| | | |
| | | document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { |
| | | const currentSlug = e.detail.url |
| | | const data = await fetchData |
| | | const container = document.getElementById("search-container") |
| | | const sidebar = container?.closest(".sidebar") as HTMLElement |
| | | const searchIcon = document.getElementById("search-icon") |
| | | const searchBar = document.getElementById("search-bar") as HTMLInputElement | null |
| | | const searchLayout = document.getElementById("search-layout") |
| | | const idDataMap = Object.keys(data) as FullSlug[] |
| | | async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) { |
| | | const container = searchElement.querySelector(".search-container") as HTMLElement |
| | | if (!container) return |
| | | |
| | | const sidebar = container.closest(".sidebar") as HTMLElement | null |
| | | |
| | | const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement |
| | | if (!searchButton) return |
| | | |
| | | const searchBar = searchElement.querySelector(".search-bar") as HTMLInputElement |
| | | if (!searchBar) return |
| | | |
| | | const searchLayout = searchElement.querySelector(".search-layout") as HTMLElement |
| | | if (!searchLayout) return |
| | | |
| | | const idDataMap = Object.keys(data) as FullSlug[] |
| | | const appendLayout = (el: HTMLElement) => { |
| | | if (searchLayout?.querySelector(`#${el.id}`) === null) { |
| | | searchLayout?.appendChild(el) |
| | | } |
| | | searchLayout.appendChild(el) |
| | | } |
| | | |
| | | const enablePreview = searchLayout?.dataset?.preview === "true" |
| | | const enablePreview = searchLayout.dataset.preview === "true" |
| | | let preview: HTMLDivElement | undefined = undefined |
| | | let previewInner: HTMLDivElement | undefined = undefined |
| | | const results = document.createElement("div") |
| | | results.id = "results-container" |
| | | results.style.flexBasis = enablePreview ? "min(30%, 450px)" : "100%" |
| | | results.className = "results-container" |
| | | appendLayout(results) |
| | | |
| | | if (enablePreview) { |
| | | preview = document.createElement("div") |
| | | preview.id = "preview-container" |
| | | preview.style.flexBasis = "100%" |
| | | preview.className = "preview-container" |
| | | appendLayout(preview) |
| | | } |
| | | |
| | | function hideSearch() { |
| | | container?.classList.remove("active") |
| | | if (searchBar) { |
| | | searchBar.value = "" // clear the input when we dismiss the search |
| | | } |
| | | if (sidebar) { |
| | | sidebar.style.zIndex = "unset" |
| | | } |
| | | if (results) { |
| | | removeAllChildren(results) |
| | | } |
| | | container.classList.remove("active") |
| | | searchBar.value = "" // clear the input when we dismiss the search |
| | | if (sidebar) sidebar.style.zIndex = "" |
| | | removeAllChildren(results) |
| | | if (preview) { |
| | | removeAllChildren(preview) |
| | | } |
| | | if (searchLayout) { |
| | | searchLayout.classList.remove("display-results") |
| | | } |
| | | |
| | | searchLayout.classList.remove("display-results") |
| | | searchType = "basic" // reset search type after closing |
| | | searchButton.focus() |
| | | } |
| | | |
| | | function showSearch(searchTypeNew: SearchType) { |
| | | searchType = searchTypeNew |
| | | if (sidebar) { |
| | | sidebar.style.zIndex = "1" |
| | | } |
| | | container?.classList.add("active") |
| | | searchBar?.focus() |
| | | if (sidebar) sidebar.style.zIndex = "1" |
| | | container.classList.add("active") |
| | | searchBar.focus() |
| | | } |
| | | |
| | | let currentHover: HTMLInputElement | null = null |
| | | |
| | | async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { |
| | | if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { |
| | | e.preventDefault() |
| | | const searchBarOpen = container?.classList.contains("active") |
| | | const searchBarOpen = container.classList.contains("active") |
| | | searchBarOpen ? hideSearch() : showSearch("basic") |
| | | return |
| | | } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { |
| | | // Hotkey to open tag search |
| | | e.preventDefault() |
| | | const searchBarOpen = container?.classList.contains("active") |
| | | const searchBarOpen = container.classList.contains("active") |
| | | searchBarOpen ? hideSearch() : showSearch("tags") |
| | | |
| | | // add "#" prefix for tag search |
| | | if (searchBar) searchBar.value = "#" |
| | | searchBar.value = "#" |
| | | return |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | // If search is active, then we will render the first result and display accordingly |
| | | if (!container?.classList.contains("active")) return |
| | | if (e.key === "Enter") { |
| | | if (!container.classList.contains("active")) return |
| | | if (e.key === "Enter" && !e.isComposing) { |
| | | // If result has focus, navigate to that one, otherwise pick first result |
| | | if (results?.contains(document.activeElement)) { |
| | | if (results.contains(document.activeElement)) { |
| | | const active = document.activeElement as HTMLInputElement |
| | | if (active.classList.contains("no-match")) return |
| | | await displayPreview(active) |
| | | active.click() |
| | | } else { |
| | | const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null |
| | | if (!anchor || anchor?.classList.contains("no-match")) return |
| | | if (!anchor || anchor.classList.contains("no-match")) return |
| | | await displayPreview(anchor) |
| | | anchor.click() |
| | | } |
| | | } else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) { |
| | | e.preventDefault() |
| | | if (results?.contains(document.activeElement)) { |
| | | if (results.contains(document.activeElement)) { |
| | | // If an element in results-container already has focus, focus previous one |
| | | const currentResult = currentHover |
| | | ? currentHover |
| | |
| | | itemTile.classList.add("result-card") |
| | | itemTile.id = slug |
| | | itemTile.href = resolveUrl(slug).toString() |
| | | itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}<p class="preview">${content}</p>` |
| | | itemTile.innerHTML = ` |
| | | <h3 class="card-title">${title}</h3> |
| | | ${htmlTags} |
| | | <p class="card-description">${content}</p> |
| | | ` |
| | | itemTile.addEventListener("click", (event) => { |
| | | if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return |
| | | hideSearch() |
| | | }) |
| | | |
| | | const handler = (event: MouseEvent) => { |
| | | if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return |
| | | hideSearch() |
| | | } |
| | | |
| | | async function onMouseEnter(ev: MouseEvent) { |
| | | if (!ev.target) return |
| | | const target = ev.target as HTMLInputElement |
| | | await displayPreview(target) |
| | | } |
| | | |
| | | itemTile.addEventListener("mouseenter", onMouseEnter) |
| | | window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter)) |
| | | itemTile.addEventListener("click", handler) |
| | | window.addCleanup(() => itemTile.removeEventListener("click", handler)) |
| | | |
| | |
| | | } |
| | | |
| | | async function displayResults(finalResults: Item[]) { |
| | | if (!results) return |
| | | |
| | | removeAllChildren(results) |
| | | if (finalResults.length === 0) { |
| | | results.innerHTML = `<a class="result-card no-match"> |
| | |
| | | preview.replaceChildren(previewInner) |
| | | |
| | | // scroll to longest |
| | | const highlights = [...preview.querySelectorAll(".highlight")].sort( |
| | | const highlights = [...preview.getElementsByClassName("highlight")].sort( |
| | | (a, b) => b.innerHTML.length - a.innerHTML.length, |
| | | ) |
| | | highlights[0]?.scrollIntoView({ block: "start" }) |
| | |
| | | searchLayout.classList.toggle("display-results", currentSearchTerm !== "") |
| | | searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic" |
| | | |
| | | let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[] |
| | | let searchResults: DefaultDocumentSearchResults<Item> |
| | | if (searchType === "tags") { |
| | | searchResults = await index.searchAsync({ |
| | | query: currentSearchTerm.substring(1), |
| | | limit: numSearchResults, |
| | | index: ["tags"], |
| | | }) |
| | | currentSearchTerm = currentSearchTerm.substring(1).trim() |
| | | const separatorIndex = currentSearchTerm.indexOf(" ") |
| | | if (separatorIndex != -1) { |
| | | // search by title and content index and then filter by tag (implemented in flexsearch) |
| | | const tag = currentSearchTerm.substring(0, separatorIndex) |
| | | const query = currentSearchTerm.substring(separatorIndex + 1).trim() |
| | | searchResults = await index.searchAsync({ |
| | | query: query, |
| | | // return at least 10000 documents, so it is enough to filter them by tag (implemented in flexsearch) |
| | | limit: Math.max(numSearchResults, 10000), |
| | | index: ["title", "content"], |
| | | tag: { tags: tag }, |
| | | }) |
| | | for (let searchResult of searchResults) { |
| | | searchResult.result = searchResult.result.slice(0, numSearchResults) |
| | | } |
| | | // set search type to basic and remove tag from term for proper highlightning and scroll |
| | | searchType = "basic" |
| | | currentSearchTerm = query |
| | | } else { |
| | | // default search by tags index |
| | | searchResults = await index.searchAsync({ |
| | | query: currentSearchTerm, |
| | | limit: numSearchResults, |
| | | index: ["tags"], |
| | | }) |
| | | } |
| | | } else if (searchType === "basic") { |
| | | searchResults = await index.searchAsync({ |
| | | query: currentSearchTerm, |
| | |
| | | |
| | | document.addEventListener("keydown", shortcutHandler) |
| | | window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) |
| | | searchIcon?.addEventListener("click", () => showSearch("basic")) |
| | | window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic"))) |
| | | searchBar?.addEventListener("input", onType) |
| | | window.addCleanup(() => searchBar?.removeEventListener("input", onType)) |
| | | searchButton.addEventListener("click", () => showSearch("basic")) |
| | | window.addCleanup(() => searchButton.removeEventListener("click", () => showSearch("basic"))) |
| | | searchBar.addEventListener("input", onType) |
| | | window.addCleanup(() => searchBar.removeEventListener("input", onType)) |
| | | |
| | | registerEscapeHandler(container, hideSearch) |
| | | await fillDocument(data) |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * Fills flexsearch document with data |
| | | * @param index index to fill |
| | | * @param data data to fill index with |
| | | */ |
| | | async function fillDocument(data: { [key: FullSlug]: ContentDetails }) { |
| | | let indexPopulated = false |
| | | async function fillDocument(data: ContentIndex) { |
| | | if (indexPopulated) return |
| | | let id = 0 |
| | | const promises: Array<Promise<unknown>> = [] |
| | | for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { |
| | |
| | | ) |
| | | } |
| | | |
| | | return await Promise.all(promises) |
| | | await Promise.all(promises) |
| | | indexPopulated = true |
| | | } |
| | | |
| | | document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { |
| | | const currentSlug = e.detail.url |
| | | const data = await fetchData |
| | | const searchElement = document.getElementsByClassName("search") |
| | | for (const element of searchElement) { |
| | | await setupSearch(element, currentSlug, data) |
| | | } |
| | | }) |