From 5894b28571a609c82f3871dabe1411b72f1c86b1 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Wed, 20 Nov 2019 17:47:51 +0100 Subject: [PATCH] Moved re-painting logic to anchor component --- .../javascripts/component/anchor/_/index.ts | 221 ++++++++++++++++++ .../component/anchor/element/index.ts | 63 +++++ .../javascripts/component/anchor/index.ts | 157 +------------ src/assets/javascripts/ui/element/index.ts | 8 +- src/assets/javascripts/ui/location/index.ts | 2 +- src/assets/javascripts/ui/media/index.ts | 2 +- src/assets/javascripts/ui/viewport/index.ts | 4 +- src/assets/javascripts/utilities/index.ts | 8 +- 8 files changed, 298 insertions(+), 167 deletions(-) create mode 100644 src/assets/javascripts/component/anchor/_/index.ts create mode 100644 src/assets/javascripts/component/anchor/element/index.ts diff --git a/src/assets/javascripts/component/anchor/_/index.ts b/src/assets/javascripts/component/anchor/_/index.ts new file mode 100644 index 000000000..79f53a643 --- /dev/null +++ b/src/assets/javascripts/component/anchor/_/index.ts @@ -0,0 +1,221 @@ +/* + * 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 { difference, reverse } from "ramda" +import { + MonoTypeOperatorFunction, + Observable, + animationFrameScheduler, + combineLatest, + pipe +} from "rxjs" +import { + distinctUntilChanged, + map, + observeOn, + scan, + shareReplay, + switchMap, + tap +} from "rxjs/operators" + +import { ViewportOffset, ViewportSize, getElement } from "../../../ui" +import { Header } from "../../header" +import { + resetAnchor, + setAnchorActive, + setAnchorBlur +} from "../element" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Anchor list + */ +export interface AnchorList { + done: HTMLAnchorElement[][] /* Done anchors */ + next: HTMLAnchorElement[][] /* Next anchors */ +} + +/* ---------------------------------------------------------------------------- + * Function types + * ------------------------------------------------------------------------- */ + +/** + * Watch options + */ +interface WatchOptions { + size$: Observable /* Viewport size observable */ + offset$: Observable /* Viewport offset observable */ + header$: Observable
/* Header observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Create an observable to watch an anchor list + * + * This is effectively a scroll-spy implementation which will account for the + * fixed header and automatically re-calculate anchor offsets when the viewport + * is resized. The returned observable will only emit if the anchor list needs + * to be re-painted. + * + * This implementation tracks an anchor element's entire path starting from its + * level up to the top-most anchor element, e.g. `[h3, h2, h1]`. Although the + * Material theme currently doesn't make use of this information, it enables + * the styling of the entire hierarchy through customization. + * + * @param els - Anchor elements + * @param options - Options + * + * @return Anchor list observable + */ +export function watchAnchorList( + els: HTMLAnchorElement[], { size$, offset$, header$ }: WatchOptions +): Observable { + const table = new Map() + for (const el of els) { + const target = getElement(decodeURIComponent(el.hash)) + if (typeof target !== "undefined") + table.set(el, target) + } + + /* Compute necessary adjustment for header */ + const adjust$ = header$ + .pipe( + map(header => 18 + header.height) + ) + + /* Compute partition of done and next anchors */ + const partition$ = size$.pipe( + + /* Build table to map anchor paths to vertical offsets */ + map(() => { + let path: HTMLAnchorElement[] = [] + return [...table].reduce((index, [anchor, target]) => { + while (path.length) { + const last = table.get(path[path.length - 1])! + if (last.tagName >= target.tagName) { + path.pop() + } else { + break + } + } + return index.set( + reverse(path = [...path, anchor]), + target.offsetTop + ) + }, new Map()) + }), + + /* Re-compute partition when viewport offset changes */ + switchMap(index => combineLatest(offset$, adjust$) + .pipe( + scan(([done, next], [{ y }, adjust]) => { + + /* Look forward */ + while (next.length) { + const [, offset] = next[0] + if (offset - adjust < y) { + done = [...done, next.shift()!] + } else { + break + } + } + + /* Look backward */ + while (done.length) { + const [, offset] = done[done.length - 1] + if (offset - adjust >= y) { + next = [done.pop()!, ...next] + } else { + break + } + } + + /* Return partition */ + return [done, next] + }, [[], [...index]]), + distinctUntilChanged((a, b) => { + return a[0] === b[0] + && a[1] === b[1] + }) + ) + ) + ) + + /* Extract anchor list and return hot observable */ + return partition$ + .pipe( + map(([done, next]) => ({ + done: done.map(([path]) => path), + next: next.map(([path]) => path) + })), + shareReplay({ bufferSize: 1, refCount: true }) + ) +} + +/* ------------------------------------------------------------------------- */ + +/** + * Paint anchor list from source observable + * + * This operator function will keep track of the anchor list in-between emits + * in order to optimize rendering by only re-painting anchor list migrations. + * After determining which anchors need to be re-painted, the actual rendering + * is deferred to the next animation frame. + * + * @return Operator function + */ +export function paintAnchorList(): MonoTypeOperatorFunction { + return pipe( + + /* Extract anchor list migrations only */ + scan((a, b) => { + const begin = Math.max(0, Math.min(b.done.length, a.done.length) - 1) + const end = Math.max(b.done.length, a.done.length) + return { + done: b.done.slice(begin, end + 1), + next: difference(b.next, a.next) + } + }, { done: [], next: [] }), + + /* Defer re-paint to next animation frame */ + observeOn(animationFrameScheduler), + tap(({ done, next }) => { + + /* Look forward */ + for (const [el] of next) + resetAnchor(el) + + /* Look backward */ + for (const [index, [el]] of done.entries()) { + setAnchorBlur(el, true) + setAnchorActive(el, index === done.length - 1) + } + }) + ) +} diff --git a/src/assets/javascripts/component/anchor/element/index.ts b/src/assets/javascripts/component/anchor/element/index.ts new file mode 100644 index 000000000..4a362f9bb --- /dev/null +++ b/src/assets/javascripts/component/anchor/element/index.ts @@ -0,0 +1,63 @@ +/* + * 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 + * ------------------------------------------------------------------------- */ + +/** + * Set anchor blur + * + * @param el - Anchor element + * @param value - Whether the anchor is blurred + */ +export function setAnchorBlur( + el: HTMLElement, value: boolean +): void { + el.setAttribute("data-md-state", value ? "blur" : "") +} + +/** + * Set anchor active + * + * @param el - Anchor element + * @param value - Whether the anchor is active + */ +export function setAnchorActive( + el: HTMLElement, value: boolean +): void { + el.classList.toggle("md-nav__link--active", value) +} + +/* ------------------------------------------------------------------------- */ + +/** + * Reset anchor + * + * @param el - Anchor element + */ +export function resetAnchor( + el: HTMLElement +): void { + el.removeAttribute("data-md-state") + el.classList.remove("md-nav__link--active") +} diff --git a/src/assets/javascripts/component/anchor/index.ts b/src/assets/javascripts/component/anchor/index.ts index 20fc3cf59..634b1a4ff 100644 --- a/src/assets/javascripts/component/anchor/index.ts +++ b/src/assets/javascripts/component/anchor/index.ts @@ -20,158 +20,5 @@ * IN THE SOFTWARE. */ -import { reduce, reverse } from "ramda" -import { Observable, combineLatest } from "rxjs" -import { distinctUntilChanged, map, scan, shareReplay } from "rxjs/operators" - -import { ViewportOffset, getElement } from "../../ui" -import { Header } from "../header" - -/* ---------------------------------------------------------------------------- - * Types - * ------------------------------------------------------------------------- */ - -/** - * Anchors - */ -export interface Anchors { - done: HTMLAnchorElement[][] /* Done anchors */ - next: HTMLAnchorElement[][] /* Next anchors */ -} - -/* ---------------------------------------------------------------------------- - * Function types - * ------------------------------------------------------------------------- */ - -/** - * Watch options - */ -interface WatchOptions { - offset$: Observable /* Viewport offset observable */ - header$: Observable
/* Header observable */ -} - -/* ---------------------------------------------------------------------------- - * Functions - * ------------------------------------------------------------------------- */ - -/** - * Set anchor blur - * - * @param el - Anchor element - * @param blur - Anchor blur - */ -export function setAnchorBlur( - el: HTMLAnchorElement, blur: boolean -): void { - el.setAttribute("data-md-state", blur ? "blur" : "") -} - -/** - * Set anchor active - * - * @param el - Anchor element - * @param active - Whether the anchor is active - */ -export function setAnchorActive( - el: HTMLAnchorElement, active: boolean -): void { - el.classList.toggle("md-nav__link--active", active) -} - -/** - * Reset anchor - * - * @param el - Anchor element - */ -export function resetAnchor(el: HTMLAnchorElement) { - el.removeAttribute("data-md-state") - el.classList.remove("md-nav__link--active") -} - -/* ------------------------------------------------------------------------- */ - -/** - * Create an observable to monitor all anchors in respect to viewport offset - * - * @param els - Anchor elements - * @param options - Options - * - * @return Anchors observable - */ -export function watchAnchors( - els: HTMLAnchorElement[], { offset$, header$ }: WatchOptions -): Observable { - - /* Build index to map anchors to their targets */ - const index = new Map() - for (const el of els) { - const target = getElement(decodeURIComponent(el.hash)) - if (typeof target !== "undefined") - index.set(el, target) - } - - /* Build table to map anchor paths to vertical offsets */ - const table = new Map() - reduce((path: HTMLAnchorElement[], [anchor, target]) => { - while (path.length) { - const last = index.get(path[path.length - 1])! - if (last.tagName >= target.tagName) - path.pop() - else - break - } - table.set(reverse(path = [...path, anchor]), target.offsetTop) - return path - }, [], [...index]) - - /* Compute necessary adjustment for header */ - const adjust$ = header$ - .pipe( - map(header => 18 + header.height) - ) - - /* Compute partition of done and next anchors */ - const partition$ = combineLatest(offset$, adjust$) - .pipe( - scan(([done, next], [{ y }, adjust]) => { - - /* Look forward */ - while (next.length) { - const [, offset] = next[0] - if (offset - adjust < y) { - done = [...done, next.shift()!] - } else { - break - } - } - - /* Look backward */ - while (done.length) { - const [, offset] = done[done.length - 1] - if (offset - adjust >= y) { - next = [done.pop()!, ...next] - } else { - break - } - } - - /* Return partition */ - return [done, next] - }, [[], [...table]]), - distinctUntilChanged((a, b) => { - return a[0] === b[0] - && a[1] === b[1] - }) - ) - - /* Extract anchors and return hot observable */ - return partition$ - .pipe( - map(([done, next]) => ({ - done: done.map(([anchors]) => anchors), - next: next.map(([anchors]) => anchors) - })), - shareReplay({ bufferSize: 1, refCount: true }) - ) -} +export * from "./_" +export * from "./element" diff --git a/src/assets/javascripts/ui/element/index.ts b/src/assets/javascripts/ui/element/index.ts index fd789f377..e21cb60c1 100644 --- a/src/assets/javascripts/ui/element/index.ts +++ b/src/assets/javascripts/ui/element/index.ts @@ -37,7 +37,7 @@ import { toArray } from "../../utilities" * @param selector - Query selector * @param node - Node of reference * - * @return HTML element + * @return Element */ export function getElement( selector: string, node: ParentNode = document @@ -53,7 +53,7 @@ export function getElement( * @param selector - Query selector * @param node - Node of reference * - * @return HTML elements + * @return Elements */ export function getElements( selector: string, node: ParentNode = document @@ -72,7 +72,7 @@ export function getElements( * * @param node - Node of reference * - * @return HTML element observable + * @return Operator function */ export function withElement( node: ParentNode = document @@ -90,7 +90,7 @@ export function withElement( * * @param node - Node of reference * - * @return HTML elements observable + * @return Operator function */ export function withElements( node: ParentNode = document diff --git a/src/assets/javascripts/ui/location/index.ts b/src/assets/javascripts/ui/location/index.ts index 4b64b05b3..96fd14f52 100644 --- a/src/assets/javascripts/ui/location/index.ts +++ b/src/assets/javascripts/ui/location/index.ts @@ -37,7 +37,7 @@ const hash$ = fromEvent(window, "hashchange") * ------------------------------------------------------------------------- */ /** - * Create an observable to monitor the location hash + * Create an observable to watch the location hash * * @return Location hash observable */ diff --git a/src/assets/javascripts/ui/media/index.ts b/src/assets/javascripts/ui/media/index.ts index b8db72315..e01e523ac 100644 --- a/src/assets/javascripts/ui/media/index.ts +++ b/src/assets/javascripts/ui/media/index.ts @@ -28,7 +28,7 @@ import { shareReplay, startWith } from "rxjs/operators" * ------------------------------------------------------------------------- */ /** - * Create an observable from a media query + * Create an observable to watch a media query * * @param query - Media query * diff --git a/src/assets/javascripts/ui/viewport/index.ts b/src/assets/javascripts/ui/viewport/index.ts index 16d643866..f293622af 100644 --- a/src/assets/javascripts/ui/viewport/index.ts +++ b/src/assets/javascripts/ui/viewport/index.ts @@ -88,7 +88,7 @@ export function getViewportSize(): ViewportSize { /* ------------------------------------------------------------------------- */ /** - * Create an observable to monitor the viewport offset + * Create an observable to watch the viewport offset * * @return Viewport offset observable */ @@ -102,7 +102,7 @@ export function watchViewportOffset(): Observable { } /** - * Create an observable to monitor the viewport size + * Create an observable to watch the viewport size * * @return Viewport size observable */ diff --git a/src/assets/javascripts/utilities/index.ts b/src/assets/javascripts/utilities/index.ts index af6d87a57..1bc6f9039 100644 --- a/src/assets/javascripts/utilities/index.ts +++ b/src/assets/javascripts/utilities/index.ts @@ -28,13 +28,13 @@ import { switchMap } from "rxjs/operators" * ------------------------------------------------------------------------- */ /** - * Convert a HTML collection to an array + * Convert a collection to an array * - * @template T - HTML element type + * @template T - Element type * - * @param collection - HTML collection + * @param collection - Collection or node list * - * @return Array of HTML elements + * @return Array of elements */ export function toArray< T extends HTMLElement