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:
parent
9aa6a18be2
commit
756acc7f97
@ -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"
|
||||||
|
@ -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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user