Implemented lazy rendering of search results

This commit is contained in:
squidfunk 2019-12-19 14:05:00 +01:00
parent 6b1ff5ef1d
commit a18ac26f59
15 changed files with 350 additions and 59 deletions

View File

@ -22,4 +22,5 @@
export * from "./header" export * from "./header"
export * from "./hidden" export * from "./hidden"
export * from "./search"
export * from "./sidebar" export * from "./sidebar"

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2016-2019 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 "./result"

View File

@ -0,0 +1,48 @@
/*
* Copyright (c) 2016-2019 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.
*/
/* ----------------------------------------------------------------------------
* 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 = ""
}

View File

@ -54,12 +54,12 @@ interface Options {
* This function returns an observable that computes the relative offset to the * This function returns an observable that computes the relative offset to the
* top of the given element based on the current viewport offset. * top of the given element based on the current viewport offset.
* *
* @param el - Element * @param el - HTML element
* @param options - Options * @param options - Options
* *
* @return Viewport offset observable * @return Viewport offset observable
*/ */
export function watchTopOffset( export function watchHeaderOffsetToTopOf(
el: HTMLElement, { size$, offset$, header$ }: Options el: HTMLElement, { size$, offset$, header$ }: Options
): Observable<ViewportOffset> { ): Observable<ViewportOffset> {
@ -85,12 +85,12 @@ export function watchTopOffset(
* This function returns an observable that computes the relative offset to the * This function returns an observable that computes the relative offset to the
* bottom of the given element based on the current viewport offset. * bottom of the given element based on the current viewport offset.
* *
* @param el - Element * @param el - HTML element
* @param options - Options * @param options - Options
* *
* @return Viewport offset observable * @return Viewport offset observable
*/ */
export function watchBottomOffset( export function watchHeaderOffsetToBottomOf(
el: HTMLElement, { size$, offset$, header$ }: Options el: HTMLElement, { size$, offset$, header$ }: Options
): Observable<ViewportOffset> { ): Observable<ViewportOffset> {

View File

@ -22,3 +22,4 @@
// export * from "./query" // export * from "./query"
export * from "./reset" export * from "./reset"
export * from "./result"

View File

@ -19,4 +19,3 @@
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */

View File

@ -0,0 +1,106 @@
/*
* Copyright (c) 2016-2019 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 { 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<ViewportSize> /* 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<SearchResult[]> {
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)
})
)
)
)
}

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
import { h, toElement } from "extensions" import { h, toHTMLElement } from "extensions"
import { SearchResult } from "modules" import { SearchResult } from "modules"
import { renderArticleDocument } from "../article" import { renderArticleDocument } from "../article"
@ -46,12 +46,12 @@ const css = {
* *
* @param article - Search result * @param article - Search result
* *
* @return Element * @return HTML element
*/ */
export function renderSearchResult( export function renderSearchResult(
{ article, sections }: SearchResult { article, sections }: SearchResult
): Element { ): HTMLElement {
return toElement( return toHTMLElement(
<li class={css.item}> <li class={css.item}>
{renderArticleDocument(article)} {renderArticleDocument(article)}
{...sections.map(renderSectionDocument)} {...sections.map(renderSectionDocument)}

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
import { h, toElement } from "extensions" import { h, toHTMLElement } from "extensions"
import { ArticleDocument } from "modules" import { ArticleDocument } from "modules"
import { truncate } from "utilities" import { truncate } from "utilities"
@ -47,12 +47,12 @@ const css = {
* *
* @param article - Article document * @param article - Article document
* *
* @return Element * @return HTML element
*/ */
export function renderArticleDocument( export function renderArticleDocument(
{ location, title, text }: ArticleDocument { location, title, text }: ArticleDocument
): Element { ): HTMLElement {
return toElement( return toHTMLElement(
<a href={location} title={title} class={css.link} tabIndex={-1}> <a href={location} title={title} class={css.link} tabIndex={-1}>
<article class={css.article}> <article class={css.article}>
<h1 class={css.title}>{title}</h1> <h1 class={css.title}>{title}</h1>

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
import { h, toElement } from "extensions" import { h, toHTMLElement } from "extensions"
import { SectionDocument } from "modules" import { SectionDocument } from "modules"
import { truncate } from "utilities" import { truncate } from "utilities"
@ -47,12 +47,12 @@ const css = {
* *
* @param section - Section document * @param section - Section document
* *
* @return Element * @return HTML element
*/ */
export function renderSectionDocument( export function renderSectionDocument(
{ location, title, text }: SectionDocument { location, title, text }: SectionDocument
): Element { ): HTMLElement {
return toElement( return toHTMLElement(
<a href={location} title={title} class={css.link} tabIndex={-1}> <a href={location} title={title} class={css.link} tabIndex={-1}>
<article class={css.article}> <article class={css.article}>
<h1 class={css.title}>{title}</h1> <h1 class={css.title}>{title}</h1>

View File

@ -0,0 +1,57 @@
/*
* Copyright (c) 2016-2019 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.
*/
/* ----------------------------------------------------------------------------
* 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<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T | undefined {
return node.querySelector<T>(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<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T[] {
return Array.from(node.querySelectorAll<T>(selector))
}

View File

@ -20,38 +20,5 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
/* ---------------------------------------------------------------------------- export * from "./_"
* Functions export * from "./offset"
* ------------------------------------------------------------------------- */
/**
* 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<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T | undefined {
return node.querySelector<T>(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<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T[] {
return Array.from(node.querySelectorAll<T>(selector))
}

View File

@ -0,0 +1,89 @@
/*
* Copyright (c) 2016-2019 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, 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<ViewportSize> /* 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<ElementOffset> {
const scroll$ = fromEvent(el, "scroll")
return merge(scroll$, size$)
.pipe(
map(() => getElementOffset(el)),
startWith(getElementOffset(el)),
shareReplay(1)
)
}

View File

@ -45,7 +45,7 @@ export interface WorkerMessage {
* @template T - Worker message type * @template T - Worker message type
*/ */
interface Options<T extends WorkerMessage> { interface Options<T extends WorkerMessage> {
message$: Observable<T> /* Message observable */ send$: Observable<T> /* Message observable */
} }
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
@ -65,22 +65,22 @@ interface Options<T extends WorkerMessage> {
* @return Worker message observable * @return Worker message observable
*/ */
export function watchWorker<T extends WorkerMessage>( export function watchWorker<T extends WorkerMessage>(
worker: Worker, { message$ }: Options<T> worker: Worker, { send$ }: Options<T>
): Observable<T> { ): Observable<T> {
/* Observable for messages from web worker */ /* Observable for messages from web worker */
const worker$ = fromEvent(worker, "message") const recv$ = fromEvent(worker, "message")
.pipe( .pipe(
pluck<Event, T>("data"), pluck<Event, T>("data"),
share() share()
) )
/* Send and receive messages, return hot observable */ /* Send and receive messages, return hot observable */
return message$ return send$
.pipe( .pipe(
throttle(() => worker$, { leading: true, trailing: true }), throttle(() => recv$, { leading: true, trailing: true }),
tap(message => worker.postMessage(message)), tap(message => worker.postMessage(message)),
switchMapTo(worker$), switchMapTo(recv$),
share() share()
) )
} }

View File

@ -35,7 +35,7 @@
export function truncate(string: string, n: number): string { export function truncate(string: string, n: number): string {
let i = n let i = n
if (string.length > i) { if (string.length > i) {
while (string[i] !== " " && --i > 0); while (string[i] !== " " && --i > 0); // tslint:disable-line
return `${string.substring(0, i)}...` return `${string.substring(0, i)}...`
} }
return string return string