feat: Implement search for tags (#436)
* Quartz sync: Aug 29, 2023, 10:17 PM * style: add basic style to tags in search * feat: add SearchType + tags to search preview * feat: support multiple matches * style(search): add style to matching tags * feat(search): add content to preview for tag search * fix: only display tags on tag search * feat: support basic + tag search * refactor: extract common `fillDocument`, format * feat: add hotkey to search for tags * chore: remove logs * fix: dont render empty `<ul>` if tags not present * fix(search-tag): make case insensitive * refactor: clean `hideSearch` and `showSearch` * feat: trim content similar to `description.ts` * fix(search-tag): hotkey for windows * perf: re-use main index for tag search
This commit is contained in:
		| @@ -1,4 +1,4 @@ | ||||
| import { Document } from "flexsearch" | ||||
| import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch" | ||||
| import { ContentDetails } from "../../plugins/emitters/contentIndex" | ||||
| import { registerEscapeHandler, removeAllChildren } from "./util" | ||||
| import { FullSlug, resolveRelative } from "../../util/path" | ||||
| @@ -8,12 +8,20 @@ interface Item { | ||||
|   slug: FullSlug | ||||
|   title: string | ||||
|   content: string | ||||
|   tags: string[] | ||||
| } | ||||
|  | ||||
| let index: Document<Item> | undefined = undefined | ||||
|  | ||||
| // Can be expanded with things like "term" in the future | ||||
| type SearchType = "basic" | "tags" | ||||
|  | ||||
| // Current searchType | ||||
| let searchType: SearchType = "basic" | ||||
|  | ||||
| const contextWindowWords = 30 | ||||
| const numSearchResults = 5 | ||||
| const numTagResults = 3 | ||||
| function highlight(searchTerm: string, text: string, trim?: boolean) { | ||||
|   // try to highlight longest tokens first | ||||
|   const tokenizedTerms = searchTerm | ||||
| @@ -87,9 +95,12 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|     if (results) { | ||||
|       removeAllChildren(results) | ||||
|     } | ||||
|  | ||||
|     searchType = "basic" // reset search type after closing | ||||
|   } | ||||
|  | ||||
|   function showSearch() { | ||||
|   function showSearch(searchTypeNew: SearchType) { | ||||
|     searchType = searchTypeNew | ||||
|     if (sidebar) { | ||||
|       sidebar.style.zIndex = "1" | ||||
|     } | ||||
| @@ -98,10 +109,18 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|   } | ||||
|  | ||||
|   function shortcutHandler(e: HTMLElementEventMap["keydown"]) { | ||||
|     if (e.key === "k" && (e.ctrlKey || e.metaKey)) { | ||||
|     if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { | ||||
|       e.preventDefault() | ||||
|       const searchBarOpen = container?.classList.contains("active") | ||||
|       searchBarOpen ? hideSearch() : showSearch() | ||||
|       searchBarOpen ? hideSearch() : showSearch("basic") | ||||
|     } 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") | ||||
|       searchBarOpen ? hideSearch() : showSearch("tags") | ||||
|  | ||||
|       // add "#" prefix for tag search | ||||
|       if (searchBar) searchBar.value = "#" | ||||
|     } else if (e.key === "Enter") { | ||||
|       const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null | ||||
|       if (anchor) { | ||||
| @@ -110,21 +129,77 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function trimContent(content: string) { | ||||
|     // works without escaping html like in `description.ts` | ||||
|     const sentences = content.replace(/\s+/g, " ").split(".") | ||||
|     let finalDesc = "" | ||||
|     let sentenceIdx = 0 | ||||
|  | ||||
|     // Roughly estimate characters by (words * 5). Matches description length in `description.ts`. | ||||
|     const len = contextWindowWords * 5 | ||||
|     while (finalDesc.length < len) { | ||||
|       const sentence = sentences[sentenceIdx] | ||||
|       if (!sentence) break | ||||
|       finalDesc += sentence + "." | ||||
|       sentenceIdx++ | ||||
|     } | ||||
|  | ||||
|     // If more content would be available, indicate it by finishing with "..." | ||||
|     if (finalDesc.length < content.length) { | ||||
|       finalDesc += ".." | ||||
|     } | ||||
|  | ||||
|     return finalDesc | ||||
|   } | ||||
|  | ||||
|   const formatForDisplay = (term: string, id: number) => { | ||||
|     const slug = idDataMap[id] | ||||
|     return { | ||||
|       id, | ||||
|       slug, | ||||
|       title: highlight(term, data[slug].title ?? ""), | ||||
|       content: highlight(term, data[slug].content ?? "", true), | ||||
|       // if searchType is tag, display context from start of file and trim, otherwise use regular highlight | ||||
|       content: | ||||
|         searchType === "tags" | ||||
|           ? trimContent(data[slug].content) | ||||
|           : highlight(term, data[slug].content ?? "", true), | ||||
|       tags: highlightTags(term, data[slug].tags), | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const resultToHTML = ({ slug, title, content }: Item) => { | ||||
|   function highlightTags(term: string, tags: string[]) { | ||||
|     if (tags && searchType === "tags") { | ||||
|       // Find matching tags | ||||
|       const termLower = term.toLowerCase() | ||||
|       let matching = tags.filter((str) => str.includes(termLower)) | ||||
|  | ||||
|       // Substract matching from original tags, then push difference | ||||
|       if (matching.length > 0) { | ||||
|         let difference = tags.filter((x) => !matching.includes(x)) | ||||
|  | ||||
|         // Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`) | ||||
|         matching = matching.map((tag) => `<li><p class="match-tag">#${tag}</p></li>`) | ||||
|         difference = difference.map((tag) => `<li><p>#${tag}</p></li>`) | ||||
|         matching.push(...difference) | ||||
|       } | ||||
|  | ||||
|       // Only allow max of `numTagResults` in preview | ||||
|       if (tags.length > numTagResults) { | ||||
|         matching.splice(numTagResults) | ||||
|       } | ||||
|  | ||||
|       return matching | ||||
|     } else { | ||||
|       return [] | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const resultToHTML = ({ slug, title, content, tags }: Item) => { | ||||
|     const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : `` | ||||
|     const button = document.createElement("button") | ||||
|     button.classList.add("result-card") | ||||
|     button.id = slug | ||||
|     button.innerHTML = `<h3>${title}</h3><p>${content}</p>` | ||||
|     button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>` | ||||
|     button.addEventListener("click", () => { | ||||
|       const targ = resolveRelative(currentSlug, slug) | ||||
|       window.spaNavigate(new URL(targ, window.location.toString())) | ||||
| @@ -148,15 +223,45 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|   } | ||||
|  | ||||
|   async function onType(e: HTMLElementEventMap["input"]) { | ||||
|     const term = (e.target as HTMLInputElement).value | ||||
|     const searchResults = (await index?.searchAsync(term, numSearchResults)) ?? [] | ||||
|     let term = (e.target as HTMLInputElement).value | ||||
|     let searchResults: SimpleDocumentSearchResultSetUnit[] | ||||
|  | ||||
|     if (term.toLowerCase().startsWith("#")) { | ||||
|       searchType = "tags" | ||||
|     } else { | ||||
|       searchType = "basic" | ||||
|     } | ||||
|  | ||||
|     switch (searchType) { | ||||
|       case "tags": { | ||||
|         term = term.substring(1) | ||||
|         searchResults = | ||||
|           (await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ?? | ||||
|           [] | ||||
|         break | ||||
|       } | ||||
|       case "basic": | ||||
|       default: { | ||||
|         searchResults = | ||||
|           (await index?.searchAsync({ | ||||
|             query: term, | ||||
|             limit: numSearchResults, | ||||
|             index: ["title", "content"], | ||||
|           })) ?? [] | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const getByField = (field: string): number[] => { | ||||
|       const results = searchResults.filter((x) => x.field === field) | ||||
|       return results.length === 0 ? [] : ([...results[0].result] as number[]) | ||||
|     } | ||||
|  | ||||
|     // order titles ahead of content | ||||
|     const allIds: Set<number> = new Set([...getByField("title"), ...getByField("content")]) | ||||
|     const allIds: Set<number> = new Set([ | ||||
|       ...getByField("title"), | ||||
|       ...getByField("content"), | ||||
|       ...getByField("tags"), | ||||
|     ]) | ||||
|     const finalResults = [...allIds].map((id) => formatForDisplay(term, id)) | ||||
|     displayResults(finalResults) | ||||
|   } | ||||
| @@ -167,8 +272,8 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|  | ||||
|   document.addEventListener("keydown", shortcutHandler) | ||||
|   prevShortcutHandler = shortcutHandler | ||||
|   searchIcon?.removeEventListener("click", showSearch) | ||||
|   searchIcon?.addEventListener("click", showSearch) | ||||
|   searchIcon?.removeEventListener("click", () => showSearch("basic")) | ||||
|   searchIcon?.addEventListener("click", () => showSearch("basic")) | ||||
|   searchBar?.removeEventListener("input", onType) | ||||
|   searchBar?.addEventListener("input", onType) | ||||
|  | ||||
| @@ -190,22 +295,36 @@ document.addEventListener("nav", async (e: unknown) => { | ||||
|             field: "content", | ||||
|             tokenize: "reverse", | ||||
|           }, | ||||
|           { | ||||
|             field: "tags", | ||||
|             tokenize: "reverse", | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }) | ||||
|  | ||||
|     let id = 0 | ||||
|     for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { | ||||
|       await index.addAsync(id, { | ||||
|         id, | ||||
|         slug: slug as FullSlug, | ||||
|         title: fileData.title, | ||||
|         content: fileData.content, | ||||
|       }) | ||||
|       id++ | ||||
|     } | ||||
|     fillDocument(index, data) | ||||
|   } | ||||
|  | ||||
|   // register handlers | ||||
|   registerEscapeHandler(container, hideSearch) | ||||
| }) | ||||
|  | ||||
| /** | ||||
|  * Fills flexsearch document with data | ||||
|  * @param index index to fill | ||||
|  * @param data data to fill index with | ||||
|  */ | ||||
| async function fillDocument(index: Document<Item, false>, data: any) { | ||||
|   let id = 0 | ||||
|   for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { | ||||
|     await index.addAsync(id, { | ||||
|       id, | ||||
|       slug: slug as FullSlug, | ||||
|       title: fileData.title, | ||||
|       content: fileData.content, | ||||
|       tags: fileData.tags, | ||||
|     }) | ||||
|     id++ | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user