The Trans Directory
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(search): experimental telescope layout (closes #718) (#722)

* feat(search): telescope-style search

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* chore(search): cleanup some basis and borders

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* fix(search): make sure to set overflow-y

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* feat(search): shows preview on desktop only search

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* perf: add options to control layout through config

cache memoize results to avoid fetching

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* chore: use the default configuration

* fix: correct minor type for search

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* fix: use datasets to query for preview

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* chore: layout changes

show preview on normal layout, and only show previous layout in list page.

* fix(type): annotate search with types

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* chore: apply jacky's suggestion

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

* chore: using map API and scss

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* fix: styling on search container view on phones

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* Update quartz.layout.ts

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

authored by

Aaron Pham
Jacky Zhao
and committed by
GitHub
a29fadb0 4e5643fb

+206 -82
+12 -2
quartz/components/Search.tsx
··· 4 4 import script from "./scripts/search.inline" 5 5 import { classNames } from "../util/lang" 6 6 7 - export default (() => { 7 + export interface SearchOptions { 8 + enablePreview: boolean 9 + } 10 + 11 + const defaultOptions: SearchOptions = { 12 + enablePreview: true, 13 + } 14 + 15 + export default ((userOpts?: Partial<SearchOptions>) => { 8 16 function Search({ displayClass }: QuartzComponentProps) { 17 + const opts = { ...defaultOptions, ...userOpts } 18 + 9 19 return ( 10 20 <div class={classNames(displayClass, "search")}> 11 21 <div id="search-icon"> ··· 36 46 aria-label="Search for something" 37 47 placeholder="Search for something" 38 48 /> 39 - <div id="results-container"></div> 49 + <div id="search-layout" data-preview={opts.enablePreview}></div> 40 50 </div> 41 51 </div> 42 52 </div>
+84 -7
quartz/components/scripts/search.inline.ts
··· 1 1 import FlexSearch from "flexsearch" 2 2 import { ContentDetails } from "../../plugins/emitters/contentIndex" 3 3 import { registerEscapeHandler, removeAllChildren } from "./util" 4 - import { FullSlug, resolveRelative } from "../../util/path" 4 + import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" 5 5 6 6 interface Item { 7 7 id: number ··· 71 71 }` 72 72 } 73 73 74 + const p = new DOMParser() 74 75 const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/) 75 76 let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined 76 - document.addEventListener("nav", async (e: unknown) => { 77 - const currentSlug = (e as CustomEventMap["nav"]).detail.url 77 + 78 + const fetchContentCache: Map<FullSlug, Element[]> = new Map() 79 + 80 + document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { 81 + const currentSlug = e.detail.url 78 82 79 83 const data = await fetchData 80 84 const container = document.getElementById("search-container") 81 85 const sidebar = container?.closest(".sidebar") as HTMLElement 82 86 const searchIcon = document.getElementById("search-icon") 83 87 const searchBar = document.getElementById("search-bar") as HTMLInputElement | null 84 - const results = document.getElementById("results-container") 88 + const searchLayout = document.getElementById("search-layout") 85 89 const resultCards = document.getElementsByClassName("result-card") 86 90 const idDataMap = Object.keys(data) as FullSlug[] 87 91 92 + const appendLayout = (el: HTMLElement) => { 93 + if (searchLayout?.querySelector(`#${el.id}`) === null) { 94 + searchLayout?.appendChild(el) 95 + } 96 + } 97 + 98 + const enablePreview = searchLayout?.dataset?.preview === "true" 99 + let preview: HTMLDivElement | undefined = undefined 100 + const results = document.createElement("div") 101 + results.id = "results-container" 102 + results.style.flexBasis = enablePreview ? "30%" : "100%" 103 + appendLayout(results) 104 + 105 + if (enablePreview) { 106 + preview = document.createElement("div") 107 + preview.id = "preview-container" 108 + preview.style.flexBasis = "70%" 109 + appendLayout(preview) 110 + } 111 + 88 112 function hideSearch() { 89 113 container?.classList.remove("active") 90 114 if (searchBar) { ··· 95 119 } 96 120 if (results) { 97 121 removeAllChildren(results) 122 + } 123 + if (preview) { 124 + removeAllChildren(preview) 98 125 } 99 126 100 127 searchType = "basic" // reset search type after closing ··· 109 136 searchBar?.focus() 110 137 } 111 138 112 - function shortcutHandler(e: HTMLElementEventMap["keydown"]) { 139 + async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { 113 140 if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { 114 141 e.preventDefault() 115 142 const searchBarOpen = container?.classList.contains("active") ··· 139 166 if (results?.contains(document.activeElement)) { 140 167 // If an element in results-container already has focus, focus previous one 141 168 const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null 169 + if (enablePreview && prevResult?.id) { 170 + await displayPreview(prevResult?.id as FullSlug) 171 + } 142 172 prevResult?.focus() 143 173 } 144 174 } else if (e.key === "ArrowDown" || e.key === "Tab") { ··· 146 176 // When first pressing ArrowDown, results wont contain the active element, so focus first element 147 177 if (!results?.contains(document.activeElement)) { 148 178 const firstResult = resultCards[0] as HTMLInputElement | null 179 + if (enablePreview && firstResult?.id) { 180 + await displayPreview(firstResult?.id as FullSlug) 181 + } 149 182 firstResult?.focus() 150 183 } else { 151 184 // If an element in results-container already has focus, focus next one 152 185 const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null 186 + if (enablePreview && nextResult?.id) { 187 + await displayPreview(nextResult?.id as FullSlug) 188 + } 153 189 nextResult?.focus() 154 190 } 155 191 } ··· 220 256 } 221 257 } 222 258 259 + function resolveUrl(slug: FullSlug): URL { 260 + return new URL(resolveRelative(currentSlug, slug), location.toString()) 261 + } 262 + 223 263 const resultToHTML = ({ slug, title, content, tags }: Item) => { 224 264 const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : `` 225 265 const itemTile = document.createElement("a") 226 266 itemTile.classList.add("result-card") 227 267 itemTile.id = slug 228 - itemTile.href = new URL(resolveRelative(currentSlug, slug), location.toString()).toString() 229 - itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>` 268 + itemTile.href = resolveUrl(slug).toString() 269 + itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}${enablePreview && window.innerWidth > 600 ? "" : `<p>${content}</p>`}` 230 270 itemTile.addEventListener("click", (event) => { 231 271 if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return 232 272 hideSearch() ··· 248 288 } 249 289 } 250 290 291 + async function fetchContent(slug: FullSlug): Promise<Element[]> { 292 + if (fetchContentCache.has(slug)) { 293 + return fetchContentCache.get(slug) as Element[] 294 + } 295 + 296 + const targetUrl = resolveUrl(slug).toString() 297 + const contents = await fetch(targetUrl) 298 + .then((res) => res.text()) 299 + .then((contents) => { 300 + if (contents === undefined) { 301 + throw new Error(`Could not fetch ${targetUrl}`) 302 + } 303 + const html = p.parseFromString(contents ?? "", "text/html") 304 + normalizeRelativeURLs(html, targetUrl) 305 + return [...html.getElementsByClassName("popover-hint")] 306 + }) 307 + 308 + fetchContentCache.set(slug, contents) 309 + return contents 310 + } 311 + 312 + async function displayPreview(slug: FullSlug) { 313 + if (!searchLayout || !enablePreview) return 314 + 315 + removeAllChildren(preview as HTMLElement) 316 + const contentDetails = await fetchContent(slug) 317 + 318 + const previewInner = document.createElement("div") 319 + previewInner.classList.add("preview-inner") 320 + preview?.appendChild(previewInner) 321 + contentDetails?.forEach((elt) => previewInner.appendChild(elt)) 322 + } 323 + 251 324 async function onType(e: HTMLElementEventMap["input"]) { 252 325 let term = (e.target as HTMLInputElement).value 253 326 let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[] 327 + 328 + if (searchLayout) { 329 + searchLayout.style.opacity = "1" 330 + } 254 331 255 332 if (term.toLowerCase().startsWith("#")) { 256 333 searchType = "tags"
+110 -73
quartz/components/styles/search.scss
··· 55 55 56 56 & > #search-space { 57 57 width: 50%; 58 - margin-top: 15vh; 58 + margin-top: 12vh; 59 59 margin-left: auto; 60 60 margin-right: auto; 61 61 ··· 86 86 } 87 87 } 88 88 89 - & > #results-container { 90 - & .result-card { 91 - padding: 1em; 92 - cursor: pointer; 93 - transition: background 0.2s ease; 94 - border: 1px solid var(--lightgray); 95 - border-bottom: none; 96 - width: 100%; 97 - display: block; 98 - box-sizing: border-box; 89 + & > #search-layout { 90 + display: flex; 91 + flex-direction: row; 92 + justify-content: space-between; 93 + opacity: 0; 99 94 100 - // normalize card props 101 - font-family: inherit; 102 - font-size: 100%; 103 - line-height: 1.15; 104 - margin: 0; 105 - text-transform: none; 106 - text-align: left; 107 - background: var(--light); 108 - outline: none; 109 - font-weight: inherit; 95 + & > * { 96 + height: calc(75vh - 20em); 97 + background: none; 98 + border-radius: 5px; 99 + border: 1px solid var(--lightgray); // Border to define the box 100 + } 110 101 111 - & .highlight { 112 - color: var(--secondary); 113 - font-weight: 700; 102 + @media all and (max-width: $mobileBreakpoint) { 103 + display: block; 104 + & > *:not(#results-container) { 105 + display: none !important; 114 106 } 115 107 116 - &:hover, 117 - &:focus { 118 - background: var(--lightgray); 108 + & > #results-container { 109 + width: 100%; 110 + height: auto; 119 111 } 112 + } 120 113 121 - &:first-of-type { 122 - border-top-left-radius: 5px; 123 - border-top-right-radius: 5px; 124 - } 114 + & > #preview-container { 115 + display: block; 116 + box-sizing: border-box; 117 + overflow: hidden; 125 118 126 - &:last-of-type { 127 - border-bottom-left-radius: 5px; 128 - border-bottom-right-radius: 5px; 129 - border-bottom: 1px solid var(--lightgray); 119 + & .preview-inner { 120 + padding: 1em; 121 + height: 100%; 122 + box-sizing: border-box; 123 + overflow-y: auto; 124 + font-family: inherit; 125 + font-size: 1.1em; 126 + color: var(--dark); 127 + line-height: 1.5em; 128 + font-weight: 400; 129 + background: var(--light); 130 + border-radius: 5px; 131 + box-shadow: 132 + 0 14px 50px rgba(27, 33, 48, 0.12), 133 + 0 10px 30px rgba(27, 33, 48, 0.16); 130 134 } 135 + } 131 136 132 - & > h3 { 137 + & > #results-container { 138 + overflow-y: auto; 139 + 140 + & .result-card { 141 + padding: 1em; 142 + cursor: pointer; 143 + transition: background 0.2s ease; 144 + width: 100%; 145 + display: block; 146 + box-sizing: border-box; 147 + 148 + // normalize card props 149 + font-family: inherit; 150 + font-size: 100%; 151 + line-height: 1.15; 133 152 margin: 0; 134 - } 153 + text-transform: none; 154 + text-align: left; 155 + background: var(--light); 156 + outline: none; 157 + font-weight: inherit; 135 158 136 - & > ul > li { 137 - margin: 0; 138 - display: inline-block; 139 - white-space: nowrap; 140 - margin: 0; 141 - overflow-wrap: normal; 142 - } 159 + & .highlight { 160 + color: var(--secondary); 161 + font-weight: 700; 162 + } 163 + 164 + &:hover, 165 + &:focus { 166 + background: var(--lightgray); 167 + } 143 168 144 - & > ul { 145 - list-style: none; 146 - display: flex; 147 - padding-left: 0; 148 - gap: 0.4rem; 149 - margin: 0; 150 - margin-top: 0.45rem; 151 - // Offset border radius 152 - margin-left: -2px; 153 - overflow: hidden; 154 - background-clip: border-box; 155 - } 169 + & > h3 { 170 + margin: 0; 171 + } 172 + 173 + & > ul > li { 174 + margin: 0; 175 + display: inline-block; 176 + white-space: nowrap; 177 + margin: 0; 178 + overflow-wrap: normal; 179 + } 180 + 181 + & > ul { 182 + list-style: none; 183 + display: flex; 184 + padding-left: 0; 185 + gap: 0.4rem; 186 + margin: 0; 187 + margin-top: 0.45rem; 188 + box-sizing: border-box; 189 + overflow: hidden; 190 + background-clip: border-box; 191 + } 156 192 157 - & > ul > li > p { 158 - border-radius: 8px; 159 - background-color: var(--highlight); 160 - overflow: hidden; 161 - background-clip: border-box; 162 - padding: 0.03rem 0.4rem; 163 - margin: 0; 164 - color: var(--secondary); 165 - opacity: 0.85; 166 - } 193 + & > ul > li > p { 194 + border-radius: 8px; 195 + background-color: var(--highlight); 196 + overflow: hidden; 197 + background-clip: border-box; 198 + padding: 0.03rem 0.4rem; 199 + margin: 0; 200 + color: var(--secondary); 201 + opacity: 0.85; 202 + } 167 203 168 - & > ul > li > .match-tag { 169 - color: var(--tertiary); 170 - font-weight: bold; 171 - opacity: 1; 172 - } 204 + & > ul > li > .match-tag { 205 + color: var(--tertiary); 206 + font-weight: bold; 207 + opacity: 1; 208 + } 173 209 174 - & > p { 175 - margin-bottom: 0; 210 + & > p { 211 + margin-bottom: 0; 212 + } 176 213 } 177 214 } 178 215 }