From a18ac26f59bf0f12cd4925195283144e0700dd15 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 19 Dec 2019 14:05:00 +0100 Subject: [PATCH] Implemented lazy rendering of search results --- src/assets/javascripts/actions/index.ts | 1 + .../javascripts/actions/search/index.ts | 23 ++++ .../actions/search/result/index.ts | 48 ++++++++ .../components/header/offset/index.ts | 8 +- .../javascripts/components/search/index.ts | 1 + .../components/search/query/index.ts | 1 - .../components/search/result/index.ts | 106 ++++++++++++++++++ .../javascripts/templates/search/_/index.tsx | 8 +- .../templates/search/article/index.tsx | 8 +- .../templates/search/section/index.tsx | 8 +- .../utilities/agent/element/_/index.ts | 57 ++++++++++ .../utilities/agent/element/index.ts | 37 +----- .../utilities/agent/element/offset/index.ts | 89 +++++++++++++++ .../utilities/agent/worker/index.ts | 12 +- .../javascripts/utilities/string/index.ts | 2 +- 15 files changed, 350 insertions(+), 59 deletions(-) create mode 100644 src/assets/javascripts/actions/search/index.ts create mode 100644 src/assets/javascripts/actions/search/result/index.ts create mode 100644 src/assets/javascripts/components/search/result/index.ts create mode 100644 src/assets/javascripts/utilities/agent/element/_/index.ts create mode 100644 src/assets/javascripts/utilities/agent/element/offset/index.ts diff --git a/src/assets/javascripts/actions/index.ts b/src/assets/javascripts/actions/index.ts index 2b02927a6..06ccbeced 100644 --- a/src/assets/javascripts/actions/index.ts +++ b/src/assets/javascripts/actions/index.ts @@ -22,4 +22,5 @@ export * from "./header" export * from "./hidden" +export * from "./search" export * from "./sidebar" diff --git a/src/assets/javascripts/actions/search/index.ts b/src/assets/javascripts/actions/search/index.ts new file mode 100644 index 000000000..94fea77ac --- /dev/null +++ b/src/assets/javascripts/actions/search/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2016-2019 Martin Donath + * + * 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 "./result" diff --git a/src/assets/javascripts/actions/search/result/index.ts b/src/assets/javascripts/actions/search/result/index.ts new file mode 100644 index 000000000..2696d2a8a --- /dev/null +++ b/src/assets/javascripts/actions/search/result/index.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2016-2019 Martin Donath + * + * 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. + */ + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Add an element to the search result list + * + * @param el - Search result list element + * @param child - Search result element + */ +export function addToSearchResultList( + el: HTMLElement, child: HTMLElement +): void { + el.appendChild(child) +} + +/** + * Reset search result list + * + * @param el - Search result list element + */ +export function resetSearchResultList( + el: HTMLElement +): void { + el.innerHTML = "" +} diff --git a/src/assets/javascripts/components/header/offset/index.ts b/src/assets/javascripts/components/header/offset/index.ts index d953b6192..5e376edf6 100644 --- a/src/assets/javascripts/components/header/offset/index.ts +++ b/src/assets/javascripts/components/header/offset/index.ts @@ -54,12 +54,12 @@ interface Options { * This function returns an observable that computes the relative offset to the * top of the given element based on the current viewport offset. * - * @param el - Element + * @param el - HTML element * @param options - Options * * @return Viewport offset observable */ -export function watchTopOffset( +export function watchHeaderOffsetToTopOf( el: HTMLElement, { size$, offset$, header$ }: Options ): Observable { @@ -85,12 +85,12 @@ export function watchTopOffset( * This function returns an observable that computes the relative offset to the * bottom of the given element based on the current viewport offset. * - * @param el - Element + * @param el - HTML element * @param options - Options * * @return Viewport offset observable */ -export function watchBottomOffset( +export function watchHeaderOffsetToBottomOf( el: HTMLElement, { size$, offset$, header$ }: Options ): Observable { diff --git a/src/assets/javascripts/components/search/index.ts b/src/assets/javascripts/components/search/index.ts index 89a70d6d1..45085084b 100644 --- a/src/assets/javascripts/components/search/index.ts +++ b/src/assets/javascripts/components/search/index.ts @@ -22,3 +22,4 @@ // export * from "./query" export * from "./reset" +export * from "./result" diff --git a/src/assets/javascripts/components/search/query/index.ts b/src/assets/javascripts/components/search/query/index.ts index ca66b701c..067f9369b 100644 --- a/src/assets/javascripts/components/search/query/index.ts +++ b/src/assets/javascripts/components/search/query/index.ts @@ -19,4 +19,3 @@ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS * IN THE SOFTWARE. */ - diff --git a/src/assets/javascripts/components/search/result/index.ts b/src/assets/javascripts/components/search/result/index.ts new file mode 100644 index 000000000..1ad954155 --- /dev/null +++ b/src/assets/javascripts/components/search/result/index.ts @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2016-2019 Martin Donath + * + * 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 { identity } from "ramda" +import { + MonoTypeOperatorFunction, + Observable, + animationFrameScheduler, + pipe +} from "rxjs" +import { + distinctUntilChanged, + filter, + finalize, + map, + mapTo, + observeOn, + scan, + switchMap +} from "rxjs/operators" + +import { addToSearchResultList, resetSearchResultList } from "actions" +import { SearchResult } from "modules" +import { renderSearchResult } from "templates" +import { ViewportSize, watchElementOffset } from "utilities" + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Options + */ +interface Options { + size$: Observable /* Viewport size observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Paint search result from source observable + * + * @param el - Search result element + * + * @return Operator function + */ +export function paintSearchResult( + el: HTMLElement, { size$ }: Options +): MonoTypeOperatorFunction { + const container = el.parentElement! + + /* Compute whether the container is near the bottom offset */ + const render$ = watchElementOffset(container, { size$ }) + .pipe( + map(({ y }) => y >= container.scrollHeight - container.offsetHeight - 16), + distinctUntilChanged(), + filter(identity) + ) + + /* Paint search results lazily */ + const [meta, list] = Array.from(el.children) as HTMLElement[] + return pipe( + switchMap(result => render$ + .pipe( + + /* Defer repaint to next animation frame */ + observeOn(animationFrameScheduler), + scan(index => { + while (index < result.length) { + addToSearchResultList(list, renderSearchResult(result[index++])) + if (container.scrollHeight - container.offsetHeight > 16) + break + } + return index + }, 0), + mapTo(result), + + /* Reset on complete or error */ + finalize(() => { + resetSearchResultList(list) + }) + ) + ) + ) +} diff --git a/src/assets/javascripts/templates/search/_/index.tsx b/src/assets/javascripts/templates/search/_/index.tsx index dfddf7790..10da973af 100644 --- a/src/assets/javascripts/templates/search/_/index.tsx +++ b/src/assets/javascripts/templates/search/_/index.tsx @@ -20,7 +20,7 @@ * IN THE SOFTWARE. */ -import { h, toElement } from "extensions" +import { h, toHTMLElement } from "extensions" import { SearchResult } from "modules" import { renderArticleDocument } from "../article" @@ -46,12 +46,12 @@ const css = { * * @param article - Search result * - * @return Element + * @return HTML element */ export function renderSearchResult( { article, sections }: SearchResult -): Element { - return toElement( +): HTMLElement { + return toHTMLElement(
  • {renderArticleDocument(article)} {...sections.map(renderSectionDocument)} diff --git a/src/assets/javascripts/templates/search/article/index.tsx b/src/assets/javascripts/templates/search/article/index.tsx index ba51108e9..d6dd375da 100644 --- a/src/assets/javascripts/templates/search/article/index.tsx +++ b/src/assets/javascripts/templates/search/article/index.tsx @@ -20,7 +20,7 @@ * IN THE SOFTWARE. */ -import { h, toElement } from "extensions" +import { h, toHTMLElement } from "extensions" import { ArticleDocument } from "modules" import { truncate } from "utilities" @@ -47,12 +47,12 @@ const css = { * * @param article - Article document * - * @return Element + * @return HTML element */ export function renderArticleDocument( { location, title, text }: ArticleDocument -): Element { - return toElement( +): HTMLElement { + return toHTMLElement(

    {title}

    diff --git a/src/assets/javascripts/templates/search/section/index.tsx b/src/assets/javascripts/templates/search/section/index.tsx index 43905d53d..706b56ff7 100644 --- a/src/assets/javascripts/templates/search/section/index.tsx +++ b/src/assets/javascripts/templates/search/section/index.tsx @@ -20,7 +20,7 @@ * IN THE SOFTWARE. */ -import { h, toElement } from "extensions" +import { h, toHTMLElement } from "extensions" import { SectionDocument } from "modules" import { truncate } from "utilities" @@ -47,12 +47,12 @@ const css = { * * @param section - Section document * - * @return Element + * @return HTML element */ export function renderSectionDocument( { location, title, text }: SectionDocument -): Element { - return toElement( +): HTMLElement { + return toHTMLElement(

    {title}

    diff --git a/src/assets/javascripts/utilities/agent/element/_/index.ts b/src/assets/javascripts/utilities/agent/element/_/index.ts new file mode 100644 index 000000000..2b549dfb1 --- /dev/null +++ b/src/assets/javascripts/utilities/agent/element/_/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2016-2019 Martin Donath + * + * 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. + */ + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Retrieve an element matching the query selector + * + * @template T - Element type + * + * @param selector - Query selector + * @param node - Node of reference + * + * @return Element + */ +export function getElement( + selector: string, node: ParentNode = document +): T | undefined { + return node.querySelector(selector) || undefined +} + +/** + * Retrieve all elements matching the query selector + * + * @template T - Element type + * + * @param selector - Query selector + * @param node - Node of reference + * + * @return Elements + */ +export function getElements( + selector: string, node: ParentNode = document +): T[] { + return Array.from(node.querySelectorAll(selector)) +} diff --git a/src/assets/javascripts/utilities/agent/element/index.ts b/src/assets/javascripts/utilities/agent/element/index.ts index 2b549dfb1..37f6c44bb 100644 --- a/src/assets/javascripts/utilities/agent/element/index.ts +++ b/src/assets/javascripts/utilities/agent/element/index.ts @@ -20,38 +20,5 @@ * IN THE SOFTWARE. */ -/* ---------------------------------------------------------------------------- - * Functions - * ------------------------------------------------------------------------- */ - -/** - * Retrieve an element matching the query selector - * - * @template T - Element type - * - * @param selector - Query selector - * @param node - Node of reference - * - * @return Element - */ -export function getElement( - selector: string, node: ParentNode = document -): T | undefined { - return node.querySelector(selector) || undefined -} - -/** - * Retrieve all elements matching the query selector - * - * @template T - Element type - * - * @param selector - Query selector - * @param node - Node of reference - * - * @return Elements - */ -export function getElements( - selector: string, node: ParentNode = document -): T[] { - return Array.from(node.querySelectorAll(selector)) -} +export * from "./_" +export * from "./offset" diff --git a/src/assets/javascripts/utilities/agent/element/offset/index.ts b/src/assets/javascripts/utilities/agent/element/offset/index.ts new file mode 100644 index 000000000..e46a1aac9 --- /dev/null +++ b/src/assets/javascripts/utilities/agent/element/offset/index.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2016-2019 Martin Donath + * + * 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, fromEvent, merge } from "rxjs" +import { map, shareReplay, startWith } from "rxjs/operators" + +import { ViewportSize } from "../../viewport" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Element offset + */ +export interface ElementOffset { + x: number /* Horizontal offset */ + y: number /* Vertical offset */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Options + */ +interface Options { + size$: Observable /* Viewport size observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Retrieve element offset + * + * @param el - HTML element + * + * @return Element offset + */ +export function getElementOffset(el: HTMLElement): ElementOffset { + return { + x: el.scrollLeft, + y: el.scrollTop + } +} + +/* ------------------------------------------------------------------------- */ + +/** + * Watch element offset + * + * @paramel - Element + * @param options - Options + * + * @return Element offset observable + */ +export function watchElementOffset( + el: HTMLElement, { size$ }: Options +): Observable { + const scroll$ = fromEvent(el, "scroll") + return merge(scroll$, size$) + .pipe( + map(() => getElementOffset(el)), + startWith(getElementOffset(el)), + shareReplay(1) + ) +} diff --git a/src/assets/javascripts/utilities/agent/worker/index.ts b/src/assets/javascripts/utilities/agent/worker/index.ts index 7cb0bd2e7..50918b721 100644 --- a/src/assets/javascripts/utilities/agent/worker/index.ts +++ b/src/assets/javascripts/utilities/agent/worker/index.ts @@ -45,7 +45,7 @@ export interface WorkerMessage { * @template T - Worker message type */ interface Options { - message$: Observable /* Message observable */ + send$: Observable /* Message observable */ } /* ---------------------------------------------------------------------------- @@ -65,22 +65,22 @@ interface Options { * @return Worker message observable */ export function watchWorker( - worker: Worker, { message$ }: Options + worker: Worker, { send$ }: Options ): Observable { /* Observable for messages from web worker */ - const worker$ = fromEvent(worker, "message") + const recv$ = fromEvent(worker, "message") .pipe( pluck("data"), share() ) /* Send and receive messages, return hot observable */ - return message$ + return send$ .pipe( - throttle(() => worker$, { leading: true, trailing: true }), + throttle(() => recv$, { leading: true, trailing: true }), tap(message => worker.postMessage(message)), - switchMapTo(worker$), + switchMapTo(recv$), share() ) } diff --git a/src/assets/javascripts/utilities/string/index.ts b/src/assets/javascripts/utilities/string/index.ts index 030b97da1..098b8c6b7 100644 --- a/src/assets/javascripts/utilities/string/index.ts +++ b/src/assets/javascripts/utilities/string/index.ts @@ -35,7 +35,7 @@ export function truncate(string: string, n: number): string { let i = n if (string.length > i) { - while (string[i] !== " " && --i > 0); + while (string[i] !== " " && --i > 0); // tslint:disable-line return `${string.substring(0, i)}...` } return string