Refactored entrypoint and observable setup

This commit is contained in:
squidfunk 2019-11-26 17:56:45 +01:00
parent 62ead4092f
commit a70a42cdd1
24 changed files with 350 additions and 1125 deletions

View File

@ -1,238 +0,0 @@
/*
* 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 { 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 { ViewportOffset, ViewportSize, getElement } from "../../../ui"
import { Header } from "../../header"
import {
resetAnchorActive,
resetAnchorBlur,
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<ViewportSize> /* Viewport size observable */
offset$: Observable<ViewportOffset> /* Viewport offset observable */
header$: Observable<Header> /* 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 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.
*
* @param els - Anchor elements
* @param options - Options
*
* @return Anchor list observable
*/
export function watchAnchorList(
els: HTMLAnchorElement[], { size$, offset$, header$ }: WatchOptions
): Observable<AnchorList> {
const table = new Map<HTMLAnchorElement, HTMLElement>()
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 done 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<HTMLAnchorElement[], number>())
}),
/* 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 repainting anchor list migrations.
* After determining which anchors need to be repainted, the actual rendering
* is deferred to the next animation frame.
*
* @param els - Anchor elements
*
* @return Operator function
*/
export function paintAnchorList(
els: HTMLAnchorElement[]
): MonoTypeOperatorFunction<AnchorList> {
return pipe(
/* Extract anchor list migrations only */
scan<AnchorList>((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 repaint to next animation frame */
observeOn(animationFrameScheduler),
tap(({ done, next }) => {
/* Look forward */
for (const [el] of next) {
resetAnchorActive(el)
resetAnchorBlur(el)
}
/* Look backward */
for (const [index, [el]] of done.entries()) {
setAnchorActive(el, index === done.length - 1)
setAnchorBlur(el, true)
}
}),
/* Reset on complete or error */
finalize(() => {
for (const el of els) {
resetAnchorActive(el)
resetAnchorBlur(el)
}
})
)
}

View File

@ -1,73 +0,0 @@
/*
* 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
* ------------------------------------------------------------------------- */
/**
* 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")
}

View File

@ -20,29 +20,29 @@
* IN THE SOFTWARE.
*/
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Configuration
*/
export interface Config {
base: string /* Base URL */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Set header shadow
* Ensure that the given value is a valid configuration
*
* @param el - Header element
* @param value - Whether the shadow is shown
*/
export function setHeaderShadow(
el: HTMLElement, value: boolean
): void {
el.setAttribute("data-md-state", value ? "shadow" : "")
}
/**
* Reset header shadow
* @param config - Configuration
*
* @param el - Header element
* @return Test result
*/
export function resetHeaderShadow(
el: HTMLElement
): void {
el.removeAttribute("data-md-state")
export function isConfig(config: any): config is Config {
return typeof config === "object"
&& typeof config.base === "string"
}

View File

@ -20,412 +20,128 @@
* IN THE SOFTWARE.
*/
import { findLast } from "ramda"
import {
NEVER,
animationFrameScheduler,
fromEvent,
merge,
of
} from "rxjs"
import { AjaxRequest, ajax } from "rxjs/ajax"
import {
delay,
filter,
map,
mapTo,
observeOn,
scan,
shareReplay,
switchMap,
tap
} from "rxjs/operators"
import "./polyfill"
import { shareReplay, switchMap } from "rxjs/operators"
import { isConfig } from "./config"
import {
paintAnchorList,
paintComponentMap,
paintHeaderShadow,
paintSidebar,
pluckComponent,
setNavigationOverflowScrolling,
watchAnchorList,
setupSidebar,
switchComponent,
switchMapIfActive,
watchComponentMap,
watchHeader,
watchMain,
watchNavigationIndex,
watchSidebar
} from "./component"
watchMain
} from "./theme"
import {
getElement,
getElements,
watchDocument,
watchDocumentSwitch,
watchLocation,
watchLocationHash,
watchLocationFragment,
watchMedia,
watchViewportOffset,
watchViewportSize,
withElement
watchViewportSize
} from "./ui"
import { toggle } from "./utilities"
// ----------------------------------------------------------------------------
// Disclaimer: this file is currently heavy WIP
// ----------------------------------------------------------------------------
// TBD
const offset$ = watchViewportOffset()
const size$ = watchViewportSize()
const names = [
"header", /* Header */
"title", /* Header title */
"search", /* Search */
"query", /* Search input */
"reset", /* Search reset */
"result", /* Search results */
"container", /* Container */
"main", /* Main area */
"hero", /* Hero */
"tabs", /* Tabs */
"navigation", /* Navigation */
"toc" /* Table of contents */
] as const // TODO: put this somewhere else... (merge with config!) JSON schema!?
const aboveScreen$ = watchMedia("(min-width: 1220px)")
const belowScreen$ = watchMedia("(max-width: 1219px)")
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
const aboveTablet$ = watchMedia("(min-width: 960px)")
const belowTablet$ = watchMedia("(max-width: 959px)")
/**
* Initialize Material for MkDocs
*
* @param config - Configuration
*/
export function initialize(config: unknown) {
if (!isConfig(config))
throw new SyntaxError(`Invalid configuration: ${JSON.stringify(config)}`)
// ----------------------------------------------------------------------------
/* ----------------------------------------------------------------------- */
// modernizr for the poor
document.documentElement.classList.remove("no-js")
document.documentElement.classList.add("js")
/* Create viewport observables */
const offset$ = watchViewportOffset()
const size$ = watchViewportSize()
// ----------------------------------------------------------------------------
/* Create media observables */
const screen$ = watchMedia("(min-width: 1220px)")
const tablet$ = watchMedia("(min-width: 960px)")
// Observable that resolves with document when loaded
const init$ = fromEvent(document, "DOMContentLoaded")
.pipe(
mapTo(document),
shareReplay({ bufferSize: 1, refCount: true })
)
/* Create location observables */
const url$ = watchLocation()
const fragment$ = watchLocationFragment()
// Location subject
const location$ = watchLocation()
/* Create document observables */
const load$ = watchDocument()
const switch$ = watchDocumentSwitch({ url$ })
// Observable that resolves with document on XHR load
const reload$ = location$
.pipe(
switchMap(url => load(url))
)
/* ----------------------------------------------------------------------- */
// Extract and (re-)paint components
const components$ = merge(init$, reload$)
.pipe(
switchMap(watchComponentMap),
paintComponentMap(),
shareReplay({ bufferSize: 1, refCount: true })
)
/* Create component map observable */
const components$ = watchComponentMap(names, { load$, switch$ })
// ----------------------------------------------------------------------------
const header$ = components$
.pipe(
pluckComponent("header"),
switchMap(watchHeader)
)
const main$ = components$
.pipe(
pluckComponent("main"),
switchMap(el => watchMain(el, { size$, offset$, header$ })),
shareReplay({ bufferSize: 1, refCount: true})
)
// ----------------------------------------------------------------------------
/* Component: sidebar with navigation */
components$
.pipe(
pluckComponent("navigation"),
switchMap(el => aboveScreen$
.pipe(
toggle(() => watchSidebar(el, { offset$, main$ })
.pipe(
paintSidebar(el)
)
)
))
)
.subscribe()
/* Component: sidebar with table of contents (missing on 404 page) */
components$
.pipe(
pluckComponent("toc"),
switchMap(el => aboveTablet$
.pipe(
toggle(() => watchSidebar(el, { offset$, main$ })
.pipe(
paintSidebar(el)
)
)
))
)
.subscribe()
/* Component: link blurring for table of contents */
components$
.pipe(
pluckComponent("toc"),
map(el => getElements<HTMLAnchorElement>(".md-nav__link", el)),
switchMap(els => aboveTablet$
.pipe(
toggle(() => watchAnchorList(els, { size$, offset$, header$ })
.pipe(
paintAnchorList(els)
)
)
)
)
)
.subscribe()
/* Component: header shadow toggle */
components$
.pipe(
pluckComponent("header"),
switchMap(el => main$.pipe(
paintHeaderShadow(el)
))
)
.subscribe()
// ----------------------------------------------------------------------------
// Refactor:
// ----------------------------------------------------------------------------
// Observable that catches all internal links without the necessity of rebinding
// as events are bubbled up through the DOM.
init$
.pipe(
switchMap(({ body }) => fromEvent(body, "click")),
switchMap(ev => {
/* Walk up as long as we're not in a details tag */
let parent = ev.target as Node | undefined
while (parent && !(parent instanceof HTMLAnchorElement))
parent = parent.parentNode // TODO: fix errors...
if (parent) { // this one OR (!) one of
// its parents...
if (!/(:\/\/|^#[^\/]+$)/.test(parent.getAttribute("href")!)) {
ev.preventDefault()
console.log("> ", parent.href)
// Extract URL; push to state, then emit new URL
const href = parent.href
history.pushState({}, "", href) // move this somewhere else!???
return of(href)
}
}
return NEVER
}),
shareReplay({ bufferSize: 1, refCount: true })
)
.subscribe(location$)
// ----------------------------------------------------------------------------
const nav2 = getElement("[data-md-component=navigation]")!
const index$ = watchNavigationIndex(nav2) // TODO: maybe rename into setup!? merge with sidebar?
belowScreen$
.pipe(
toggle(() => index$
.pipe(
switchMap(index => merge(...[...index.keys()]
.map(input => fromEvent(input, "change"))
)
.pipe(
mapTo(index)
)
),
map(index => getElement("ul", index.get(
findLast(input => input.checked, [...index.keys()])!)
)!), // find the TOP MOST! <-- this is the actively displayed on mobile
// this is the paint job...
// dispatch action - TODO: document why this crap is even necessary
scan((prev, next) => {
if (prev)
setNavigationOverflowScrolling(prev, false) // TODO: resetOverflowScrolling ....
return next
}),
delay(250),
tap(next => {
setNavigationOverflowScrolling(next, true) // setNavigationScrollfix
})
)
)
)
.subscribe()
// ----------------------------------------------------------------------------
function isNavigationCollapsible(el: HTMLElement): boolean {
return el.getAttribute("data-md-component") === "collapsible" // TODO: maybe better remove again
}
aboveScreen$
.pipe(
toggle(() => index$
.pipe(
// map(index => )
// filter shit from index...
switchMap(index => [...index.keys()]
.filter(input => isNavigationCollapsible(index.get(input)!))
.map(input => {
const el = index.get(input)!
// this doesnt work...
el.setAttribute("data-md-height", `${el.offsetHeight}`) // TODO: this is a hack
return input
})
.map(input => fromEvent(input, "change")
.pipe(
map(() => {
const el = index.get(input)!
let height = parseInt(el.getAttribute("data-md-height")!, 10)
// always goes from data-md-height... wrong...
if (!input.checked) {
el.style.maxHeight = `${height}px`
/* Set target height */
height = 0
} else {
el.style.maxHeight = "initial" // 100%!?
el.style.transitionDuration = "initial"
/* Retrieve target height */
height = el.offsetHeight
console.log("expand to height")
/* Reset state and set start height */
// el.removeAttribute("data-md-state")
el.style.maxHeight = "0px"
}
/* Force style recalculation */
el.offsetHeight // tslint:disable-line
el.style.transitionDuration = ""
return height
}), // max height is set... just read it.
observeOn(animationFrameScheduler),
tap(height => {
const el = index.get(input)!
// el.setAttribute("data-md-state", "animate")
el.style.maxHeight = `${height}px`
console.log("setting shit...")
el.setAttribute("data-md-height", `${height}`)
}),
delay(250),
tap(() => {
const el = index.get(input)!
console.log("DONE")
// el.removeAttribute("data-md-state")
el.style.maxHeight = ""
})
)
.subscribe() // merge shit and return it...
)
)
)
)
)
.subscribe()
// ----------------------------------------------------------------------------
/* Open details after anchor jump */
const hash$ = watchLocationHash()
hash$
.pipe(
withElement(), // TODO: somehow ugly... not so nice and cool
tap(el => {
let parent = el.parentNode
while (parent && !(parent instanceof HTMLDetailsElement)) // TODO: put this into a FUNCTION!
parent = parent.parentNode
/* If there's a details tag, open it */
if (parent && !parent.open) {
parent.open = true
/* Hack: force reload for repositioning */ // TODO. what happens here!?
const hash = location.hash
location.hash = " "
location.hash = hash // tslint:disable-line
// TODO: setLocationHash() + forceLocationHashChange
}
})
)
.subscribe()
// ----------------------------------------------------------------------------
// setupAnchorToggle?
const drawerToggle = getElement<HTMLInputElement>("[data-md-toggle=drawer]")!
const searchToggle = getElement<HTMLInputElement>("[data-md-toggle=search]")!
/* Listener: close drawer when anchor links are clicked */
hash$
.pipe(
tap(() => setToggle(drawerToggle, false))
)
.subscribe()
/* Listener: open search on focus */
const query = getElement("[data-md-component=query]")!
if (query) {
fromEvent(query, "focus")
/* Create header observable */
const header$ = components$
.pipe(
tap(() => setToggle(searchToggle, true))
switchComponent("header"),
switchMap(watchHeader)
)
/* Create main area observable */
const main$ = components$
.pipe(
switchComponent("main"),
switchMap(el => watchMain(el, { size$, offset$, header$ })),
shareReplay(1)
)
/* ----------------------------------------------------------------------- */
/* Create sidebar with navigation */
screen$
.pipe(
switchMapIfActive(() => components$ // TODO: write an observable creation function...
.pipe(
switchComponent("navigation"),
switchMap(el => setupSidebar(el, { offset$, main$ }))
)
)
)
.subscribe()
}
/* Listener: focus input after opening search */
fromEvent(searchToggle, "change")
.pipe(
filter(() => searchToggle.checked),
delay(400),
tap(() => query.focus())
)
.subscribe()
/* Create sidebar with table of contents (missing on 404 page) */
tablet$
.pipe(
switchMapIfActive(() => components$
.pipe(
switchComponent("toc"),
switchMap(el => setupSidebar(el, { offset$, main$ }))
)
)
)
.subscribe(console.log)
// data-md-toggle!
function setToggle(toggle: HTMLInputElement, active: boolean): void {
if (toggle.checked !== active) {
toggle.checked = active
toggle.dispatchEvent(new CustomEvent("change"))
/* Return all observables */
return {
ui: {
document: { load$, switch$ },
location: { url$, fragment$ },
media: { screen$, tablet$ },
viewport: { offset$, size$ }
}
}
}
// ----------------------------------------------------------------------------
// Asynchronously load a document
function load(url: string) {
const options: AjaxRequest = {
responseType: "document",
withCredentials: true
} // TODO: remove favicon from source!? patch...
return ajax({ url, ...options })
.pipe(
map(({ response }) => {
if (!(response instanceof Document)) // TODO: what to do in case of error?
throw Error("Unknown error...")
return response
})
)
}
// ----------------------------------------------------------------------------
// function isLocal(el: HTMLAnchorElement): boolean {
// return /(:\/\/|^#[^\/]+$)/.test(el.getAttribute("href")!)
// }
export function app(config: any) {
console.log("called app with", config)
}

View File

@ -1,81 +0,0 @@
/*
* 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.
*/
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Check if browser supports the `<details>` tag
*
* As this polyfill is executed at the end of `<body>`, 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 = "<summary>_</summary>_"
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", () => {
const details = summary.parentNode as HTMLElement
if (details.hasAttribute("open")) {
details.removeAttribute("open")
} else {
details.setAttribute("open", "")
}
})
})
})

