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:
		
							parent
							
								
									2d6dc176c3
								
							
						
					
					
						commit
						90dac31216
					
				
					 2 changed files with 179 additions and 22 deletions
				
			
		| 
						 | 
					@ -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)
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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;
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue