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:
		| @@ -11,23 +11,29 @@ interface Item { | ||||
|   tags: string[] | ||||
| } | ||||
|  | ||||
| let index: FlexSearch.Document<Item> | undefined = undefined | ||||
|  | ||||
| // Can be expanded with things like "term" in the future | ||||
| type SearchType = "basic" | "tags" | ||||
|  | ||||
| // Current searchType | ||||
| 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 numSearchResults = 8 | ||||
| const numTagResults = 5 | ||||
| function highlight(searchTerm: string, text: string, trim?: boolean) { | ||||
|   // try to highlight longest tokens first | ||||
|   const tokenizedTerms = searchTerm | ||||
|  | ||||
| const tokenizeTerm = (term: string) => | ||||
|   term | ||||
|     .split(/\s+/) | ||||
|     .filter((t) => t !== "") | ||||
|     .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 startIndex = 0 | ||||
| @@ -64,6 +70,7 @@ function highlight(searchTerm: string, text: string, trim?: boolean) { | ||||
|       } | ||||
|       return tok | ||||
|     }) | ||||
|     .slice(startIndex, endIndex + 1) | ||||
|     .join(" ") | ||||
|  | ||||
|   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 encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/) | ||||
| 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" | ||||
|   let preview: HTMLDivElement | undefined = undefined | ||||
|   let previewInner: HTMLDivElement | undefined = undefined | ||||
|   const results = document.createElement("div") | ||||
|   results.id = "results-container" | ||||
|   results.style.flexBasis = enablePreview ? "30%" : "100%" | ||||
| @@ -384,17 +431,21 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { | ||||
|     el.classList.add("focus") | ||||
|  | ||||
|     removeAllChildren(preview as HTMLElement) | ||||
|     const contentDetails = await fetchContent(slug) | ||||
|  | ||||
|     const previewInner = document.createElement("div") | ||||
|     previewInner = document.createElement("div") | ||||
|     previewInner.classList.add("preview-inner") | ||||
|     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"]) { | ||||
|     let term = (e.target as HTMLInputElement).value | ||||
|     let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[] | ||||
|     currentSearchTerm = (e.target as HTMLInputElement).value | ||||
|  | ||||
|     if (searchLayout) { | ||||
|       searchLayout.style.opacity = "1" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user