diff --git a/package-lock.json b/package-lock.json index 57a9e27e2..927c4c450 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2166,6 +2166,11 @@ "array-find-index": "^1.0.1" } }, + "custom-event-polyfill": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz", + "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==" + }, "cyclist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", diff --git a/package.json b/package.json index cd8b763fb..fb850886e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "clipboard": "^2.0.0", + "custom-event-polyfill": "^1.0.7", "escape-html": "^1.0.3", "js-cookie": "^2.2.1", "lunr": "^2.3.6", diff --git a/src/assets/javascripts/component/anchor/index.ts b/src/assets/javascripts/component/anchor/index.ts index f19580035..020b2a4da 100644 --- a/src/assets/javascripts/component/anchor/index.ts +++ b/src/assets/javascripts/component/anchor/index.ts @@ -21,10 +21,11 @@ */ import { reduce, reverse } from "ramda" -import { Observable } from "rxjs" +import { Observable, combineLatest } from "rxjs" import { distinctUntilChanged, map, scan, shareReplay } from "rxjs/operators" import { ViewportOffset, getElement } from "../../ui" +import { Header } from "../header" /* ---------------------------------------------------------------------------- * Types @@ -43,10 +44,11 @@ export interface Anchors { * ------------------------------------------------------------------------- */ /** - * Options + * Watch options */ -interface Options { +interface WatchOptions { offset$: Observable /* Viewport offset observable */ + header$: Observable
/* Header observable */ } /* ---------------------------------------------------------------------------- @@ -92,28 +94,21 @@ export function resetAnchor(anchor: HTMLAnchorElement) { /** * Create an observable to monitor all anchors in respect to viewport offset * - * @param anchors - Anchor elements - * @param header - Header element + * @param els - Anchor elements * @param options - Options * * @return Anchors observable */ export function watchAnchors( - anchors: HTMLAnchorElement[], header: HTMLElement, { offset$ }: Options + els: HTMLAnchorElement[], { offset$, header$ }: WatchOptions ): 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)) + for (const el of els) { + const target = getElement(decodeURIComponent(el.hash)) if (typeof target !== "undefined") - index.set(anchor, target) + index.set(el, target) } /* Build table to map anchor paths to vertical offsets */ @@ -130,10 +125,16 @@ export function watchAnchors( return path }, [], [...index]) - /* Compute partition of done and next anchors */ - const partition$ = offset$ + /* Compute necessary adjustment for header */ + const adjust$ = header$ .pipe( - scan(([done, next], { y }) => { + 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) { @@ -168,8 +169,8 @@ export function watchAnchors( return partition$ .pipe( map(([done, next]) => ({ - done: done.map(([els]) => els), - next: next.map(([els]) => els) + done: done.map(([anchors]) => anchors), + next: next.map(([anchors]) => anchors) })), shareReplay({ bufferSize: 1, refCount: true }) ) diff --git a/src/assets/javascripts/component/container/index.ts b/src/assets/javascripts/component/container/index.ts index 4b30118b8..989b6f7b7 100644 --- a/src/assets/javascripts/component/container/index.ts +++ b/src/assets/javascripts/component/container/index.ts @@ -21,9 +21,10 @@ */ import { Observable, combineLatest } from "rxjs" -import { distinctUntilChanged, map, shareReplay } from "rxjs/operators" +import { distinctUntilChanged, map, pluck, shareReplay } from "rxjs/operators" import { ViewportOffset, ViewportSize } from "../../ui" +import { Header } from "../header" /* ---------------------------------------------------------------------------- * Types @@ -43,11 +44,12 @@ export interface Container { * ------------------------------------------------------------------------- */ /** - * Options + * Watch options */ -interface Options { +interface WatchOptions { size$: Observable /* Viewport size observable */ offset$: Observable /* Viewport offset observable */ + header$: Observable
/* Header observable */ } /* ---------------------------------------------------------------------------- @@ -60,28 +62,27 @@ interface Options { * The container represents the main content area including the sidebars (table * of contents and navigation), as well as the actual page content. * - * @param container - Container element - * @param header - Header element + * @param el - Container element * @param options - Options * * @return Container observable */ export function watchContainer( - container: HTMLElement, header: HTMLElement, { size$, offset$ }: Options + el: HTMLElement, { size$, offset$, header$ }: WatchOptions ): Observable { - /* Adjust for header offset if fixed */ - const adjust = getComputedStyle(header) - .getPropertyValue("position") === "fixed" - ? header.offsetHeight - : 0 + /* Compute necessary adjustment for header */ + const adjust$ = header$ + .pipe( + pluck("height") + ) /* Compute the container's visible height */ - const height$ = combineLatest(offset$, size$) + const height$ = combineLatest(offset$, size$, adjust$) .pipe( - map(([{ y }, { height }]) => { - const top = container.offsetTop - const bottom = container.offsetHeight + top + map(([{ y }, { height }, adjust]) => { + const top = el.offsetTop + const bottom = el.offsetHeight + top return height - Math.max(0, top - y, adjust) - Math.max(0, height + y - bottom) @@ -90,17 +91,17 @@ export function watchContainer( ) /* Compute whether the viewport offset is past the container's top */ - const active$ = offset$ + const active$ = combineLatest(offset$, adjust$) .pipe( - map(({ y }) => y >= container.offsetTop - adjust), + map(([{ y }, adjust]) => y >= el.offsetTop - adjust), distinctUntilChanged() ) /* Combine into a single hot observable */ - return combineLatest(height$, active$) + return combineLatest(height$, adjust$, active$) .pipe( - map(([height, active]) => ({ - offset: container.offsetTop - adjust, + map(([height, adjust, active]) => ({ + offset: el.offsetTop - adjust, height, active })), diff --git a/src/assets/javascripts/component/header/index.ts b/src/assets/javascripts/component/header/index.ts index 682b7e3da..899b10119 100644 --- a/src/assets/javascripts/component/header/index.ts +++ b/src/assets/javascripts/component/header/index.ts @@ -20,6 +20,21 @@ * IN THE SOFTWARE. */ +import { Observable, of } from "rxjs" +import { shareReplay } from "rxjs/operators" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Header + */ +export interface Header { + sticky: boolean /* Header stickyness */ + height: number /* Header visible height */ +} + /* ---------------------------------------------------------------------------- * Functions * ------------------------------------------------------------------------- */ @@ -44,3 +59,28 @@ export function setHeaderShadow( export function resetHeader(header: HTMLElement): void { header.removeAttribute("data-md-state") } + +/* ------------------------------------------------------------------------- */ + +/** + * Create an observable to monitor the header + * + * @param header - Header element + * + * @return Header observable + */ +export function watchHeader( + header: HTMLElement +): Observable
{ + const sticky = getComputedStyle(header) + .getPropertyValue("position") === "fixed" + + /* Return header as hot observable */ + return of({ + sticky, + height: sticky ? header.offsetHeight : 0 + }) + .pipe( + shareReplay({ bufferSize: 1, refCount: true }) + ) +} diff --git a/src/assets/javascripts/component/navigation/index.ts b/src/assets/javascripts/component/navigation/index.ts index 7366fff39..8838e19a7 100644 --- a/src/assets/javascripts/component/navigation/index.ts +++ b/src/assets/javascripts/component/navigation/index.ts @@ -74,7 +74,7 @@ export function watchNavigationIndex( } } - /* Return navigation index */ + /* Return navigation index as hot observable */ return of(index) .pipe( shareReplay({ bufferSize: 1, refCount: true }) diff --git a/src/assets/javascripts/component/sidebar/index.ts b/src/assets/javascripts/component/sidebar/index.ts index 7ea409833..4db3b49ba 100644 --- a/src/assets/javascripts/component/sidebar/index.ts +++ b/src/assets/javascripts/component/sidebar/index.ts @@ -44,9 +44,9 @@ export interface Sidebar { * ------------------------------------------------------------------------- */ /** - * Options + * Watch options */ -interface Options { +interface WatchOptions { container$: Observable /* Container observable */ offset$: Observable /* Viewport offset observable */ } @@ -58,35 +58,35 @@ interface Options { /** * Set sidebar height * - * @param sidebar - Sidebar HTML element + * @param el - Sidebar HTML element * @param height - Sidebar height */ export function setSidebarHeight( - sidebar: HTMLElement, height: number + el: HTMLElement, height: number ): void { - sidebar.style.height = `${height}px` + el.style.height = `${height}px` } /** * Set sidebar lock * - * @param sidebar - Sidebar HTML element + * @param el - Sidebar HTML element * @param lock - Whether the sidebar is locked */ export function setSidebarLock( - sidebar: HTMLElement, lock: boolean + el: HTMLElement, lock: boolean ): void { - sidebar.setAttribute("data-md-state", lock ? "lock" : "") + el.setAttribute("data-md-state", lock ? "lock" : "") } /** * Reset sidebar * - * @param sidebar - Sidebar HTML element + * @param el - Sidebar HTML element */ -export function resetSidebar(sidebar: HTMLElement): void { - sidebar.removeAttribute("data-md-state") - sidebar.style.height = "" +export function resetSidebar(el: HTMLElement): void { + el.removeAttribute("data-md-state") + el.style.height = "" } /* ------------------------------------------------------------------------- */ @@ -94,18 +94,18 @@ export function resetSidebar(sidebar: HTMLElement): void { /** * Create an observable to monitor the sidebar * - * @param sidebar - Sidebar element + * @param el - Sidebar element * @param options - Options * * @return Sidebar observable */ export function watchSidebar( - sidebar: HTMLElement, { container$, offset$ }: Options + el: HTMLElement, { container$, offset$ }: WatchOptions ): Observable { /* Adjust for internal container offset */ const adjust = parseFloat( - getComputedStyle(sidebar.parentElement!) + getComputedStyle(el.parentElement!) .getPropertyValue("padding-top") ) diff --git a/src/assets/javascripts/polyfill/custom-event/index.ts b/src/assets/javascripts/polyfill/custom-event/index.ts new file mode 100644 index 000000000..d9195c9a5 --- /dev/null +++ b/src/assets/javascripts/polyfill/custom-event/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. + */ + +import "custom-event-polyfill" diff --git a/src/assets/javascripts/polyfill/details/index.ts b/src/assets/javascripts/polyfill/details/index.ts new file mode 100644 index 000000000..34f5fdd51 --- /dev/null +++ b/src/assets/javascripts/polyfill/details/index.ts @@ -0,0 +1,81 @@ +/* + * 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. + */ + +/* ---------------------------------------------------------------------------- + * Helper functions + * ------------------------------------------------------------------------- */ + +/** + * Check if browser supports the `
` tag + * + * As this polyfill is executed at the end of ``, not all checks from the + * original source were necessary, so the script was stripped down a little. + * + * @see https://bit.ly/2O1teyP - Original source + * + * @return Test result + */ +function isSupported(): boolean { + const details = document.createElement("details") + if (!("open" in details)) + return false + + /* Insert summary and append element */ + details.innerHTML = "__" + details.style.display = "block" + document.body.appendChild(details) + + /* Measure height difference */ + const h0 = details.offsetHeight + details.open = true + const h1 = details.offsetHeight + + /* Remove element and return test result */ + document.body.removeChild(details) + return h1 - h0 !== 0 +} + +/* ---------------------------------------------------------------------------- + * Polyfill + * ------------------------------------------------------------------------- */ + +/* Execute polyfill when DOM is available */ +document.addEventListener("DOMContentLoaded", () => { + if (isSupported()) + return + + /* Indicate presence of details polyfill */ + document.documentElement.classList.add("no-details") + + /* Retrieve all summaries and polyfill open/close functionality */ + const summaries = document.querySelectorAll("details > summary") + summaries.forEach(summary => { + summary.addEventListener("click", ev => { + const details = summary.parentNode as HTMLElement + if (details.hasAttribute("open")) { + details.removeAttribute("open") + } else { + details.setAttribute("open", "") + } + }) + }) +}) diff --git a/src/assets/javascripts/polyfill/index.ts b/src/assets/javascripts/polyfill/index.ts new file mode 100644 index 000000000..3879dcefa --- /dev/null +++ b/src/assets/javascripts/polyfill/index.ts @@ -0,0 +1,24 @@ +/* + * 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 "./custom-event" +import "./details" diff --git a/src/assets/javascripts/ui/element/index.ts b/src/assets/javascripts/ui/element/index.ts index 83ca1b08f..fd789f377 100644 --- a/src/assets/javascripts/ui/element/index.ts +++ b/src/assets/javascripts/ui/element/index.ts @@ -35,13 +35,14 @@ import { toArray } from "../../utilities" * @template T - Element type * * @param selector - Query selector + * @param node - Node of reference * * @return HTML element */ export function getElement( - selector: string + selector: string, node: ParentNode = document ): T | undefined { - return document.querySelector(selector) || undefined + return node.querySelector(selector) || undefined } /** @@ -50,13 +51,14 @@ export function getElement( * @template T - Element type * * @param selector - Query selector + * @param node - Node of reference * * @return HTML elements */ export function getElements( - selector: string + selector: string, node: ParentNode = document ): T[] { - return toArray(document.querySelectorAll(selector)) + return toArray(node.querySelectorAll(selector)) } /* ---------------------------------------------------------------------------- @@ -68,13 +70,15 @@ export function getElements( * * @template T - Element type * + * @param node - Node of reference + * * @return HTML element observable */ -export function withElement< - T extends HTMLElement ->(): OperatorFunction { +export function withElement( + node: ParentNode = document +): OperatorFunction { return pipe( - map(selector => getElement(selector)!), + map(selector => getElement(selector, node)!), filter(Boolean) ) } @@ -84,12 +88,14 @@ export function withElement< * * @template T - Element type * + * @param node - Node of reference + * * @return HTML elements observable */ -export function withElements< - T extends HTMLElement ->(): OperatorFunction { +export function withElements( + node: ParentNode = document +): OperatorFunction { return pipe( - map(selector => getElements(selector)) + map(selector => getElements(selector, node)) ) } diff --git a/typings/dom.d.ts b/typings/dom.d.ts new file mode 100644 index 000000000..01a5b7d9e --- /dev/null +++ b/typings/dom.d.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +interface CSSStyleDeclaration { + webkitOverflowScrolling: "touch" | "" +}