mirror of
				https://github.com/ZetaKebab/quartz.git
				synced 2025-11-04 06:49:49 +00:00 
			
		
		
		
	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++
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
          }
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user