diff --git a/quartz/components/Search.tsx b/quartz/components/Search.tsx index 92684ae76..239bc033b 100644 --- a/quartz/components/Search.tsx +++ b/quartz/components/Search.tsx @@ -4,8 +4,18 @@ import style from "./styles/search.scss" import script from "./scripts/search.inline" import { classNames } from "../util/lang" -export default (() => { +export interface SearchOptions { + enablePreview: boolean +} + +const defaultOptions: SearchOptions = { + enablePreview: true, +} + +export default ((userOpts?: Partial) => { function Search({ displayClass }: QuartzComponentProps) { + const opts = { ...defaultOptions, ...userOpts } + return (
@@ -36,7 +46,7 @@ export default (() => { aria-label="Search for something" placeholder="Search for something" /> -
+
diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 941d35bb3..cbcc9ab5a 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -1,7 +1,7 @@ import FlexSearch from "flexsearch" import { ContentDetails } from "../../plugins/emitters/contentIndex" import { registerEscapeHandler, removeAllChildren } from "./util" -import { FullSlug, resolveRelative } from "../../util/path" +import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" interface Item { id: number @@ -71,20 +71,44 @@ function highlight(searchTerm: string, text: string, trim?: boolean) { }` } +const p = new DOMParser() const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/) let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined -document.addEventListener("nav", async (e: unknown) => { - const currentSlug = (e as CustomEventMap["nav"]).detail.url + +const fetchContentCache: Map = new Map() + +document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { + const currentSlug = e.detail.url const data = await fetchData const container = document.getElementById("search-container") const sidebar = container?.closest(".sidebar") as HTMLElement const searchIcon = document.getElementById("search-icon") const searchBar = document.getElementById("search-bar") as HTMLInputElement | null - const results = document.getElementById("results-container") + const searchLayout = document.getElementById("search-layout") const resultCards = document.getElementsByClassName("result-card") const idDataMap = Object.keys(data) as FullSlug[] + const appendLayout = (el: HTMLElement) => { + if (searchLayout?.querySelector(`#${el.id}`) === null) { + searchLayout?.appendChild(el) + } + } + + const enablePreview = searchLayout?.dataset?.preview === "true" + let preview: HTMLDivElement | undefined = undefined + const results = document.createElement("div") + results.id = "results-container" + results.style.flexBasis = enablePreview ? "30%" : "100%" + appendLayout(results) + + if (enablePreview) { + preview = document.createElement("div") + preview.id = "preview-container" + preview.style.flexBasis = "70%" + appendLayout(preview) + } + function hideSearch() { container?.classList.remove("active") if (searchBar) { @@ -96,6 +120,9 @@ document.addEventListener("nav", async (e: unknown) => { if (results) { removeAllChildren(results) } + if (preview) { + removeAllChildren(preview) + } searchType = "basic" // reset search type after closing } @@ -109,7 +136,7 @@ document.addEventListener("nav", async (e: unknown) => { searchBar?.focus() } - function shortcutHandler(e: HTMLElementEventMap["keydown"]) { + async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { e.preventDefault() const searchBarOpen = container?.classList.contains("active") @@ -139,6 +166,9 @@ document.addEventListener("nav", async (e: unknown) => { if (results?.contains(document.activeElement)) { // If an element in results-container already has focus, focus previous one const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null + if (enablePreview && prevResult?.id) { + await displayPreview(prevResult?.id as FullSlug) + } prevResult?.focus() } } else if (e.key === "ArrowDown" || e.key === "Tab") { @@ -146,10 +176,16 @@ document.addEventListener("nav", async (e: unknown) => { // When first pressing ArrowDown, results wont contain the active element, so focus first element if (!results?.contains(document.activeElement)) { const firstResult = resultCards[0] as HTMLInputElement | null + if (enablePreview && firstResult?.id) { + await displayPreview(firstResult?.id as FullSlug) + } firstResult?.focus() } else { // If an element in results-container already has focus, focus next one const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null + if (enablePreview && nextResult?.id) { + await displayPreview(nextResult?.id as FullSlug) + } nextResult?.focus() } } @@ -220,13 +256,17 @@ document.addEventListener("nav", async (e: unknown) => { } } + function resolveUrl(slug: FullSlug): URL { + return new URL(resolveRelative(currentSlug, slug), location.toString()) + } + const resultToHTML = ({ slug, title, content, tags }: Item) => { const htmlTags = tags.length > 0 ? `` : `` const itemTile = document.createElement("a") itemTile.classList.add("result-card") itemTile.id = slug - itemTile.href = new URL(resolveRelative(currentSlug, slug), location.toString()).toString() - itemTile.innerHTML = `

${title}

${htmlTags}

${content}

` + itemTile.href = resolveUrl(slug).toString() + itemTile.innerHTML = `

${title}

${htmlTags}${enablePreview && window.innerWidth > 600 ? "" : `

${content}

`}` itemTile.addEventListener("click", (event) => { if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return hideSearch() @@ -248,10 +288,47 @@ document.addEventListener("nav", async (e: unknown) => { } } + async function fetchContent(slug: FullSlug): Promise { + if (fetchContentCache.has(slug)) { + return fetchContentCache.get(slug) as Element[] + } + + const targetUrl = resolveUrl(slug).toString() + const contents = await fetch(targetUrl) + .then((res) => res.text()) + .then((contents) => { + if (contents === undefined) { + throw new Error(`Could not fetch ${targetUrl}`) + } + const html = p.parseFromString(contents ?? "", "text/html") + normalizeRelativeURLs(html, targetUrl) + return [...html.getElementsByClassName("popover-hint")] + }) + + fetchContentCache.set(slug, contents) + return contents + } + + async function displayPreview(slug: FullSlug) { + if (!searchLayout || !enablePreview) return + + removeAllChildren(preview as HTMLElement) + const contentDetails = await fetchContent(slug) + + const previewInner = document.createElement("div") + previewInner.classList.add("preview-inner") + preview?.appendChild(previewInner) + contentDetails?.forEach((elt) => previewInner.appendChild(elt)) + } + async function onType(e: HTMLElementEventMap["input"]) { let term = (e.target as HTMLInputElement).value let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[] + if (searchLayout) { + searchLayout.style.opacity = "1" + } + if (term.toLowerCase().startsWith("#")) { searchType = "tags" } else { diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss index d1d271637..f88803b4b 100644 --- a/quartz/components/styles/search.scss +++ b/quartz/components/styles/search.scss @@ -55,7 +55,7 @@ & > #search-space { width: 50%; - margin-top: 15vh; + margin-top: 12vh; margin-left: auto; margin-right: auto; @@ -86,93 +86,130 @@ } } - & > #results-container { - & .result-card { - padding: 1em; - cursor: pointer; - transition: background 0.2s ease; - border: 1px solid var(--lightgray); - border-bottom: none; - width: 100%; + & > #search-layout { + display: flex; + flex-direction: row; + justify-content: space-between; + opacity: 0; + + & > * { + height: calc(75vh - 20em); + background: none; + border-radius: 5px; + border: 1px solid var(--lightgray); // Border to define the box + } + + @media all and (max-width: $mobileBreakpoint) { + display: block; + & > *:not(#results-container) { + display: none !important; + } + + & > #results-container { + width: 100%; + height: auto; + } + } + + & > #preview-container { display: block; box-sizing: border-box; + overflow: hidden; - // normalize card props - font-family: inherit; - font-size: 100%; - line-height: 1.15; - margin: 0; - text-transform: none; - text-align: left; - background: var(--light); - outline: none; - font-weight: inherit; - - & .highlight { - color: var(--secondary); - font-weight: 700; + & .preview-inner { + padding: 1em; + height: 100%; + box-sizing: border-box; + overflow-y: auto; + font-family: inherit; + font-size: 1.1em; + color: var(--dark); + line-height: 1.5em; + font-weight: 400; + background: var(--light); + border-radius: 5px; + box-shadow: + 0 14px 50px rgba(27, 33, 48, 0.12), + 0 10px 30px rgba(27, 33, 48, 0.16); } + } - &:hover, - &:focus { - background: var(--lightgray); - } + & > #results-container { + overflow-y: auto; - &:first-of-type { - border-top-left-radius: 5px; - border-top-right-radius: 5px; - } + & .result-card { + padding: 1em; + cursor: pointer; + transition: background 0.2s ease; + width: 100%; + display: block; + box-sizing: border-box; - &:last-of-type { - border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; - border-bottom: 1px solid var(--lightgray); - } - - & > h3 { + // normalize card props + font-family: inherit; + font-size: 100%; + line-height: 1.15; margin: 0; - } + text-transform: none; + text-align: left; + background: var(--light); + outline: none; + font-weight: inherit; - & > ul > li { - margin: 0; - display: inline-block; - white-space: nowrap; - margin: 0; - overflow-wrap: normal; - } + & .highlight { + color: var(--secondary); + font-weight: 700; + } - & > ul { - list-style: none; - display: flex; - padding-left: 0; - gap: 0.4rem; - margin: 0; - margin-top: 0.45rem; - // Offset border radius - margin-left: -2px; - overflow: hidden; - background-clip: border-box; - } + &:hover, + &:focus { + background: var(--lightgray); + } - & > 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; - } + & > h3 { + margin: 0; + } - & > ul > li > .match-tag { - color: var(--tertiary); - font-weight: bold; - opacity: 1; - } + & > ul > li { + margin: 0; + display: inline-block; + white-space: nowrap; + margin: 0; + overflow-wrap: normal; + } - & > p { - margin-bottom: 0; + & > ul { + list-style: none; + display: flex; + padding-left: 0; + gap: 0.4rem; + margin: 0; + margin-top: 0.45rem; + box-sizing: border-box; + overflow: hidden; + background-clip: border-box; + } + + & > 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); + font-weight: bold; + opacity: 1; + } + + & > p { + margin-bottom: 0; + } } } }