* 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>
This commit is contained in:
parent
4e5643fb49
commit
a29fadb046
@ -4,8 +4,18 @@ import style from "./styles/search.scss"
|
|||||||
import script from "./scripts/search.inline"
|
import script from "./scripts/search.inline"
|
||||||
import { classNames } from "../util/lang"
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
export default (() => {
|
export interface SearchOptions {
|
||||||
|
enablePreview: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: SearchOptions = {
|
||||||
|
enablePreview: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ((userOpts?: Partial<SearchOptions>) => {
|
||||||
function Search({ displayClass }: QuartzComponentProps) {
|
function Search({ displayClass }: QuartzComponentProps) {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={classNames(displayClass, "search")}>
|
<div class={classNames(displayClass, "search")}>
|
||||||
<div id="search-icon">
|
<div id="search-icon">
|
||||||
@ -36,7 +46,7 @@ export default (() => {
|
|||||||
aria-label="Search for something"
|
aria-label="Search for something"
|
||||||
placeholder="Search for something"
|
placeholder="Search for something"
|
||||||
/>
|
/>
|
||||||
<div id="results-container"></div>
|
<div id="search-layout" data-preview={opts.enablePreview}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import FlexSearch from "flexsearch"
|
import FlexSearch from "flexsearch"
|
||||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||||
import { FullSlug, resolveRelative } from "../../util/path"
|
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
id: number
|
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])/)
|
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
|
||||||
let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined
|
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<FullSlug, Element[]> = new Map()
|
||||||
|
|
||||||
|
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||||
|
const currentSlug = e.detail.url
|
||||||
|
|
||||||
const data = await fetchData
|
const data = await fetchData
|
||||||
const container = document.getElementById("search-container")
|
const container = document.getElementById("search-container")
|
||||||
const sidebar = container?.closest(".sidebar") as HTMLElement
|
const sidebar = container?.closest(".sidebar") as HTMLElement
|
||||||
const searchIcon = document.getElementById("search-icon")
|
const searchIcon = document.getElementById("search-icon")
|
||||||
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
|
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 resultCards = document.getElementsByClassName("result-card")
|
||||||
const idDataMap = Object.keys(data) as FullSlug[]
|
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() {
|
function hideSearch() {
|
||||||
container?.classList.remove("active")
|
container?.classList.remove("active")
|
||||||
if (searchBar) {
|
if (searchBar) {
|
||||||
@ -96,6 +120,9 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
if (results) {
|
if (results) {
|
||||||
removeAllChildren(results)
|
removeAllChildren(results)
|
||||||
}
|
}
|
||||||
|
if (preview) {
|
||||||
|
removeAllChildren(preview)
|
||||||
|
}
|
||||||
|
|
||||||
searchType = "basic" // reset search type after closing
|
searchType = "basic" // reset search type after closing
|
||||||
}
|
}
|
||||||
@ -109,7 +136,7 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
searchBar?.focus()
|
searchBar?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||||
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const searchBarOpen = container?.classList.contains("active")
|
const searchBarOpen = container?.classList.contains("active")
|
||||||
@ -139,6 +166,9 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
if (results?.contains(document.activeElement)) {
|
if (results?.contains(document.activeElement)) {
|
||||||
// If an element in results-container already has focus, focus previous one
|
// If an element in results-container already has focus, focus previous one
|
||||||
const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null
|
const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null
|
||||||
|
if (enablePreview && prevResult?.id) {
|
||||||
|
await displayPreview(prevResult?.id as FullSlug)
|
||||||
|
}
|
||||||
prevResult?.focus()
|
prevResult?.focus()
|
||||||
}
|
}
|
||||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
} 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
|
// When first pressing ArrowDown, results wont contain the active element, so focus first element
|
||||||
if (!results?.contains(document.activeElement)) {
|
if (!results?.contains(document.activeElement)) {
|
||||||
const firstResult = resultCards[0] as HTMLInputElement | null
|
const firstResult = resultCards[0] as HTMLInputElement | null
|
||||||
|
if (enablePreview && firstResult?.id) {
|
||||||
|
await displayPreview(firstResult?.id as FullSlug)
|
||||||
|
}
|
||||||
firstResult?.focus()
|
firstResult?.focus()
|
||||||
} else {
|
} else {
|
||||||
// If an element in results-container already has focus, focus next one
|
// If an element in results-container already has focus, focus next one
|
||||||
const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null
|
const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null
|
||||||
|
if (enablePreview && nextResult?.id) {
|
||||||
|
await displayPreview(nextResult?.id as FullSlug)
|
||||||
|
}
|
||||||
nextResult?.focus()
|
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 resultToHTML = ({ slug, title, content, tags }: Item) => {
|
||||||
const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
|
const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
|
||||||
const itemTile = document.createElement("a")
|
const itemTile = document.createElement("a")
|
||||||
itemTile.classList.add("result-card")
|
itemTile.classList.add("result-card")
|
||||||
itemTile.id = slug
|
itemTile.id = slug
|
||||||
itemTile.href = new URL(resolveRelative(currentSlug, slug), location.toString()).toString()
|
itemTile.href = resolveUrl(slug).toString()
|
||||||
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
|
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}${enablePreview && window.innerWidth > 600 ? "" : `<p>${content}</p>`}`
|
||||||
itemTile.addEventListener("click", (event) => {
|
itemTile.addEventListener("click", (event) => {
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||||
hideSearch()
|
hideSearch()
|
||||||
@ -248,10 +288,47 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchContent(slug: FullSlug): Promise<Element[]> {
|
||||||
|
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"]) {
|
async function onType(e: HTMLElementEventMap["input"]) {
|
||||||
let term = (e.target as HTMLInputElement).value
|
let term = (e.target as HTMLInputElement).value
|
||||||
let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
|
let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
|
||||||
|
|
||||||
|
if (searchLayout) {
|
||||||
|
searchLayout.style.opacity = "1"
|
||||||
|
}
|
||||||
|
|
||||||
if (term.toLowerCase().startsWith("#")) {
|
if (term.toLowerCase().startsWith("#")) {
|
||||||
searchType = "tags"
|
searchType = "tags"
|
||||||
} else {
|
} else {
|
||||||
|
@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
& > #search-space {
|
& > #search-space {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
margin-top: 15vh;
|
margin-top: 12vh;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|
||||||
@ -86,13 +86,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > #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 {
|
& > #results-container {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > #preview-container {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
& .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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > #results-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
& .result-card {
|
& .result-card {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
border: 1px solid var(--lightgray);
|
|
||||||
border-bottom: none;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -118,17 +166,6 @@
|
|||||||
background: var(--lightgray);
|
background: var(--lightgray);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:first-of-type {
|
|
||||||
border-top-left-radius: 5px;
|
|
||||||
border-top-right-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-of-type {
|
|
||||||
border-bottom-left-radius: 5px;
|
|
||||||
border-bottom-right-radius: 5px;
|
|
||||||
border-bottom: 1px solid var(--lightgray);
|
|
||||||
}
|
|
||||||
|
|
||||||
& > h3 {
|
& > h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@ -148,8 +185,7 @@
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-top: 0.45rem;
|
margin-top: 0.45rem;
|
||||||
// Offset border radius
|
box-sizing: border-box;
|
||||||
margin-left: -2px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-clip: border-box;
|
background-clip: border-box;
|
||||||
}
|
}
|
||||||
@ -178,4 +214,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user