diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index ef26ba38..806a746e 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -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 | 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) => `
  • #${tag}

  • `) + difference = difference.map((tag) => `
  • #${tag}

  • `) + 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 ? `` : `` const button = document.createElement("button") button.classList.add("result-card") button.id = slug - button.innerHTML = `

    ${title}

    ${content}

    ` + button.innerHTML = `

    ${title}

    ${htmlTags}

    ${content}

    ` 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 = new Set([...getByField("title"), ...getByField("content")]) + const allIds: Set = 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(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, data: any) { + let id = 0 + for (const [slug, fileData] of Object.entries(data)) { + await index.addAsync(id, { + id, + slug: slug as FullSlug, + title: fileData.title, + content: fileData.content, + tags: fileData.tags, + }) + id++ + } +} diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss index 4d5ad95c..66f809f9 100644 --- a/quartz/components/styles/search.scss +++ b/quartz/components/styles/search.scss @@ -130,6 +130,44 @@ 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 { margin-bottom: 0; }