feat(search): highlight on preview (#783)

* feat: primitive full-text search on preview

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* fix: remove invalid regex and unused code path

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
This commit is contained in:
Aaron Pham 2024-02-01 16:48:27 -05:00 committed by GitHub
parent 9aa6a18be2
commit 756acc7f97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 64 additions and 13 deletions

View File

@ -11,23 +11,29 @@ interface Item {
tags: string[] tags: string[]
} }
let index: FlexSearch.Document<Item> | undefined = undefined
// Can be expanded with things like "term" in the future // Can be expanded with things like "term" in the future
type SearchType = "basic" | "tags" type SearchType = "basic" | "tags"
// Current searchType // Current searchType
let searchType: SearchType = "basic" let searchType: SearchType = "basic"
// Current search term // TODO: exact match
let currentSearchTerm: string = ""
// index for search
let index: FlexSearch.Document<Item> | undefined = undefined
const contextWindowWords = 30 const contextWindowWords = 30
const numSearchResults = 8 const numSearchResults = 8
const numTagResults = 5 const numTagResults = 5
function highlight(searchTerm: string, text: string, trim?: boolean) {
// try to highlight longest tokens first const tokenizeTerm = (term: string) =>
const tokenizedTerms = searchTerm term
.split(/\s+/) .split(/\s+/)
.filter((t) => t !== "") .filter((t) => t !== "")
.sort((a, b) => b.length - a.length) .sort((a, b) => b.length - a.length)
function highlight(searchTerm: string, text: string, trim?: boolean) {
// try to highlight longest tokens first
const tokenizedTerms = tokenizeTerm(searchTerm)
let tokenizedText = text.split(/\s+/).filter((t) => t !== "") let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
let startIndex = 0 let startIndex = 0
@ -64,6 +70,7 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
} }
return tok return tok
}) })
.slice(startIndex, endIndex + 1)
.join(" ") .join(" ")
return `${startIndex === 0 ? "" : "..."}${slice}${ return `${startIndex === 0 ? "" : "..."}${slice}${
@ -71,6 +78,45 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
}` }`
} }
function highlightHTML(searchTerm: string, el: HTMLElement) {
// try to highlight longest tokens first
const p = new DOMParser()
const tokenizedTerms = tokenizeTerm(searchTerm)
const html = p.parseFromString(el.innerHTML, "text/html")
const createHighlightSpan = (text: string) => {
const span = document.createElement("span")
span.className = "highlight"
span.textContent = text
return span
}
const highlightTextNodes = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
let nodeText = node.nodeValue || ""
tokenizedTerms.forEach((term) => {
const regex = new RegExp(term.toLowerCase(), "gi")
const matches = nodeText.match(regex)
const spanContainer = document.createElement("span")
let lastIndex = 0
matches?.forEach((match) => {
const matchIndex = nodeText.indexOf(match, lastIndex)
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex)))
spanContainer.appendChild(createHighlightSpan(match))
lastIndex = matchIndex + match.length
})
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex)))
node.parentNode?.replaceChild(spanContainer, node)
})
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(highlightTextNodes)
}
}
highlightTextNodes(html.body)
return html.body
}
const p = new DOMParser() const p = new DOMParser()
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/) const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined
@ -96,6 +142,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const enablePreview = searchLayout?.dataset?.preview === "true" const enablePreview = searchLayout?.dataset?.preview === "true"
let preview: HTMLDivElement | undefined = undefined let preview: HTMLDivElement | undefined = undefined
let previewInner: HTMLDivElement | undefined = undefined
const results = document.createElement("div") const results = document.createElement("div")
results.id = "results-container" results.id = "results-container"
results.style.flexBasis = enablePreview ? "30%" : "100%" results.style.flexBasis = enablePreview ? "30%" : "100%"
@ -384,17 +431,21 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
el.classList.add("focus") el.classList.add("focus")
removeAllChildren(preview as HTMLElement) removeAllChildren(preview as HTMLElement)
const contentDetails = await fetchContent(slug)
const previewInner = document.createElement("div") previewInner = document.createElement("div")
previewInner.classList.add("preview-inner") previewInner.classList.add("preview-inner")
preview?.appendChild(previewInner) preview?.appendChild(previewInner)
contentDetails?.forEach((elt) => previewInner.appendChild(elt))
const innerDiv = await fetchContent(slug).then((contents) =>
contents.map((el) => highlightHTML(currentSearchTerm, el as HTMLElement)),
)
previewInner.append(...innerDiv)
} }
async function onType(e: HTMLElementEventMap["input"]) { async function onType(e: HTMLElementEventMap["input"]) {
let term = (e.target as HTMLInputElement).value let term = (e.target as HTMLInputElement).value
let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[] let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
currentSearchTerm = (e.target as HTMLInputElement).value
if (searchLayout) { if (searchLayout) {
searchLayout.style.opacity = "1" searchLayout.style.opacity = "1"

View File

@ -121,6 +121,11 @@
} }
} }
& .highlight {
color: var(--secondary);
font-weight: 700;
}
& > #preview-container { & > #preview-container {
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
@ -166,11 +171,6 @@
outline: none; outline: none;
font-weight: inherit; font-weight: inherit;
& .highlight {
color: var(--secondary);
font-weight: 700;
}
&:hover, &:hover,
&:focus, &:focus,
&.focus { &.focus {