From 756acc7f975f313f9d9139b42be9d57805014454 Mon Sep 17 00:00:00 2001 From: Aaron Pham <29749331+aarnphm@users.noreply.github.com> Date: Thu, 1 Feb 2024 16:48:27 -0500 Subject: [PATCH] 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> --- quartz/components/scripts/search.inline.ts | 67 +++++++++++++++++++--- quartz/components/styles/search.scss | 10 ++-- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 43332a6..3bbfa7b 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -11,23 +11,29 @@ interface Item { tags: string[] } -let index: FlexSearch.Document | 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 | 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" diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss index fb7dd74..e84172e 100644 --- a/quartz/components/styles/search.scss +++ b/quartz/components/styles/search.scss @@ -121,6 +121,11 @@ } } + & .highlight { + color: var(--secondary); + font-weight: 700; + } + & > #preview-container { display: block; box-sizing: border-box; @@ -166,11 +171,6 @@ outline: none; font-weight: inherit; - & .highlight { - color: var(--secondary); - font-weight: 700; - } - &:hover, &:focus, &.focus {