View File

@ -1,24 +0,0 @@
/*
* 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 "./custom-event"
import "./details"

View File

@ -21,17 +21,10 @@
*/
import { keys } from "ramda"
import {
MonoTypeOperatorFunction,
NEVER,
Observable,
OperatorFunction,
of,
pipe
} from "rxjs"
import { scan, shareReplay, switchMap } from "rxjs/operators"
import { NEVER, Observable, OperatorFunction, merge, of, pipe } from "rxjs"
import { map, scan, shareReplay, switchMap } from "rxjs/operators"
import { getElement } from "../../ui"
import { getElement } from "../../utilities"
/* ----------------------------------------------------------------------------
* Types
@ -61,92 +54,82 @@ export type ComponentMap = {
[P in Component]?: HTMLElement
}
/* ----------------------------------------------------------------------------
* Function types
* ------------------------------------------------------------------------- */
/**
* Options
*/
interface Options {
load$: Observable<Document> /* Document observable */
switch$: Observable<Document> /* Document switch observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve the component to element mapping
* Watch component map
*
* The document must be passed as a parameter to support retrieving elements
* from the document object returned through asynchronous loading.
* This function returns an observable that will maintain bindings to the given
* components in-between document switches and update the document in-place.
*
* @param document - Document of reference
* @param names - Components
* @param options - Options
*
* @return Component map observable
*/
export function watchComponentMap(
document: Document
names: Component[], { load$, switch$ }: Options
): Observable<ComponentMap> {
const components$ = merge(load$, switch$)
.pipe(
/* Build component map */
const map$ = of([
"header", /* Header */
"title", /* Header title */
"search", /* Search */
"query", /* Search input */
"reset", /* Search reset */
"result", /* Search results */
"container", /* Container */
"main", /* Main area */
"hero", /* Hero */
"tabs", /* Tabs */
"navigation", /* Navigation */
"toc" /* Table of contents */
].reduce<ComponentMap>((map, name) => {
const el = getElement(`[data-md-component=${name}]`, document)
return {
...map,
...typeof el !== "undefined" ? { [name]: el } : {}
}
}, {}))
/* Build component map */
map(document => names.reduce<ComponentMap>((components, name) => {
const el = getElement(`[data-md-component=${name}]`, document)
return {
...components,
...typeof el !== "undefined" ? { [name]: el } : {}
}
}, {})),
/* Re-compute component map on document switch */
scan((prev, next) => {
for (const name of keys(prev)) {
switch (name) {
/* Top-level components: update */
case "title":
case "container":
if (name in prev && typeof prev[name] !== "undefined") {
prev[name]!.replaceWith(next[name]!)
prev[name] = next[name]
}
break
/* All other components: rebind */
default:
prev[name] = getElement(`[data-md-component=${name}]`)
}
}
return prev
})
)
/* Return component map as hot observable */
return map$
return components$
.pipe(
shareReplay({ bufferSize: 1, refCount: true })
shareReplay(1)
)
}
/* ------------------------------------------------------------------------- */
/**
* Paint component map from source observable
*
* This operator function will swap the components in the previous component
* map with the new components identified by the given names and rebind all
* remaining components, as they may be children of swapped components.
*
* @param names - Components to paint
*
* @return Operator function
*/
export function paintComponentMap(
names: Component[] = ["title", "container"]
): MonoTypeOperatorFunction<ComponentMap> {
return pipe(
scan<ComponentMap>((prev, next) => {
for (const name of keys(prev)) {
/* Swap component */
if (names.includes(name)) {
if (name in prev && typeof prev[name] !== "undefined") {
prev[name]!.replaceWith(next[name]!)
prev[name] = next[name]
}
/* Bind component */
} else {
prev[name] = getElement(`[data-md-component=${name}]`)
}
}
return prev
})
)
}
/**
* Pluck a component from the component map
* Switch to component
*
* @template T - Element type
*
@ -154,13 +137,13 @@ export function paintComponentMap(
*
* @return Operator function
*/
export function pluckComponent(
export function switchComponent<T extends HTMLElement>(
name: Component
): OperatorFunction<ComponentMap, HTMLElement> {
): OperatorFunction<ComponentMap, T> {
return pipe(
switchMap(map => {
return typeof map[name] !== "undefined"
? of(map[name]!)
switchMap(components => {
return typeof components[name] !== "undefined"
? of(components[name] as T)
: NEVER
})
)

View File

@ -20,19 +20,7 @@
* IN THE SOFTWARE.
*/
import { MonoTypeOperatorFunction, Observable, of, pipe } from "rxjs"
import {
distinctUntilKeyChanged,
finalize,
shareReplay,
tap
} from "rxjs/operators"
import { Main } from "../../main"
import {
resetHeaderShadow,
setHeaderShadow
} from "../element"
import { Observable, defer, of } from "rxjs"
/* ----------------------------------------------------------------------------
* Types
@ -51,7 +39,7 @@ export interface Header {
* ------------------------------------------------------------------------- */
/**
* Create an observable to watch the header
* Watch header
*
* The header is wrapped in an observable to pave the way for auto-hiding or
* other dynamic behaviors that may be implemented later on.
@ -63,38 +51,14 @@ export interface Header {
export function watchHeader(
el: HTMLElement
): Observable<Header> {
const sticky = getComputedStyle(el)
.getPropertyValue("position") === "fixed"
return defer(() => {
const sticky = getComputedStyle(el)
.getPropertyValue("position") === "fixed"
/* Return header as hot observable */
return of({
sticky,
height: sticky ? el.offsetHeight : 0
})
.pipe(
shareReplay({ bufferSize: 1, refCount: true })
)
}
/* ------------------------------------------------------------------------- */
/**
* Paint header shadow from source observable
*
* @param el - Header element
*
* @return Operator function
*/
export function paintHeaderShadow(
el: HTMLElement
): MonoTypeOperatorFunction<Main> {
return pipe(
distinctUntilKeyChanged("active"),
tap(({ active }) => {
setHeaderShadow(el, active)
}),
finalize(() => {
resetHeaderShadow(el)
/* Return header as hot observable */
return of({
sticky,
height: sticky ? el.offsetHeight : 0
})
)
})
}

View File

@ -21,4 +21,3 @@
*/
export * from "./_"
export * from "./element"

View File

@ -21,8 +21,6 @@
*/
export * from "./_"
export * from "./anchor"
export * from "./header"
export * from "./main"
export * from "./navigation"
export * from "./sidebar"

View File

@ -28,7 +28,7 @@ import {
shareReplay
} from "rxjs/operators"
import { ViewportOffset, ViewportSize } from "../../ui"
import { ViewportOffset, ViewportSize } from "../../../ui"
import { Header } from "../header"
/* ----------------------------------------------------------------------------
@ -49,9 +49,9 @@ export interface Main {
* ------------------------------------------------------------------------- */
/**
* Watch options
* Options
*/
interface WatchOptions {
interface Options {
size$: Observable<ViewportSize> /* Viewport size observable */
offset$: Observable<ViewportOffset> /* Viewport offset observable */
header$: Observable<Header> /* Header observable */
@ -62,7 +62,12 @@ interface WatchOptions {
* ------------------------------------------------------------------------- */
/**
* Create an observable to watch the main area
* Watch main area
*
* This function returns an observable that computes the visual parameters of
* the main area from the viewport height and vertical offset, as well as the
* height of the header element. The height of the main area is corrected by
* the height of the header (if fixed) and footer element.
*
* @param el - Main area element
* @param options - Options
@ -70,7 +75,7 @@ interface WatchOptions {
* @return Main area observable
*/
export function watchMain(
el: HTMLElement, { size$, offset$, header$ }: WatchOptions
el: HTMLElement, { size$, offset$, header$ }: Options
): Observable<Main> {
/* Compute necessary adjustment for header */
@ -80,7 +85,7 @@ export function watchMain(
)
/* Compute the main area's visible height */
const height$ = combineLatest(offset$, size$, adjust$)
const height$ = combineLatest([offset$, size$, adjust$])
.pipe(
map(([{ y }, { height }, adjust]) => {
const top = el.offsetTop
@ -93,20 +98,20 @@ export function watchMain(
)
/* Compute whether the viewport offset is past the main area's top */
const active$ = combineLatest(offset$, adjust$)
const active$ = combineLatest([offset$, adjust$])
.pipe(
map(([{ y }, adjust]) => y >= el.offsetTop - adjust),
distinctUntilChanged()
)
/* Combine into a single hot observable */
return combineLatest(height$, adjust$, active$)
return combineLatest([height$, adjust$, active$])
.pipe(
map(([height, adjust, active]) => ({
offset: el.offsetTop - adjust,
height,
active
})),
shareReplay({ bufferSize: 1, refCount: true })
shareReplay(1)
)
}

View File

@ -21,13 +21,7 @@
*/
import { equals } from "ramda"
import {
MonoTypeOperatorFunction,
Observable,
animationFrameScheduler,
combineLatest,
pipe
} from "rxjs"
import { Observable, animationFrameScheduler, combineLatest } from "rxjs"
import {
distinctUntilChanged,
finalize,
@ -37,7 +31,7 @@ import {
tap
} from "rxjs/operators"
import { ViewportOffset } from "../../../ui"
import { ViewportOffset } from "../../../../ui"
import { Main } from "../../main"
import {
resetSidebarHeight,
@ -63,9 +57,9 @@ export interface Sidebar {
* ------------------------------------------------------------------------- */
/**
* Watch options
* Options
*/
interface WatchOptions {
interface Options {
offset$: Observable<ViewportOffset> /* Viewport offset observable */
main$: Observable<Main> /* Main area observable */
}
@ -75,7 +69,12 @@ interface WatchOptions {
* ------------------------------------------------------------------------- */
/**
* Create an observable to watch a sidebar
* Watch sidebar
*
* This function returns an observable that computes the visual parameters of
* the given element (a sidebar) from the vertical viewport offset, as well as
* the height of the main area. When the page is scrolled beyond the header,
* the sidebar is locked and fills the remaining space.
*
* @param el - Sidebar element
* @param options - Options
@ -83,7 +82,7 @@ interface WatchOptions {
* @return Sidebar observable
*/
export function watchSidebar(
el: HTMLElement, { offset$, main$ }: WatchOptions
el: HTMLElement, { offset$, main$ }: Options
): Observable<Sidebar> {
/* Adjust for internal main area offset */
@ -93,7 +92,7 @@ export function watchSidebar(
)
/* Compute the sidebar's available height */
const height$ = combineLatest(offset$, main$)
const height$ = combineLatest([offset$, main$])
.pipe(
map(([{ y }, { offset, height }]) => {
return height - adjust + Math.min(adjust, Math.max(0, y - offset))
@ -101,45 +100,45 @@ export function watchSidebar(
)
/* Compute whether the sidebar should be locked */
const lock$ = combineLatest(offset$, main$)
const lock$ = combineLatest([offset$, main$])
.pipe(
map(([{ y }, { offset }]) => y >= offset + adjust)
)
/* Combine into single hot observable */
return combineLatest(height$, lock$)
return combineLatest([height$, lock$])
.pipe(
map(([height, lock]) => ({ height, lock })),
distinctUntilChanged<Sidebar>(equals),
shareReplay({ bufferSize: 1, refCount: true })
shareReplay(1)
)
}
/* ------------------------------------------------------------------------- */
/**
* Paint sidebar from source observable
* Setup sidebar
*
* @param el - Sidebar element
* @param options - Options
*
* @return Operator function
* @return Sidebar observable
*/
export function paintSidebar(
el: HTMLElement
): MonoTypeOperatorFunction<Sidebar> {
return pipe(
export function setupSidebar(
el: HTMLElement, options: Options
): Observable<Sidebar> {
return watchSidebar(el, options)
.pipe(
observeOn(animationFrameScheduler),
/* Defer repaint to next animation frame */
observeOn(animationFrameScheduler),
tap(({ height, lock }) => {
setSidebarHeight(el, height)
setSidebarLock(el, lock)
}),
/* Apply mutations (side effects) */
tap(({ height, lock }) => {
setSidebarHeight(el, height)
setSidebarLock(el, lock)
}),
/* Reset on complete or error */
finalize(() => {
resetSidebarHeight(el)
resetSidebarLock(el)
})
)
/* Reset on complete or error */
finalize(() => {
resetSidebarHeight(el)
resetSidebarLock(el)
})
)
}

View File

@ -20,4 +20,6 @@
* IN THE SOFTWARE.
*/
import "custom-event-polyfill"
export * from "./component"
export * from "./utilities"
// export * from "./worker"

View File

@ -20,11 +20,6 @@
* IN THE SOFTWARE.
*/
import { OperatorFunction, pipe } from "rxjs"
import { filter, map } from "rxjs/operators"
import { toArray } from "../../utilities"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@ -58,44 +53,5 @@ export function getElement<T extends HTMLElement>(
export function getElements<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T[] {
return toArray(node.querySelectorAll<T>(selector))
}
/* ----------------------------------------------------------------------------
* Operators
* ------------------------------------------------------------------------- */
/**
* Retrieve an element matching the query selector
*
* @template T - Element type
*
* @param node - Node of reference
*
* @return Operator function
*/
export function withElement<T extends HTMLElement>(
node: ParentNode = document
): OperatorFunction<string, T> {
return pipe(
map(selector => getElement<T>(selector, node)!),
filter<T>(Boolean)
)
}
/**
* Retrieve all elements matching the query selector
*
* @template T - Element type
*
* @param node - Node of reference
*
* @return Operator function
*/
export function withElements<T extends HTMLElement>(
node: ParentNode = document
): OperatorFunction<string, T[]> {
return pipe(
map(selector => getElements<T>(selector, node))
)
return Array.from(node.querySelectorAll<T>(selector))
}

View File

@ -20,5 +20,5 @@
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./element"
export * from "./operator"

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { NEVER, Observable, OperatorFunction, pipe } from "rxjs"
import { EMPTY, Observable, OperatorFunction, pipe } from "rxjs"
import { switchMap } from "rxjs/operators"
/* ----------------------------------------------------------------------------
@ -28,37 +28,35 @@ import { switchMap } from "rxjs/operators"
* ------------------------------------------------------------------------- */
/**
* Convert a collection to an array
*
* @template T - Element type
*
* @param collection - Collection or node list
*
* @return Array of elements
*/
export function toArray<
T extends HTMLElement
>(collection: HTMLCollection | NodeListOf<T>): T[] {
return Array.from(collection) as T[]
}
/* ----------------------------------------------------------------------------
* Operators
* ------------------------------------------------------------------------- */
/**
* Switch to another observable, if toggle is active
* Switch to another observable if source observable emits `true`
*
* @template T - Observable value type
*
* @param project - Project function
*
* @return Observable, if toggle is active
* @return Operator function
*/
export function toggle<T>(
project: () => Observable<T>
export function switchMapIfActive<T>(
project: (value: boolean) => Observable<T>
): OperatorFunction<boolean, T> {
return pipe(
switchMap(active => active ? project() : NEVER)
switchMap(value => value ? project(value) : EMPTY)
)
}
/**
* Switch to another observable if source observable emits `false`
*
* @template T - Observable value type
*
* @param project - Project function
*
* @return Operator function
*/
export function switchMapIfNotActive<T>(
project: (value: boolean) => Observable<T>
): OperatorFunction<boolean, T> {
return pipe(
switchMap(value => value ? EMPTY : project(value))
)
}

View File

@ -20,64 +20,86 @@
* IN THE SOFTWARE.
*/
import { Observable, of } from "rxjs"
import { shareReplay } from "rxjs/operators"
import { getElement, getElements } from "../../ui"
import { Observable, fromEvent } from "rxjs"
import { ajax } from "rxjs/ajax"
import {
distinctUntilChanged,
map,
mapTo,
pluck,
shareReplay,
skip,
startWith,
switchMap
} from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Types
* Function types
* ------------------------------------------------------------------------- */
/**
* Navigation index
* Switch options
*/
export type NavigationIndex = Map<HTMLInputElement, HTMLElement>
interface SwitchOptions {
url$: Observable<string> /* Location observable */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Observable for document load events
*/
const load$ = fromEvent(document, "DOMContentLoaded")
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Set navigation overflow scrolling
* Watch document
*
* @param el - Navigation element
* @param active - Whether overflow scrolling is active
* @return Document observable
*/
export function setNavigationOverflowScrolling(
el: HTMLElement, active: boolean
): void {
el.style.background = active ? "yellow" : "" // TODO: hack, temporary
el.style.webkitOverflowScrolling = active ? "touch" : ""
}
/* ------------------------------------------------------------------------- */
/**
* Create an observable to index all navigation elements
*
* @param el - Top-level navigation element
*
* @return Navigation index observable
*/
export function watchNavigationIndex(
el: HTMLElement
): Observable<NavigationIndex> {
const list = getElements("nav", el)
/* Build index to map inputs to navigation lists */
const index = new Map<HTMLInputElement, HTMLElement>()
for (const item of list) {
const label = getElement<HTMLLabelElement>("label", item)!
if (typeof label !== "undefined") {
const input = getElement<HTMLInputElement>(`#${label.htmlFor}`)!
index.set(input, item)
}
}
/* Return navigation index as hot observable */
return of(index)
export function watchDocument(): Observable<Document> {
return load$
.pipe(
shareReplay({ bufferSize: 1, refCount: true })
mapTo(document),
shareReplay(1)
)
}
/**
* Watch document switch
*
* This function returns an observables that fetches a document if the provided
* location observable emits a new value (i.e. URL). If the emitted URL points
* to the same page, the request is effectively ignored (e.g. when only the
* fragment identifier changes)
*
* @param options - Options
*
* @return Document switch observable
*/
export function watchDocumentSwitch(
{ url$ }: SwitchOptions
): Observable<Document> {
return url$
.pipe(
startWith(location.href),
map(url => url.replace(/#[^#]+$/, "")),
distinctUntilChanged(),
skip(1),
switchMap(url => ajax({
url,
responseType: "document",
withCredentials: true
})
.pipe<Document>(
pluck("response")
)
),
shareReplay(1)
)
}

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
export * from "./element"
export * from "./document"
export * from "./location"
export * from "./media"
export * from "./viewport"

View File

@ -21,7 +21,7 @@
*/
import { Observable, Subject, fromEvent } from "rxjs"
import { filter, map, share, startWith } from "rxjs/operators"
import { filter, map, share } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Data
@ -42,7 +42,7 @@ const popstate$ = fromEvent<PopStateEvent>(window, "popstate")
* ------------------------------------------------------------------------- */
/**
* Create a subject to watch or alter the location
* Watch location
*
* @return Location subject
*/
@ -50,7 +50,8 @@ export function watchLocation(): Subject<string> {
const location$ = new Subject<string>()
popstate$
.pipe(
map(() => location.href)
map(() => location.href),
share()
)
.subscribe(location$)
@ -59,15 +60,14 @@ export function watchLocation(): Subject<string> {
}
/**
* Create an observable to watch the location hash
* Watch location fragment
*
* @return Location hash observable
* @return Location fragment observable
*/
export function watchLocationHash(): Observable<string> {
export function watchLocationFragment(): Observable<string> {
return hashchange$
.pipe(
map(() => location.hash),
startWith(location.hash),
filter(hash => hash.length > 0),
share()
)

View File

@ -28,19 +28,19 @@ import { shareReplay, startWith } from "rxjs/operators"
* ------------------------------------------------------------------------- */
/**
* Create an observable to watch a media query
* Watch media query
*
* @param query - Media query
*
* @return Media observable
*/
export function watchMedia(query: string): Observable<boolean> {
const media = window.matchMedia(query)
const media = matchMedia(query)
return fromEventPattern<boolean>(next =>
media.addListener(() => next(media.matches))
)
.pipe(
startWith(media.matches),
shareReplay({ bufferSize: 1, refCount: true })
shareReplay(1)
)
}

View File

@ -62,33 +62,33 @@ export interface ViewportSize {
* ------------------------------------------------------------------------- */
/**
* Retrieve the viewport offset
* Retrieve viewport offset
*
* @return Viewport offset
*/
export function getViewportOffset(): ViewportOffset {
return {
x: window.pageXOffset,
y: window.pageYOffset
x: pageXOffset,
y: pageYOffset
}
}
/**
* Retrieve the viewport size
* Retrieve viewport size
*
* @return Viewport size
*/
export function getViewportSize(): ViewportSize {
return {
width: window.innerWidth,
height: window.innerHeight
width: innerWidth,
height: innerHeight
}
}
/* ------------------------------------------------------------------------- */
/**
* Create an observable to watch the viewport offset
* Watch viewport offset
*
* @return Viewport offset observable
*/
@ -97,12 +97,12 @@ export function watchViewportOffset(): Observable<ViewportOffset> {
.pipe(
map(getViewportOffset),
startWith(getViewportOffset()),
shareReplay({ bufferSize: 1, refCount: true })
shareReplay(1)
)
}
/**
* Create an observable to watch the viewport size
* Watch viewport size
*
* @return Viewport size observable
*/
@ -111,6 +111,6 @@ export function watchViewportSize(): Observable<ViewportSize> {
.pipe(
map(getViewportSize),
startWith(getViewportSize()),
shareReplay({ bufferSize: 1, refCount: true })
shareReplay(1)
)
}

View File

@ -1 +0,0 @@
export * from "./other"