diff --git a/quartz/components/scripts/clipboard.inline.ts b/quartz/components/scripts/clipboard.inline.ts index c604c9bc5..87182a154 100644 --- a/quartz/components/scripts/clipboard.inline.ts +++ b/quartz/components/scripts/clipboard.inline.ts @@ -14,7 +14,7 @@ document.addEventListener("nav", () => { button.type = "button" button.innerHTML = svgCopy button.ariaLabel = "Copy source" - button.addEventListener("click", () => { + function onClick() { navigator.clipboard.writeText(source).then( () => { button.blur() @@ -26,7 +26,9 @@ document.addEventListener("nav", () => { }, (error) => console.error(error), ) - }) + } + button.addEventListener("click", onClick) + window.addCleanup(() => button.removeEventListener("click", onClick)) els[i].prepend(button) } } diff --git a/quartz/components/scripts/darkmode.inline.ts b/quartz/components/scripts/darkmode.inline.ts index 4c671aa41..48e0aa1f5 100644 --- a/quartz/components/scripts/darkmode.inline.ts +++ b/quartz/components/scripts/darkmode.inline.ts @@ -10,13 +10,21 @@ const emitThemeChangeEvent = (theme: "light" | "dark") => { } document.addEventListener("nav", () => { - const switchTheme = (e: any) => { - const newTheme = e.target.checked ? "dark" : "light" + const switchTheme = (e: Event) => { + const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light" document.documentElement.setAttribute("saved-theme", newTheme) localStorage.setItem("theme", newTheme) emitThemeChangeEvent(newTheme) } + const themeChange = (e: MediaQueryListEvent) => { + const newTheme = e.matches ? "dark" : "light" + document.documentElement.setAttribute("saved-theme", newTheme) + localStorage.setItem("theme", newTheme) + toggleSwitch.checked = e.matches + emitThemeChangeEvent(newTheme) + } + // Darkmode toggle const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement toggleSwitch.addEventListener("change", switchTheme) @@ -27,11 +35,6 @@ document.addEventListener("nav", () => { // Listen for changes in prefers-color-scheme const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - colorSchemeMediaQuery.addEventListener("change", (e) => { - const newTheme = e.matches ? "dark" : "light" - document.documentElement.setAttribute("saved-theme", newTheme) - localStorage.setItem("theme", newTheme) - toggleSwitch.checked = e.matches - emitThemeChangeEvent(newTheme) - }) + colorSchemeMediaQuery.addEventListener("change", themeChange) + window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange)) }) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 55919cdc6..c960f5e47 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -26,7 +26,6 @@ const numTagResults = 5 const tokenizeTerm = (term: string) => { const tokens = term.split(/\s+/).filter((t) => t.trim() !== "") - const tokenLen = tokens.length if (tokenLen > 1) { for (let i = 1; i < tokenLen; i++) { @@ -77,15 +76,14 @@ function highlight(searchTerm: string, text: string, trim?: boolean) { }) .join(" ") - return `${startIndex === 0 ? "" : "..."}${slice}${ - endIndex === tokenizedText.length - 1 ? "" : "..." - }` + return `${startIndex === 0 ? "" : "..."}${slice}${endIndex === tokenizedText.length - 1 ? "" : "..." + }` } -function highlightHTML(searchTerm: string, el: HTMLElement) { +function highlightHTML(searchTerm: string, innerHTML: string) { const p = new DOMParser() const tokenizedTerms = tokenizeTerm(searchTerm) - const html = p.parseFromString(el.innerHTML, "text/html") + const html = p.parseFromString(innerHTML, "text/html") const createHighlightSpan = (text: string) => { const span = document.createElement("span") @@ -125,10 +123,8 @@ function highlightHTML(searchTerm: string, el: HTMLElement) { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const currentSlug = e.detail.url - const data = await fetchData const container = document.getElementById("search-container") - const searchSpace = document.getElementById("search-space") const sidebar = container?.closest(".sidebar") as HTMLElement const searchIcon = document.getElementById("search-icon") const searchBar = document.getElementById("search-bar") as HTMLInputElement | null @@ -193,6 +189,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { e.preventDefault() const searchBarOpen = container?.classList.contains("active") searchBarOpen ? hideSearch() : showSearch("basic") + return } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { // Hotkey to open tag search e.preventDefault() @@ -201,6 +198,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { // add "#" prefix for tag search if (searchBar) searchBar.value = "#" + return } if (currentHover) { @@ -262,69 +260,29 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { } } - 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 slug = idDataMap[id] return { id, slug, title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), - // 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), + content: highlight(term, data[slug].content ?? "", true), + tags: highlightTags(term.substring(1), data[slug].tags), } } 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)) - - // Subtract 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) => `
  • #${tag}

  • `) - difference = difference.map((tag) => `
  • #${tag}

  • `) - matching.push(...difference) - } - - // Only allow max of `numTagResults` in preview - if (tags.length > numTagResults) { - matching.splice(numTagResults) - } - - return matching - } else { + if (!tags || searchType !== "tags") { return [] } + + return tags.map(tag => { + if (tag.toLowerCase().includes(term.toLowerCase())) { + return `
  • #${tag}

  • ` + } else { + return `
  • #${tag}

  • ` + } + }).slice(0, numTagResults) } function resolveUrl(slug: FullSlug): URL { @@ -332,34 +290,26 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { } const resultToHTML = ({ slug, title, content, tags }: Item) => { - const htmlTags = tags.length > 0 ? `` : `` - const resultContent = enablePreview && window.innerWidth > 600 ? "" : `

    ${content}

    ` - + const htmlTags = tags.length > 0 ? `` : `` const itemTile = document.createElement("a") itemTile.classList.add("result-card") - Object.assign(itemTile, { - id: slug, - href: resolveUrl(slug).toString(), - innerHTML: `

    ${title}

    ${htmlTags}${resultContent}`, - }) + itemTile.id = slug + itemTile.href = resolveUrl(slug).toString() + itemTile.innerHTML = `

    ${title}

    ${htmlTags}

    ${content}

    ` async function onMouseEnter(ev: MouseEvent) { - // Actually when we hover, we need to clean all highlights within the result childs if (!ev.target) return - for (const el of document.getElementsByClassName( - "result-card", - ) as HTMLCollectionOf) { - el.classList.remove("focus") - el.blur() - } + currentHover?.classList.remove('focus') + currentHover?.blur() const target = ev.target as HTMLInputElement await displayPreview(target) currentHover = target - currentHover.classList.remove("focus") + currentHover.classList.add("focus") } async function onMouseLeave(ev: MouseEvent) { - const target = ev.target as HTMLAnchorElement + if (!ev.target) return + const target = ev.target as HTMLElement target.classList.remove("focus") } @@ -373,9 +323,12 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { hideSearch() }, ], - ] as [keyof HTMLElementEventMap, (this: HTMLElement) => void][] + ] as const - events.forEach(([event, handler]) => itemTile.addEventListener(event, handler)) + events.forEach(([event, handler]) => { + itemTile.addEventListener(event, handler) + window.addCleanup(() => itemTile.removeEventListener(event, handler)) + }) return itemTile } @@ -386,22 +339,22 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { removeAllChildren(results) if (finalResults.length === 0) { results.innerHTML = ` -

    No results.

    -

    Try another search term?

    -
    ` +

    No results.

    +

    Try another search term?

    + ` } else { results.append(...finalResults.map(resultToHTML)) } - // focus on first result, then also dispatch preview immediately - if (results?.firstElementChild) { + + if (finalResults.length === 0 && preview) { + // no results, clear previous preview + removeAllChildren(preview) + } else { + // focus on first result, then also dispatch preview immediately const firstChild = results.firstElementChild as HTMLElement - if (firstChild.classList.contains("no-match")) { - removeAllChildren(preview as HTMLElement) - } else { - firstChild.classList.add("focus") - currentHover = firstChild as HTMLInputElement - await displayPreview(firstChild) - } + firstChild.classList.add("focus") + currentHover = firstChild as HTMLInputElement + await displayPreview(firstChild) } } @@ -427,59 +380,41 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { } async function displayPreview(el: HTMLElement | null) { - if (!searchLayout || !enablePreview || !el) return - + if (!searchLayout || !enablePreview || !el || !preview) return const slug = el.id as FullSlug el.classList.add("focus") - - removeAllChildren(preview as HTMLElement) - previewInner = document.createElement("div") previewInner.classList.add("preview-inner") - preview?.appendChild(previewInner) - const innerDiv = await fetchContent(slug).then((contents) => - contents.map((el) => highlightHTML(currentSearchTerm, el as HTMLElement)), + contents.map((el) => highlightHTML(currentSearchTerm, el.innerHTML)), ) previewInner.append(...innerDiv) + preview.replaceChildren(previewInner) + + // scroll to longest + const highlights = [...preview.querySelectorAll(".highlight")].sort((a, b) => b.innerHTML.length - a.innerHTML.length) + highlights[0]?.scrollIntoView() } async function onType(e: HTMLElementEventMap["input"]) { - let term = (e.target as HTMLInputElement).value - let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[] + if (!searchLayout || !index) return currentSearchTerm = (e.target as HTMLInputElement).value + searchLayout.style.visibility = currentSearchTerm === "" ? "hidden" : "visible" + searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic" - if (searchLayout) { - searchLayout.style.visibility = "visible" - } - - if (term === "" && searchLayout) { - searchLayout.style.visibility = "hidden" - } - - 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"], - })) ?? [] - } + let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[] + if (searchType === "tags") { + searchResults = await index.searchAsync({ + query: currentSearchTerm.substring(1), + limit: numSearchResults, + index: ["tags"], + }) + } else if (searchType === "basic") { + searchResults = await index.searchAsync({ + query: currentSearchTerm, + limit: numSearchResults, + index: ["title", "content"], + }) } const getByField = (field: string): number[] => { @@ -493,7 +428,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { ...getByField("content"), ...getByField("tags"), ]) - const finalResults = [...allIds].map((id) => formatForDisplay(term, id)) + const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id)) await displayResults(finalResults) } @@ -505,7 +440,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { window.addCleanup(() => searchBar?.removeEventListener("input", onType)) index ??= await fillDocument(data) - registerEscapeHandler(searchSpace, hideSearch) + registerEscapeHandler(container, hideSearch) }) /** diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss index 0e6ecb580..df4f5bab5 100644 --- a/quartz/components/styles/search.scss +++ b/quartz/components/styles/search.scss @@ -88,6 +88,14 @@ visibility: hidden; border: 1px solid var(--lightgray); + @media all and (min-width: $tabletBreakpoint) { + &[data-preview] { + & .result-card > p.preview { + display: none; + } + } + } + & > div { // vh - #search-space.margin-top height: calc(75vh - 12vh); @@ -174,7 +182,6 @@ outline: none; font-weight: inherit; - &:hover, &:focus, &.focus { background: var(--lightgray); @@ -184,41 +191,23 @@ 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; + & > ul.tags { margin-top: 0.45rem; - box-sizing: border-box; - overflow: hidden; - background-clip: border-box; + margin-bottom: 0; } & > 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); + padding: 0.2rem 0.4rem; + margin: 0 0.1rem; + line-height: 1.4rem; font-weight: bold; - opacity: 1; + color: var(--secondary); + + &.match-tag { + color: var(--tertiary); + } } & > p {