diff options
Diffstat (limited to 'src/templates/assets/javascripts/components/search')
8 files changed, 1079 insertions, 0 deletions
diff --git a/src/templates/assets/javascripts/components/search/_/index.ts b/src/templates/assets/javascripts/components/search/_/index.ts new file mode 100644 index 00000000..aa963b47 --- /dev/null +++ b/src/templates/assets/javascripts/components/search/_/index.ts @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +import { + NEVER, + Observable, + ObservableInput, + filter, + fromEvent, + merge, + mergeWith +} from "rxjs" + +import { configuration } from "~/_" +import { + Keyboard, + getActiveElement, + getElements, + setToggle +} from "~/browser" +import { + SearchIndex, + SearchResult, + setupSearchWorker +} from "~/integrations" + +import { + Component, + getComponentElement, + getComponentElements +} from "../../_" +import { + SearchQuery, + mountSearchQuery +} from "../query" +import { mountSearchResult } from "../result" +import { + SearchShare, + mountSearchShare +} from "../share" +import { + SearchSuggest, + mountSearchSuggest +} from "../suggest" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Search + */ +export type Search = + | SearchQuery + | SearchResult + | SearchShare + | SearchSuggest + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + index$: ObservableInput<SearchIndex> /* Search index observable */ + keyboard$: Observable<Keyboard> /* Keyboard observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount search + * + * This function sets up the search functionality, including the underlying + * web worker and all keyboard bindings. + * + * @param el - Search element + * @param options - Options + * + * @returns Search component observable + */ +export function mountSearch( + el: HTMLElement, { index$, keyboard$ }: MountOptions +): Observable<Component<Search>> { + const config = configuration() + try { + const worker$ = setupSearchWorker(config.search, index$) + + /* Retrieve query and result components */ + const query = getComponentElement("search-query", el) + const result = getComponentElement("search-result", el) + + /* Always close search on result selection */ + fromEvent<PointerEvent>(el, "click") + .pipe( + filter(({ target }) => ( + target instanceof Element && !!target.closest("a") + )) + ) + .subscribe(() => setToggle("search", false)) + + /* Set up search keyboard handlers */ + keyboard$ + .pipe( + filter(({ mode }) => mode === "search") + ) + .subscribe(key => { + const active = getActiveElement() + switch (key.type) { + + /* Enter: go to first (best) result */ + case "Enter": + if (active === query) { + const anchors = new Map<HTMLAnchorElement, number>() + for (const anchor of getElements<HTMLAnchorElement>( + ":first-child [href]", result + )) { + const article = anchor.firstElementChild! + anchors.set(anchor, parseFloat( + article.getAttribute("data-md-score")! + )) + } + + /* Go to result with highest score, if any */ + if (anchors.size) { + const [[best]] = [...anchors].sort(([, a], [, b]) => b - a) + best.click() + } + + /* Otherwise omit form submission */ + key.claim() + } + break + + /* Escape or Tab: close search */ + case "Escape": + case "Tab": + setToggle("search", false) + query.blur() + break + + /* Vertical arrows: select previous or next search result */ + case "ArrowUp": + case "ArrowDown": + if (typeof active === "undefined") { + query.focus() + } else { + const els = [query, ...getElements( + ":not(details) > [href], summary, details[open] [href]", + result + )] + const i = Math.max(0, ( + Math.max(0, els.indexOf(active)) + els.length + ( + key.type === "ArrowUp" ? -1 : +1 + ) + ) % els.length) + els[i].focus() + } + + /* Prevent scrolling of page */ + key.claim() + break + + /* All other keys: hand to search query */ + default: + if (query !== getActiveElement()) + query.focus() + } + }) + + /* Set up global keyboard handlers */ + keyboard$ + .pipe( + filter(({ mode }) => mode === "global") + ) + .subscribe(key => { + switch (key.type) { + + /* Open search and select query */ + case "f": + case "s": + case "/": + query.focus() + query.select() + + /* Prevent scrolling of page */ + key.claim() + break + } + }) + + /* Create and return component */ + const query$ = mountSearchQuery(query, { worker$ }) + return merge( + query$, + mountSearchResult(result, { worker$, query$ }) + ) + .pipe( + mergeWith( + + /* Search sharing */ + ...getComponentElements("search-share", el) + .map(child => mountSearchShare(child, { query$ })), + + /* Search suggestions */ + ...getComponentElements("search-suggest", el) + .map(child => mountSearchSuggest(child, { worker$, keyboard$ })) + ) + ) + + /* Gracefully handle broken search */ + } catch (err) { + el.hidden = true + return NEVER + } +} diff --git a/src/templates/assets/javascripts/components/search/highlight/.eslintrc b/src/templates/assets/javascripts/components/search/highlight/.eslintrc new file mode 100644 index 00000000..38a5714d --- /dev/null +++ b/src/templates/assets/javascripts/components/search/highlight/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "no-null/no-null": "off" + } +} diff --git a/src/templates/assets/javascripts/components/search/highlight/index.ts b/src/templates/assets/javascripts/components/search/highlight/index.ts new file mode 100644 index 00000000..bc3f94c9 --- /dev/null +++ b/src/templates/assets/javascripts/components/search/highlight/index.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +import { + Observable, + ObservableInput, + combineLatest, + filter, + map, + startWith +} from "rxjs" + +import { getLocation } from "~/browser" +import { + SearchIndex, + setupSearchHighlighter +} from "~/integrations" +import { h } from "~/utilities" + +import { Component } from "../../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Search highlighting + */ +export interface SearchHighlight { + nodes: Map<ChildNode, string> /* Map of replacements */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + index$: ObservableInput<SearchIndex> /* Search index observable */ + location$: Observable<URL> /* Location observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount search highlighting + * + * @param el - Content element + * @param options - Options + * + * @returns Search highlighting component observable + */ +export function mountSearchHiglight( + el: HTMLElement, { index$, location$ }: MountOptions +): Observable<Component<SearchHighlight>> { + return combineLatest([ + index$, + location$ + .pipe( + startWith(getLocation()), + filter(url => !!url.searchParams.get("h")) + ) + ]) + .pipe( + map(([index, url]) => setupSearchHighlighter(index.config)( + url.searchParams.get("h")! + )), + map(fn => { + const nodes = new Map<ChildNode, string>() + + /* Traverse text nodes and collect matches */ + const it = document.createNodeIterator(el, NodeFilter.SHOW_TEXT) + for (let node = it.nextNode(); node; node = it.nextNode()) { + if (node.parentElement?.offsetHeight) { + const original = node.textContent! + const replaced = fn(original) + if (replaced.length > original.length) + nodes.set(node as ChildNode, replaced) + } + } + + /* Replace original nodes with matches */ + for (const [node, text] of nodes) { + const { childNodes } = h("span", null, text) + node.replaceWith(...Array.from(childNodes)) + } + + /* Return component */ + return { ref: el, nodes } + }) + ) +} diff --git a/src/templates/assets/javascripts/components/search/index.ts b/src/templates/assets/javascripts/components/search/index.ts new file mode 100644 index 00000000..846d8685 --- /dev/null +++ b/src/templates/assets/javascripts/components/search/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +export * from "./_" +export * from "./highlight" +export * from "./query" +export * from "./result" +export * from "./share" +export * from "./suggest" diff --git a/src/templates/assets/javascripts/components/search/query/index.ts b/src/templates/assets/javascripts/components/search/query/index.ts new file mode 100644 index 00000000..4ce21279 --- /dev/null +++ b/src/templates/assets/javascripts/components/search/query/index.ts @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +import { + Observable, + Subject, + combineLatest, + distinctUntilChanged, + distinctUntilKeyChanged, + endWith, + finalize, + first, + fromEvent, + ignoreElements, + map, + merge, + shareReplay, + takeUntil, + tap +} from "rxjs" + +import { + getElement, + getLocation, + setToggle, + watchElementFocus, + watchToggle +} from "~/browser" +import { + SearchMessage, + SearchMessageType, + isSearchReadyMessage +} from "~/integrations" + +import { Component } from "../../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Search query + */ +export interface SearchQuery { + value: string /* Query value */ + focus: boolean /* Query focus */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + worker$: Subject<SearchMessage> /* Search worker */ +} + +/** + * Mount options + */ +interface MountOptions { + worker$: Subject<SearchMessage> /* Search worker */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch search query + * + * Note that the focus event which triggers re-reading the current query value + * is delayed by `1ms` so the input's empty state is allowed to propagate. + * + * @param el - Search query element + * @param options - Options + * + * @returns Search query observable + */ +export function watchSearchQuery( + el: HTMLInputElement, { worker$ }: WatchOptions +): Observable<SearchQuery> { + + /* Support search deep linking */ + const { searchParams } = getLocation() + if (searchParams.has("q")) { + setToggle("search", true) + + /* Set query from parameter */ + el.value = searchParams.get("q")! + el.focus() + + /* Remove query parameter on close */ + watchToggle("search") + .pipe( + first(active => !active) + ) + .subscribe(() => { + const url = getLocation() + url.searchParams.delete("q") + history.replaceState({}, "", `${url}`) + }) + } + + /* Intercept focus and input events */ + const focus$ = watchElementFocus(el) + const value$ = merge( + worker$.pipe(first(isSearchReadyMessage)), + fromEvent(el, "keyup"), + focus$ + ) + .pipe( + map(() => el.value), + distinctUntilChanged() + ) + + /* Combine into single observable */ + return combineLatest([value$, focus$]) + .pipe( + map(([value, focus]) => ({ value, focus })), + shareReplay(1) + ) +} + +/** + * Mount search query + * + * @param el - Search query element + * @param options - Options + * + * @returns Search query component observable + */ +export function mountSearchQuery( + el: HTMLInputElement, { worker$ }: MountOptions +): Observable<Component<SearchQuery, HTMLInputElement>> { + const push$ = new Subject<SearchQuery>() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + + /* Handle value change */ + combineLatest([ + worker$.pipe(first(isSearchReadyMessage)), + push$ + ], (_, query) => query) + .pipe( + distinctUntilKeyChanged("value") + ) + .subscribe(({ value }) => worker$.next({ + type: SearchMessageType.QUERY, + data: value + })) + + /* Handle focus change */ + push$ + .pipe( + distinctUntilKeyChanged("focus") + ) + .subscribe(({ focus }) => { + if (focus) + setToggle("search", focus) + }) + + /* Handle reset */ + fromEvent(el.form!, "reset") + .pipe( + takeUntil(done$) + ) + .subscribe(() => el.focus()) + + // Focus search query on label click - note that this is necessary to bring + // up the keyboard on iOS and other mobile platforms, as the search dialog is + // not visible at first, and programatically focusing an input element must + // be triggered by a user interaction - see https://t.ly/Cb30n + const label = getElement("header [for=__search]") + fromEvent(label, "click") + .subscribe(() => el.focus()) + + /* Create and return component */ + return watchSearchQuery(el, { worker$ }) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })), + shareReplay(1) + ) +} diff --git a/src/templates/assets/javascripts/components/search/result/index.ts b/src/templates/assets/javascripts/components/search/result/index.ts new file mode 100644 index 00000000..c3c9ef20 --- /dev/null +++ b/src/templates/assets/javascripts/components/search/result/index.ts @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +import { + EMPTY, + Observable, + Subject, + bufferCount, + filter, + finalize, + first, + fromEvent, + map, + merge, + mergeMap, + of, + share, + skipUntil, + switchMap, + takeUntil, + tap, + withLatestFrom, + zipWith +} from "rxjs" + +import { translation } from "~/_" +import { + getElement, + getOptionalElement, + watchElementBoundary, + watchToggle +} from "~/browser" +import { + SearchMessage, + SearchResult, + isSearchReadyMessage, + isSearchResultMessage +} from "~/integrations" +import { renderSearchResultItem } from "~/templates" +import { round } from "~/utilities" + +import { Component } from "../../_" +import { SearchQuery } from "../query" + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + query$: Observable<SearchQuery> /* Search query observable */ + worker$: Subject<SearchMessage> /* Search worker */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount search result list + * + * This function performs a lazy rendering of the search results, depending on + * the vertical offset of the search result container. + * + * @param el - Search result list element + * @param options - Options + * + * @returns Search result list component observable + */ +export function mountSearchResult( + el: HTMLElement, { worker$, query$ }: MountOptions +): Observable<Component<SearchResult>> { + const push$ = new Subject<SearchResult>() + const boundary$ = watchElementBoundary(el.parentElement!) + .pipe( + filter(Boolean) + ) + + /* Retrieve container */ + const container = el.parentElement! + + /* Retrieve nested components */ + const meta = getElement(":scope > :first-child", el) + const list = getElement(":scope > :last-child", el) + + /* Reveal to accessibility tree – see https://bit.ly/3iAA7t8 */ + watchToggle("search") + .subscribe(active => list.setAttribute( + "role", active ? "list" : "presentation" + )) + + /* Update search result metadata */ + push$ + .pipe( + withLatestFrom(query$), + skipUntil(worker$.pipe(first(isSearchReadyMessage))) + ) + .subscribe(([{ items }, { value }]) => { + switch (items.length) { + + /* No results */ + case 0: + meta.textContent = value.length + ? translation("search.result.none") + : translation("search.result.placeholder") + break + + /* One result */ + case 1: + meta.textContent = translation("search.result.one") + break + + /* Multiple result */ + default: + const count = round(items.length) + meta.textContent = translation("search.result.other", count) + } + }) + + /* Render search result item */ + const render$ = push$ + .pipe( + tap(() => list.innerHTML = ""), + switchMap(({ items }) => merge( + of(...items.slice(0, 10)), + of(...items.slice(10)) + .pipe( + bufferCount(4), + zipWith(boundary$), + switchMap(([chunk]) => chunk) + ) + )), + map(renderSearchResultItem), + share() + ) + + /* Update search result list */ + render$.subscribe(item => list.appendChild(item)) + render$ + .pipe( + mergeMap(item => { + const details = getOptionalElement("details", item) + if (typeof details === "undefined") + return EMPTY + + /* Keep position of details element stable */ + return fromEvent(details, "toggle") + .pipe( + takeUntil(push$), + map(() => details) + ) + }) + ) + .subscribe(details => { + if ( + details.open === false && + details.offsetTop <= container.scrollTop + ) + container.scrollTo({ top: details.offsetTop }) + }) + + /* Filter search result message */ + const result$ = worker$ + .pipe( + filter(isSearchResultMessage), + map(({ data }) => data) + ) + + /* Create and return component */ + return result$ + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) +} diff --git a/src/templates/assets/javascripts/components/search/share/index.ts b/src/templates/assets/javascripts/components/search/share/index.ts new file mode 100644 index 00000000..3db382c8 --- /dev/null +++ b/src/templates/assets/javascripts/components/search/share/index.ts @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +import { + Observable, + Subject, + endWith, + finalize, + fromEvent, + ignoreElements, + map, + takeUntil, + tap +} from "rxjs" + +import { getLocation } from "~/browser" + +import { Component } from "../../_" +import { SearchQuery } from "../query" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Search sharing + */ +export interface SearchShare { + url: URL /* Deep link for sharing */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + query$: Observable<SearchQuery> /* Search query observable */ +} + +/** + * Mount options + */ +interface MountOptions { + query$: Observable<SearchQuery> /* Search query observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount search sharing + * + * @param _el - Search sharing element + * @param options - Options + * + * @returns Search sharing observable + */ +export function watchSearchShare( + _el: HTMLElement, { query$ }: WatchOptions +): Observable<SearchShare> { + return query$ + .pipe( + map(({ value }) => { + const url = getLocation() + url.hash = "" + + /* Compute readable query strings */ + value = value + .replace(/\s+/g, "+") /* Collapse whitespace */ + .replace(/&/g, "%26") /* Escape '&' character */ + .replace(/=/g, "%3D") /* Escape '=' character */ + + /* Replace query string */ + url.search = `q=${value}` + return { url } + }) + ) +} + +/** + * Mount search sharing + * + * @param el - Search sharing element + * @param options - Options + * + * @returns Search sharing component observable + */ +export function mountSearchShare( + el: HTMLAnchorElement, options: MountOptions +): Observable<Component<SearchShare>> { + const push$ = new Subject<SearchShare>() + const done$ = push$.pipe(ignoreElements(), endWith(true)) + push$.subscribe(({ url }) => { + el.setAttribute("data-clipboard-text", el.href) + el.href = `${url}` + }) + + /* Prevent following of link */ + fromEvent(el, "click") + .pipe( + takeUntil(done$) + ) + .subscribe(ev => ev.preventDefault()) + + /* Create and return component */ + return watchSearchShare(el, options) + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(state => ({ ref: el, ...state })) + ) +} diff --git a/src/templates/assets/javascripts/components/search/suggest/index.ts b/src/templates/assets/javascripts/components/search/suggest/index.ts new file mode 100644 index 00000000..e7881475 --- /dev/null +++ b/src/templates/assets/javascripts/components/search/suggest/index.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +import { + Observable, + Subject, + asyncScheduler, + combineLatestWith, + distinctUntilChanged, + filter, + finalize, + fromEvent, + map, + merge, + observeOn, + tap +} from "rxjs" + +import { Keyboard } from "~/browser" +import { + SearchMessage, + SearchResult, + isSearchResultMessage +} from "~/integrations" + +import { Component, getComponentElement } from "../../_" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Search suggestions + */ +export interface SearchSuggest {} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Mount options + */ +interface MountOptions { + keyboard$: Observable<Keyboard> /* Keyboard observable */ + worker$: Subject<SearchMessage> /* Search worker */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Mount search suggestions + * + * This function will perform a lazy rendering of the search results, depending + * on the vertical offset of the search result container. + * + * @param el - Search result list element + * @param options - Options + * + * @returns Search result list component observable + */ +export function mountSearchSuggest( + el: HTMLElement, { worker$, keyboard$ }: MountOptions +): Observable<Component<SearchSuggest>> { + const push$ = new Subject<SearchResult>() + + /* Retrieve query component and track all changes */ + const query = getComponentElement("search-query") + const query$ = merge( + fromEvent(query, "keydown"), + fromEvent(query, "focus") + ) + .pipe( + observeOn(asyncScheduler), + map(() => query.value), + distinctUntilChanged(), + ) + + /* Update search suggestions */ + push$ + .pipe( + combineLatestWith(query$), + map(([{ suggest }, value]) => { + const words = value.split(/([\s-]+)/) + if (suggest?.length && words[words.length - 1]) { + const last = suggest[suggest.length - 1] + if (last.startsWith(words[words.length - 1])) + words[words.length - 1] = last + } else { + words.length = 0 + } + return words + }) + ) + .subscribe(words => el.innerHTML = words + .join("") + .replace(/\s/g, " ") + ) + + /* Set up search keyboard handlers */ + keyboard$ + .pipe( + filter(({ mode }) => mode === "search") + ) + .subscribe(key => { + switch (key.type) { + + /* Right arrow: accept current suggestion */ + case "ArrowRight": + if ( + el.innerText.length && + query.selectionStart === query.value.length + ) + query.value = el.innerText + break + } + }) + + /* Filter search result message */ + const result$ = worker$ + .pipe( + filter(isSearchResultMessage), + map(({ data }) => data) + ) + + /* Create and return component */ + return result$ + .pipe( + tap(state => push$.next(state)), + finalize(() => push$.complete()), + map(() => ({ ref: el })) + ) +} |
