From a03fa479b84a945b02b270c040e4c62911462f7f Mon Sep 17 00:00:00 2001 From: squidfunk Date: Tue, 29 Oct 2019 17:48:57 +0100 Subject: [PATCH] Refactored anchor blurring --- .../javascripts/component/anchor/index.ts | 181 ++++++++++++++++++ .../javascripts/component/container/index.ts | 12 +- src/assets/javascripts/component/index.ts | 2 + .../javascripts/component/sidebar/index.ts | 52 +++-- src/assets/javascripts/ui/element/index.ts | 19 +- src/assets/javascripts/ui/index.ts | 1 + src/assets/javascripts/ui/location/index.ts | 4 +- .../{viewport/breakpoint => media}/index.ts | 36 +--- src/assets/javascripts/ui/viewport/_/index.ts | 116 ----------- src/assets/javascripts/ui/viewport/index.ts | 96 +++++++++- src/assets/javascripts/utilities/index.ts | 24 +++ .../stylesheets/extensions/_permalinks.scss | 4 +- 12 files changed, 358 insertions(+), 189 deletions(-) create mode 100644 src/assets/javascripts/component/anchor/index.ts rename src/assets/javascripts/ui/{viewport/breakpoint => media}/index.ts (66%) delete mode 100644 src/assets/javascripts/ui/viewport/_/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..d496ec0f2 --- /dev/null +++ b/src/assets/javascripts/component/anchor/index.ts @@ -0,0 +1,181 @@ +/* + * 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 { reduce, reverse } from "ramda" +import { Observable } from "rxjs" +import { + distinctUntilChanged, + map, + scan, + shareReplay +} from "rxjs/operators" + +import { ViewportOffset, getElement } from "../../ui" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Anchors + */ +export interface Anchors { + past: HTMLAnchorElement[][] /* Past anchors */ + next: HTMLAnchorElement[][] /* Next anchors */ +} + +/* ---------------------------------------------------------------------------- + * Function types + * ------------------------------------------------------------------------- */ + +/** + * Options + */ +interface Options { + offset$: Observable /* Viewport offset observable */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Set anchor blur + * + * @param anchor - Anchor HTML element + * @param blur - Anchor blur + */ +export function setAnchorBlur( + anchor: HTMLAnchorElement, blur: boolean +): void { + anchor.setAttribute("data-md-state", blur ? "blur" : "") +} + +/** + * Set sidebar state lock + * + * @param anchor - Anchor HTML element + * @param active - Whether the anchor is active + */ +export function setAnchorActive( + anchor: HTMLAnchorElement, active: boolean +): void { + anchor.classList.toggle("md-nav__link--active", active) +} + +/** + * Reset anchor + * + * @param anchor - Anchor HTML element + */ +export function resetAnchor(anchor: HTMLAnchorElement) { + anchor.removeAttribute("data-md-state") + anchor.classList.remove("md-nav__link--active") +} + +/* ------------------------------------------------------------------------- */ + +/** + * Create an observable to monitor all anchors in respect to viewport offset + * + * @param anchors - Anchor elements + * @param header - Header element + * @param options - Options + * + * @return Sidebar observable + */ +export function watchAnchors( + anchors: HTMLAnchorElement[], header: HTMLElement, { offset$ }: Options +): Observable { + + /* Adjust for header offset if fixed */ + const adjust = getComputedStyle(header) + .getPropertyValue("position") === "fixed" + ? 18 + header.offsetHeight + : 18 + + /* Build index to map anchors to their targets */ + const index = new Map() + for (const anchor of anchors) { + const target = getElement(decodeURIComponent(anchor.hash)) + if (typeof target !== "undefined") + index.set(anchor, target) + } + + /* Build table to map anchor paths to vertical offsets */ + const table = new Map() + reduce((path, [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 + }, [] as HTMLAnchorElement[], [...index]) + + /* Compute partition of past and next anchors */ + const partition$ = offset$ + .pipe( + scan(([past, next], { y }) => { + y = y + adjust + + /* Look forward */ + while (next.length) { + const [, offset] = next[0] + if (offset < y) { + past = [...past, next.shift()!] + } else { + break + } + } + + /* Look backward */ + while (past.length) { + const [, offset] = past[past.length - 1] + if (offset >= y) { + next = [past.pop()!, ...next] + } else { + break + } + } + + /* Return new partition */ + return [past, next] + }, [[], [...table]]), + distinctUntilChanged(([a0, a1], [b0, b1]) => { + return a0 === b0 && a1 === b1 + }) + ) + + /* Extract anchors and return hot observable */ + return partition$ + .pipe( + map(([past, next]) => ({ + past: past.map(([els]) => els), + next: next.map(([els]) => els) + })), + shareReplay(1) + ) +} diff --git a/src/assets/javascripts/component/container/index.ts b/src/assets/javascripts/component/container/index.ts index 5c7839c15..52dec3c56 100644 --- a/src/assets/javascripts/component/container/index.ts +++ b/src/assets/javascripts/component/container/index.ts @@ -30,11 +30,11 @@ import { ViewportOffset, ViewportSize } from "../../ui" * ------------------------------------------------------------------------- */ /** - * Container state + * Container */ export interface Container { offset: number /* Container top offset */ - height: number /* Container height */ + height: number /* Container visible height */ active: boolean /* Scrolled past top offset */ } @@ -55,7 +55,7 @@ interface Options { * ------------------------------------------------------------------------- */ /** - * Create an observable for the container component + * Create an observable to monitor the container * * The container represents the main content area including the sidebars (table * of contents and navigation), as well as the actual page content. @@ -64,9 +64,9 @@ interface Options { * @param header - Header element * @param options - Options * - * @return Container state observable + * @return Container observable */ -export function fromContainer( +export function watchContainer( container: HTMLElement, header: HTMLElement, { size$, offset$ }: Options ): Observable { @@ -76,7 +76,7 @@ export function fromContainer( ? header.offsetHeight : 0 - /* Compute the container's available height */ + /* Compute the container's visible height */ const height$ = combineLatest(offset$, size$) .pipe( map(([{ y }, { height }]) => { diff --git a/src/assets/javascripts/component/index.ts b/src/assets/javascripts/component/index.ts index 57a09ff05..b413f801f 100644 --- a/src/assets/javascripts/component/index.ts +++ b/src/assets/javascripts/component/index.ts @@ -20,5 +20,7 @@ * IN THE SOFTWARE. */ +export * from "./anchor" export * from "./container" +export * from "./header" export * from "./sidebar" diff --git a/src/assets/javascripts/component/sidebar/index.ts b/src/assets/javascripts/component/sidebar/index.ts index 3373753e2..07c8bf844 100644 --- a/src/assets/javascripts/component/sidebar/index.ts +++ b/src/assets/javascripts/component/sidebar/index.ts @@ -31,7 +31,7 @@ import { Container } from "../container" * ------------------------------------------------------------------------- */ /** - * Sidebar state + * Sidebar */ export interface Sidebar { height: number /* Sidebar height */ @@ -46,7 +46,7 @@ export interface Sidebar { * Options */ interface Options { - container$: Observable /* Container state observable */ + container$: Observable /* Container observable */ offset$: Observable /* Viewport offset observable */ } @@ -60,51 +60,45 @@ interface Options { * @param sidebar - Sidebar HTML element * @param height - Sidebar height */ -export function setSidebarHeight(sidebar: HTMLElement, height: number): void { +export function setSidebarHeight( + sidebar: HTMLElement, height: number +): void { sidebar.style.height = `${height}px` } /** - * Unset sidebar height + * Set sidebar lock + * + * @param sidebar - Sidebar HTML element + * @param lock - Whether the sidebar is locked + */ +export function setSidebarLock( + sidebar: HTMLElement, lock: boolean +): void { + sidebar.setAttribute("data-md-state", lock ? "lock" : "") +} + +/** + * Reset sidebar * * @param sidebar - Sidebar HTML element */ -export function unsetSidebarHeight(sidebar: HTMLElement): void { +export function resetSidebar(sidebar: HTMLElement): void { + sidebar.removeAttribute("data-md-state") sidebar.style.height = "" } /* ------------------------------------------------------------------------- */ /** - * Set sidebar lock - * - * @param sidebar - Sidebar HTML element - * @param lock - Sidebar lock - */ -export function setSidebarLock(sidebar: HTMLElement, lock: boolean): void { - sidebar.setAttribute("data-md-state", lock ? "lock" : "") -} - -/** - * Unset sidebar lock - * - * @param sidebar - Sidebar HTML element - */ -export function unsetSidebarLock(sidebar: HTMLElement): void { - sidebar.removeAttribute("data-md-state") -} - -/* ------------------------------------------------------------------------- */ - -/** - * Create an observable for a sidebar component + * Create an observable to monitor the sidebar * * @param sidebar - Sidebar element * @param options - Options * - * @return Sidebar state observable + * @return Sidebar observable */ -export function fromSidebar( +export function watchSidebar( sidebar: HTMLElement, { container$, offset$ }: Options ): Observable { diff --git a/src/assets/javascripts/ui/element/index.ts b/src/assets/javascripts/ui/element/index.ts index 533ca5a46..83ca1b08f 100644 --- a/src/assets/javascripts/ui/element/index.ts +++ b/src/assets/javascripts/ui/element/index.ts @@ -59,7 +59,9 @@ export function getElements( return toArray(document.querySelectorAll(selector)) } -/* ------------------------------------------------------------------------- */ +/* ---------------------------------------------------------------------------- + * Operators + * ------------------------------------------------------------------------- */ /** * Retrieve an element matching the query selector @@ -76,3 +78,18 @@ export function withElement< filter(Boolean) ) } + +/** + * Retrieve all elements matching the query selector + * + * @template T - Element type + * + * @return HTML elements observable + */ +export function withElements< + T extends HTMLElement +>(): OperatorFunction { + return pipe( + map(selector => getElements(selector)) + ) +} diff --git a/src/assets/javascripts/ui/index.ts b/src/assets/javascripts/ui/index.ts index 72381fb55..2ff1e1c10 100644 --- a/src/assets/javascripts/ui/index.ts +++ b/src/assets/javascripts/ui/index.ts @@ -22,4 +22,5 @@ export * from "./element" export * from "./location" +export * from "./media" export * from "./viewport" diff --git a/src/assets/javascripts/ui/location/index.ts b/src/assets/javascripts/ui/location/index.ts index 28706823c..4b64b05b3 100644 --- a/src/assets/javascripts/ui/location/index.ts +++ b/src/assets/javascripts/ui/location/index.ts @@ -37,11 +37,11 @@ const hash$ = fromEvent(window, "hashchange") * ------------------------------------------------------------------------- */ /** - * Create an observable emitting changes in location hashes + * Create an observable to monitor the location hash * * @return Location hash observable */ -export function fromLocationHash(): Observable { +export function watchLocationHash(): Observable { return hash$ .pipe( map(() => document.location.hash), diff --git a/src/assets/javascripts/ui/viewport/breakpoint/index.ts b/src/assets/javascripts/ui/media/index.ts similarity index 66% rename from src/assets/javascripts/ui/viewport/breakpoint/index.ts rename to src/assets/javascripts/ui/media/index.ts index 510dd09d1..fcde3ed58 100644 --- a/src/assets/javascripts/ui/viewport/breakpoint/index.ts +++ b/src/assets/javascripts/ui/media/index.ts @@ -20,30 +20,21 @@ * IN THE SOFTWARE. */ -import { - Observable, - OperatorFunction, - fromEventPattern -} from "rxjs" -import { - filter, - shareReplay, - startWith, - windowToggle -} from "rxjs/operators" +import { Observable, fromEventPattern } from "rxjs" +import { shareReplay, startWith } from "rxjs/operators" /* ---------------------------------------------------------------------------- * Functions * ------------------------------------------------------------------------- */ /** - * Create an observable for a media query + * Create an observable from a media query * * @param query - Media query * - * @return Media query observable + * @return Media observable */ -export function fromMediaQuery(query: string): Observable { +export function watchMedia(query: string): Observable { const media = window.matchMedia(query) return fromEventPattern(next => media.addListener(() => next(media.matches)) @@ -53,20 +44,3 @@ export function fromMediaQuery(query: string): Observable { shareReplay(1) ) } - -/** - * Only emit values from the source observable when a media query matches - * - * @template T - Observable value type - * - * @param query - Media query observable - * - * @return Observable of source observable values - */ -export function withMediaQuery( - query$: Observable -): OperatorFunction> { - const start$ = query$.pipe(filter(match => match === true)) - const until$ = query$.pipe(filter(match => match === false)) - return windowToggle(start$, () => until$) -} diff --git a/src/assets/javascripts/ui/viewport/_/index.ts b/src/assets/javascripts/ui/viewport/_/index.ts deleted file mode 100644 index 7cc2c8af4..000000000 --- a/src/assets/javascripts/ui/viewport/_/index.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * 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" - -/* ---------------------------------------------------------------------------- - * Data - * ------------------------------------------------------------------------- */ - -/** - * Observable for window scroll events - */ -const scroll$ = fromEvent(window, "scroll") - -/** - * Observable for window resize events - */ -const resize$ = fromEvent(window, "resize") - -/* ---------------------------------------------------------------------------- - * Types - * ------------------------------------------------------------------------- */ - -/** - * Viewport offset - */ -export interface ViewportOffset { - x: number /* Horizontal offset */ - y: number /* Vertical offset */ -} - -/** - * Viewport size - */ -export interface ViewportSize { - width: number /* Viewport width */ - height: number /* Viewport height */ -} - -/* ---------------------------------------------------------------------------- - * Functions - * ------------------------------------------------------------------------- */ - -/** - * Retrieve the viewport offset - * - * @return Viewport offset - */ -export function getViewportOffset(): ViewportOffset { - return { - x: window.pageXOffset, - y: window.pageYOffset - } -} - -/** - * Retrieve the viewport size - * - * @return Viewport size - */ -export function getViewportSize(): ViewportSize { - return { - width: window.innerWidth, - height: window.innerHeight - } -} - -/* ------------------------------------------------------------------------- */ - -/** - * Create an observable emitting changes in viewport offset - * - * @return Viewport offset observable - */ -export function fromViewportOffset(): Observable { - return merge(scroll$, resize$) - .pipe( - map(getViewportOffset), - startWith(getViewportOffset()), - shareReplay(1) - ) -} - -/** - * Create an observable emitting changes in viewport size - * - * @return Viewport size observable - */ -export function fromViewportSize(): Observable { - return resize$ - .pipe( - map(getViewportSize), - startWith(getViewportSize()), - shareReplay(1) - ) -} diff --git a/src/assets/javascripts/ui/viewport/index.ts b/src/assets/javascripts/ui/viewport/index.ts index 4ff834d03..b5d4c2ece 100644 --- a/src/assets/javascripts/ui/viewport/index.ts +++ b/src/assets/javascripts/ui/viewport/index.ts @@ -20,5 +20,97 @@ * IN THE SOFTWARE. */ -export * from "./_" -export * from "./breakpoint" +import { Observable, fromEvent, merge } from "rxjs" +import { map, shareReplay, startWith } from "rxjs/operators" + +/* ---------------------------------------------------------------------------- + * Data + * ------------------------------------------------------------------------- */ + +/** + * Observable for window scroll events + */ +const scroll$ = fromEvent(window, "scroll") + +/** + * Observable for window resize events + */ +const resize$ = fromEvent(window, "resize") + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Viewport offset + */ +export interface ViewportOffset { + x: number /* Horizontal offset */ + y: number /* Vertical offset */ +} + +/** + * Viewport size + */ +export interface ViewportSize { + width: number /* Viewport width */ + height: number /* Viewport height */ +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Retrieve the viewport offset + * + * @return Viewport offset + */ +export function getViewportOffset(): ViewportOffset { + return { + x: window.pageXOffset, + y: window.pageYOffset + } +} + +/** + * Retrieve the viewport size + * + * @return Viewport size + */ +export function getViewportSize(): ViewportSize { + return { + width: window.innerWidth, + height: window.innerHeight + } +} + +/* ------------------------------------------------------------------------- */ + +/** + * Create an observable to monitor the viewport offset + * + * @return Viewport offset observable + */ +export function watchViewportOffset(): Observable { + return merge(scroll$, resize$) + .pipe( + map(getViewportOffset), + startWith(getViewportOffset()), + shareReplay(1) + ) +} + +/** + * Create an observable to monitor the viewport size + * + * @return Viewport size observable + */ +export function watchViewportSize(): Observable { + return resize$ + .pipe( + map(getViewportSize), + startWith(getViewportSize()), + shareReplay(1) + ) +} diff --git a/src/assets/javascripts/utilities/index.ts b/src/assets/javascripts/utilities/index.ts index e6115964a..def8550df 100644 --- a/src/assets/javascripts/utilities/index.ts +++ b/src/assets/javascripts/utilities/index.ts @@ -20,6 +20,9 @@ * IN THE SOFTWARE. */ +import { Observable, OperatorFunction } from "rxjs" +import { filter, windowToggle } from "rxjs/operators" + /* ---------------------------------------------------------------------------- * Functions * ------------------------------------------------------------------------- */ @@ -38,3 +41,24 @@ export function toArray< >(collection: HTMLCollection | NodeListOf): T[] { return Array.from(collection) as T[] } + +/* ---------------------------------------------------------------------------- + * Operators + * ------------------------------------------------------------------------- */ + +/** + * Toggle emission from a source observable + * + * @template T - Observable value type + * + * @param toggle$ - Toggle observable + * + * @return Observable of source observables + */ +export function toggle( + toggle$: Observable +): OperatorFunction> { + const start$ = toggle$.pipe(filter(match => match === true)) + const until$ = toggle$.pipe(filter(match => match === false)) + return windowToggle(start$, () => until$) +} diff --git a/src/assets/stylesheets/extensions/_permalinks.scss b/src/assets/stylesheets/extensions/_permalinks.scss index 51e797eff..f6c835c24 100644 --- a/src/assets/stylesheets/extensions/_permalinks.scss +++ b/src/assets/stylesheets/extensions/_permalinks.scss @@ -57,9 +57,9 @@ // Correct anchor offset for link blurring @each $level, $delta in ( - h1: 9px, + h1: 8px, h2: 8px, - h3: 9px, + h3: 8px, h4: 9px, h5: 11px, h6: 11px