Ben Schlegel
2023-09-01 90dac31216b5d3f59e65ec5778e21a308a744e11
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
2 files modified
201 ■■■■ changed files
quartz/components/scripts/search.inline.ts 163 ●●●● patch | view | raw | blame | history
quartz/components/styles/search.scss 38 ●●●●● patch | view | raw | blame | history
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 @@
  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 @@
    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 @@
  }
  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 @@
    }
  }
  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 @@
  }
  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("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 @@
            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++
  }
}
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;
          }