From fc16b41d962714cfbda88ba3fbd6f56624f51113 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Fri, 20 Dec 2019 10:47:30 +0100 Subject: [PATCH] Added anchor list implementation --- .../javascripts/actions/anchor/index.ts | 73 ++++++ src/assets/javascripts/actions/index.ts | 1 + .../javascripts/components/anchor/index.ts | 246 ++++++++++++++++++ src/assets/javascripts/components/index.ts | 1 + 4 files changed, 321 insertions(+) create mode 100644 src/assets/javascripts/actions/anchor/index.ts create mode 100644 src/assets/javascripts/components/anchor/index.ts diff --git a/src/assets/javascripts/actions/anchor/index.ts b/src/assets/javascripts/actions/anchor/index.ts new file mode 100644 index 000000000..7f6d4eb92 --- /dev/null +++ b/src/assets/javascripts/actions/anchor/index.ts @@ -0,0 +1,73 @@ +/* + * 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" : "") +} + +/** + * Reset anchor blur + * + * @param el - Anchor element + */ +export function resetAnchorBlur( + el: HTMLElement +): void { + el.removeAttribute("data-md-state") +} + +/* ------------------------------------------------------------------------- */ + +/** + * 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 active + * + * @param el - Anchor element + */ +export function resetAnchorActive( + el: HTMLElement +): void { + el.classList.remove("md-nav__link--active") +} diff --git a/src/assets/javascripts/actions/index.ts b/src/assets/javascripts/actions/index.ts index 06ccbeced..654a1da88 100644 --- a/src/assets/javascripts/actions/index.ts +++ b/src/assets/javascripts/actions/index.ts @@ -20,6 +20,7 @@ * IN THE SOFTWARE. */ +export * from "./anchor" export * from "./header" export * from "./hidden" export * from "./search" diff --git a/src/assets/javascripts/components/anchor/index.ts b/src/assets/javascripts/components/anchor/index.ts new file mode 100644 index 000000000..a61f9ea79 --- /dev/null +++ b/src/assets/javascripts/components/anchor/index.ts @@ -0,0 +1,246 @@ +/* + * 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, + finalize, + map, + observeOn, + scan, + shareReplay, + switchMap, + tap +} from "rxjs/operators" + +import { + resetAnchorActive, + resetAnchorBlur, + setAnchorActive, + setAnchorBlur +} from "actions" +import { + ViewportOffset, + ViewportSize, + getElement +} from "utilities" + +import { Header } from "../header" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Anchor list + */ +export interface AnchorList { + prev: HTMLAnchorElement[][] /* Anchors (previous) */ + next: HTMLAnchorElement[][] /* Anchors (next) */ +} + +/* ---------------------------------------------------------------------------- + * Helper types + * ------------------------------------------------------------------------- */ + +/** + * Options + */ +interface Options { + size$: Observable /* Viewport size observable */ + offset$: Observable /* Viewport offset observable */ + header$: Observable
/* Header observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Watch 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 repainted. + * + * 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. + * + * Note that the current anchor is the last item of the `prev` anchor list. + * + * @param els - Anchor elements + * @param options - Options + * + * @return Anchor list observable + */ +export function watchAnchorList( + els: HTMLAnchorElement[], { size$, offset$, header$ }: Options +): Observable { + const table = new Map() + for (const el of els) { + const id = decodeURIComponent(el.hash.substring(1)) + const target = getElement(`[id="${id}"]`) + 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 previous and next anchors */ + const partition$ = size$ + .pipe( + + /* Build index 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(([prev, next], [{ y }, adjust]) => { + + /* Look forward */ + while (next.length) { + const [, offset] = next[0] + if (offset - adjust < y) { + prev = [...prev, next.shift()!] + } else { + break + } + } + + /* Look backward */ + while (prev.length) { + const [, offset] = prev[prev.length - 1] + if (offset - adjust >= y) { + next = [prev.pop()!, ...next] + } else { + break + } + } + + /* Return partition */ + return [prev, next] + }, [[], [...index]]), + distinctUntilChanged((a, b) => { + return a[0] === b[0] + && a[1] === b[1] + }) + ) + ) + ) + + /* Compute anchor list migrations */ + const migration$ = partition$ + .pipe( + map(([prev, next]) => ({ + prev: prev.map(([path]) => path), + next: next.map(([path]) => path) + })), + + /* Extract anchor list migrations */ + scan((a, b) => { + const begin = Math.max(0, Math.min(b.prev.length, a.prev.length) - 1) + const end = Math.max(b.prev.length, a.prev.length) + return { + prev: b.prev.slice(begin, end + 1), + next: difference(b.next, a.next) + } + }, { prev: [], next: [] }) + ) + + /* Return anchor list migrations as hot observable */ + return migration$ + .pipe( + shareReplay(1) + ) +} + +/* ------------------------------------------------------------------------- */ + +/** + * Paint anchor list from source observable + * + * @param els - Anchor elements + * + * @return Operator function + */ +export function paintAnchorList( + els: HTMLAnchorElement[] +): MonoTypeOperatorFunction { + return pipe( + + /* Defer repaint to next animation frame */ + observeOn(animationFrameScheduler), + tap(({ prev, next }) => { + + /* Look forward */ + for (const [el] of next) { + resetAnchorActive(el) + resetAnchorBlur(el) + } + + /* Look backward */ + for (const [index, [el]] of prev.entries()) { + setAnchorActive(el, index === prev.length - 1) + setAnchorBlur(el, true) + } + }), + + /* Reset on complete or error */ + finalize(() => { + for (const el of els) { + resetAnchorActive(el) + resetAnchorBlur(el) + } + }) + ) +} diff --git a/src/assets/javascripts/components/index.ts b/src/assets/javascripts/components/index.ts index f7d800721..21f645bd5 100644 --- a/src/assets/javascripts/components/index.ts +++ b/src/assets/javascripts/components/index.ts @@ -21,6 +21,7 @@ */ export * from "./_" +export * from "./anchor" export * from "./header" export * from "./hidden" export * from "./main"