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:
Ben Schlegel 2023-09-01 19:09:58 +02:00 committed by GitHub
parent b9be4e470c
commit fa0629716f
2 changed files with 179 additions and 22 deletions

View File

@ -1,4 +1,4 @@
import { Document } from "flexsearch" import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex" import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, resolveRelative } from "../../util/path" import { FullSlug, resolveRelative } from "../../util/path"
@ -8,12 +8,20 @@ interface Item {
slug: FullSlug slug: FullSlug
title: string title: string
content: string content: string
tags: string[]
} }
let index: Document<Item> | undefined = undefined 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 contextWindowWords = 30
const numSearchResults = 5 const numSearchResults = 5
const numTagResults = 3
function highlight(searchTerm: string, text: string, trim?: boolean) { function highlight(searchTerm: string, text: string, trim?: boolean) {
// try to highlight longest tokens first // try to highlight longest tokens first
const tokenizedTerms = searchTerm const tokenizedTerms = searchTerm
@ -87,9 +95,12 @@ document.addEventListener("nav", async (e: unknown) => {
if (results) { if (results) {
removeAllChildren(results) removeAllChildren(results)
} }
searchType = "basic" // reset search type after closing
} }
function showSearch() { function showSearch(searchTypeNew: SearchType) {
searchType = searchTypeNew
if (sidebar) { if (sidebar) {
sidebar.style.zIndex = "1" sidebar.style.zIndex = "1"
} }
@ -98,10 +109,18 @@ document.addEventListener("nav", async (e: unknown) => {
} }
function shortcutHandler(e: HTMLElementEventMap["keydown"]) { 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() e.preventDefault()
const searchBarOpen = container?.classList.contains("active") 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") { } else if (e.key === "Enter") {
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
if (anchor) { 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 formatForDisplay = (term: string, id: number) => {
const slug = idDataMap[id] const slug = idDataMap[id]
return { return {
id, id,
slug, slug,
title: highlight(term, data[slug].title ?? ""), 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") const button = document.createElement("button")
button.classList.add("result-card") button.classList.add("result-card")
button.id = slug button.id = slug
button.innerHTML = `<h3>${title}</h3><p>${content}</p>` button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
button.addEventListener("click", () => { button.addEventListener("click", () => {
const targ = resolveRelative(currentSlug, slug) const targ = resolveRelative(currentSlug, slug)
window.spaNavigate(new URL(targ, window.location.toString())) window.spaNavigate(new URL(targ, window.location.toString()))
@ -148,15 +223,45 @@ document.addEventListener("nav", async (e: unknown) => {
} }
async function onType(e: HTMLElementEventMap["input"]) { async function onType(e: HTMLElementEventMap["input"]) {
const term = (e.target as HTMLInputElement).value let term = (e.target as HTMLInputElement).value
const searchResults = (await index?.searchAsync(term, numSearchResults)) ?? [] 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 getByField = (field: string): number[] => {
const results = searchResults.filter((x) => x.field === field) const results = searchResults.filter((x) => x.field === field)
return results.length === 0 ? [] : ([...results[0].result] as number[]) return results.length === 0 ? [] : ([...results[0].result] as number[])
} }
// order titles ahead of content // 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)) const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
displayResults(finalResults) displayResults(finalResults)
} }
@ -167,8 +272,8 @@ document.addEventListener("nav", async (e: unknown) => {
document.addEventListener("keydown", shortcutHandler) document.addEventListener("keydown", shortcutHandler)
prevShortcutHandler = shortcutHandler prevShortcutHandler = shortcutHandler
searchIcon?.removeEventListener("click", showSearch) searchIcon?.removeEventListener("click", () => showSearch("basic"))
searchIcon?.addEventListener("click", showSearch) searchIcon?.addEventListener("click", () => showSearch("basic"))
searchBar?.removeEventListener("input", onType) searchBar?.removeEventListener("input", onType)
searchBar?.addEventListener("input", onType) searchBar?.addEventListener("input", onType)
@ -190,10 +295,27 @@ document.addEventListener("nav", async (e: unknown) => {
field: "content", field: "content",
tokenize: "reverse", tokenize: "reverse",
}, },
{
field: "tags",
tokenize: "reverse",
},
], ],
}, },
}) })
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 let id = 0
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
await index.addAsync(id, { await index.addAsync(id, {
@ -201,11 +323,8 @@ document.addEventListener("nav", async (e: unknown) => {
slug: slug as FullSlug, slug: slug as FullSlug,
title: fileData.title, title: fileData.title,
content: fileData.content, content: fileData.content,
tags: fileData.tags,
}) })
id++ id++
} }
} }
// register handlers
registerEscapeHandler(container, hideSearch)
})

View File

@ -130,6 +130,44 @@
margin: 0; margin: 0;
} }
& > ul > li {
margin: 0;
display: inline-block;
white-space: nowrap;
margin: 0;
overflow-wrap: normal;
}
& > ul {
list-style: none;
display: flex;
padding-left: 0;
gap: 0.4rem;
margin: 0;
margin-top: 0.45rem;
// Offset border radius
margin-left: -2px;
overflow: hidden;
background-clip: border-box;
}
& > ul > li > p {
border-radius: 8px;
background-color: var(--highlight);
overflow: hidden;
background-clip: border-box;
padding: 0.03rem 0.4rem;
margin: 0;
color: var(--secondary);
opacity: 0.85;
}
& > ul > li > .match-tag {
color: var(--tertiary);
font-weight: bold;
opacity: 1;
}
& > p { & > p {
margin-bottom: 0; margin-bottom: 0;
} }